Setup and context — The Gap Between a Working Prototype and a Shippable App
Antigravity can produce an Android prototype astonishingly fast. But between a prototype and a production app lies a deep chasm: error handling, offline support, memory management, testing, CI/CD. Skip these and your Play Store reviews will be full of "crashes constantly."
This article walks through building a production-quality Android app by collaborating with Antigravity. It assumes intermediate-to-advanced familiarity with Kotlin, Jetpack Compose, and Hilt.
Architecture: Adopting MVI
Why MVI Over MVVM
MVVM is the Android default, but as state complexity grows, tracking "which LiveData represents which UI state" becomes chaotic. MVI (Model-View-Intent) uses a single State object for the entire screen, making state transitions predictable and debuggable.
The first thing to tell Antigravity: "This project uses the MVI pattern." Put it in AGENTS.md and every subsequent code generation will conform.
// Core MVI type definitions
// Prompt: "Define MVI base interfaces with UiState, UiEvent, and UiEffect"
/** Screen state — the single source of truth for what the UI looks like */
interface UiState
/** User actions — button taps, swipes, text input */
interface UiEvent
/** One-shot side effects — Toast, navigation, Snackbar */
interface UiEffect
/**
* MVI base ViewModel.
* State: screen state, Event: user actions, Effect: one-shot side effects
*/
abstract class MviViewModel<State : UiState, Event : UiEvent, Effect : UiEffect>(
initialState: State
) : ViewModel() {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<State> = _state.asStateFlow()
private val _effect = Channel<Effect>(Channel.BUFFERED)
val effect: Flow<Effect> = _effect.receiveAsFlow()
protected fun updateState(reducer: State.() -> State) {
_state.update { it.reducer() }
}
protected fun sendEffect(effect: Effect) {
viewModelScope.launch { _effect.send(effect) }
}
abstract fun onEvent(event: Event)
}In Practice: Product List Screen
// State / Event / Effect for a product listing screen
data class ProductListState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: UiError? = null,
val searchQuery: String = "",
val hasNextPage: Boolean = true,
) : UiState
sealed interface ProductListEvent : UiEvent {
data object LoadInitial : ProductListEvent
data object LoadNextPage : ProductListEvent
data object Refresh : ProductListEvent
data class Search(val query: String) : ProductListEvent
data class TapProduct(val productId: String) : ProductListEvent
}
sealed interface ProductListEffect : UiEffect {
data class NavigateToDetail(val productId: String) : ProductListEffect
data class ShowError(val message: String) : ProductListEffect
}// ViewModel implementation
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val getProducts: GetProductsUseCase,
private val searchProducts: SearchProductsUseCase,
) : MviViewModel<ProductListState, ProductListEvent, ProductListEffect>(
initialState = ProductListState()
) {
private var currentPage = 0
override fun onEvent(event: ProductListEvent) {
when (event) {
is ProductListEvent.LoadInitial -> loadProducts(reset = true)
is ProductListEvent.LoadNextPage -> loadProducts(reset = false)
is ProductListEvent.Refresh -> refresh()
is ProductListEvent.Search -> search(event.query)
is ProductListEvent.TapProduct -> {
sendEffect(ProductListEffect.NavigateToDetail(event.productId))
}
}
}
private fun loadProducts(reset: Boolean) {
if (state.value.isLoading) return // prevent duplicate loads
viewModelScope.launch {
if (reset) currentPage = 0
updateState { copy(isLoading = true, error = null) }
getProducts(page = currentPage, query = state.value.searchQuery)
.onSuccess { result ->
currentPage++
updateState {
copy(
products = if (reset) result.items else products + result.items,
isLoading = false,
hasNextPage = result.hasNext,
)
}
}
.onFailure { e ->
updateState { copy(isLoading = false, error = e.toUiError()) }
sendEffect(ProductListEffect.ShowError(e.userMessage()))
}
}
}
private fun refresh() {
viewModelScope.launch {
updateState { copy(isRefreshing = true) }
currentPage = 0
getProducts(page = 0, query = state.value.searchQuery)
.onSuccess { result ->
currentPage = 1
updateState {
copy(
products = result.items,
isRefreshing = false,
hasNextPage = result.hasNext,
error = null,
)
}
}
.onFailure { e ->
updateState { copy(isRefreshing = false) }
sendEffect(ProductListEffect.ShowError(e.userMessage()))
}
}
}
private fun search(query: String) {
updateState { copy(searchQuery = query) }
loadProducts(reset = true)
}
}Jetpack Compose Performance Optimization
Diagnosing Recomposition Issues with Antigravity
The most common performance issue in production Compose apps is unnecessary recomposition. Hand Antigravity a Composable and ask it to diagnose.
// Prompt: "Diagnose recomposition issues in this Composable.
// Flag unstable parameters, lambda recreation, and missing derivedStateOf."
// ❌ Before: recomposes every frame
@Composable
fun ProductCard(
product: Product, // data class → stable ✓
cartItems: List<CartItem>, // List → unstable ✗
onAddToCart: (Product) -> Unit, // lambda recreated every call ✗
) {
val isInCart = cartItems.any { it.productId == product.id }
Card(modifier = Modifier.clickable { onAddToCart(product) }) {
Text(product.name)
if (isInCart) Icon(Icons.Default.ShoppingCart, contentDescription = "In cart")
}
}
// ✅ After (Antigravity output)
@Composable
fun ProductCard(
product: Product,
isInCart: Boolean, // Boolean → stable ✓ (computed by parent)
onAddToCart: (String) -> Unit, // pass productId only → easier to stabilize
) {
Card(modifier = Modifier.clickable { onAddToCart(product.id) }) {
Text(product.name)
if (isInCart) Icon(Icons.Default.ShoppingCart, contentDescription = "In cart")
}
}
// Parent computes isInCart with derivedStateOf
@Composable
fun ProductList(
products: List<Product>,
cartItems: List<CartItem>,
onAddToCart: (String) -> Unit,
) {
val cartProductIds by remember(cartItems) {
derivedStateOf { cartItems.map { it.productId }.toSet() }
}
LazyColumn {
items(products, key = { it.id }) { product ->
ProductCard(
product = product,
isInCart = product.id in cartProductIds,
onAddToCart = onAddToCart,
)
}
}
}Error Handling Strategy
Unified Error Management with Result Types
// A sealed error hierarchy that distinguishes network, server, auth, and validation errors
sealed class AppError(val message: String, val cause: Throwable? = null) {
class NetworkError(cause: Throwable) : AppError("No network connection", cause)
class ServerError(val code: Int) : AppError("Server error ($code)")
class AuthError : AppError("Authentication required")
class ValidationError(val fields: Map<String, String>) : AppError("Check your input")
class UnknownError(cause: Throwable) : AppError("Unexpected error", cause)
}
fun Throwable.toAppError(): AppError = when (this) {
is java.net.UnknownHostException -> AppError.NetworkError(this)
is java.net.SocketTimeoutException -> AppError.NetworkError(this)
is retrofit2.HttpException -> when (code()) {
401, 403 -> AppError.AuthError()
in 500..599 -> AppError.ServerError(code())
else -> AppError.UnknownError(this)
}
else -> AppError.UnknownError(this)
}
data class UiError(val message: String, val isRetryable: Boolean)
fun AppError.toUiError(): UiError = when (this) {
is AppError.NetworkError -> UiError("You're offline. Check your connection.", isRetryable = true)
is AppError.ServerError -> UiError("Server is having issues.", isRetryable = true)
is AppError.AuthError -> UiError("Please log in again.", isRetryable = false)
is AppError.ValidationError -> UiError(message, isRetryable = false)
is AppError.UnknownError -> UiError("Something went wrong.", isRetryable = true)
}Testing: Three-Layer Strategy with AI
// ViewModel test using Turbine for StateFlow / Effect verification
@OptIn(ExperimentalCoroutinesApi::class)
class ProductListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val fakeGetProducts = FakeGetProductsUseCase()
private lateinit var viewModel: ProductListViewModel
@Before
fun setup() {
viewModel = ProductListViewModel(
getProducts = fakeGetProducts,
searchProducts = FakeSearchProductsUseCase(),
)
}
@Test
fun `initial load populates product list`() = runTest {
val testProducts = listOf(testProduct(id = "1"), testProduct(id = "2"))
fakeGetProducts.setResult(PaginatedResult(testProducts, hasNext = true))
viewModel.state.test {
val initial = awaitItem()
assertThat(initial.products).isEmpty()
viewModel.onEvent(ProductListEvent.LoadInitial)
val loading = awaitItem()
assertThat(loading.isLoading).isTrue()
val loaded = awaitItem()
assertThat(loaded.products).hasSize(2)
assertThat(loaded.isLoading).isFalse()
assertThat(loaded.hasNextPage).isTrue()
}
}
@Test
fun `API error emits ShowError effect`() = runTest {
fakeGetProducts.setError(AppError.NetworkError(IOException()))
viewModel.effect.test {
viewModel.onEvent(ProductListEvent.LoadInitial)
val effect = awaitItem()
assertThat(effect).isInstanceOf(ProductListEffect.ShowError::class.java)
}
}
}CI/CD: GitHub Actions to Play Store
# .github/workflows/android-ci.yml
name: Android CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
- name: Run lint
run: ./gradlew lintRelease
- name: Run unit tests
run: ./gradlew testReleaseUnitTest
build-and-deploy:
needs: lint-and-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build release AAB
run: ./gradlew bundleRelease
- name: Deploy to Play Store internal track
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.example.app
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internalSummary
Production-quality Android development with Antigravity requires MVI for predictable state management, Compose performance optimization to prevent recomposition issues, unified error handling with sealed types, and automated CI/CD.
The single most important step: tell Antigravity your architecture rules upfront. Put your MVI patterns, error handling conventions, and testing strategy in AGENTS.md so every generated file is consistent from the start.
For more, see the Android Studio × Antigravity Beginner Guide and Daily AI Partner Workflow.