ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-03-15Advanced

Antigravity × Android Production Development — Building Shippable Apps with Jetpack Compose + MVI

An advanced guide to building production-quality Android apps with Antigravity. Covers MVI architecture, Jetpack Compose performance optimization, error handling strategy, testing patterns, and CI/CD — all through AI-assisted development.

Android27Jetpack Compose6MVIproduction71premium16

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: internal

Summary

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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-07-14
Protecting Screenshot Tests for AdMob Screens from Ad Nondeterminism
Screens with ads turn red on every screenshot run, and eventually nobody reviews the diffs. Here is a design that seals off AdMob banner nondeterminism and leaves only real layout breaks in your checks, with Compose code and Antigravity-driven diff triage.
App Dev2026-06-26
Green in the Embedded Emulator, Broken on the First Real Device — Putting a Parity Gate on AI-Generated Compose Apps
AI Studio now generates Kotlin/Compose apps from a prompt, runs them in an embedded emulator, and pushes them to a real device over USB — all from one screen. Yet a screen that passed in the emulator can break the first time it lands on a real phone. As a solo developer running several apps, here is how I put a gate that catches device parity issues before they ship.
App Dev2026-06-25
The Day Rolled Over and Yesterday's Wallpaper Came Back — Designing Date Boundaries Instead of Trusting an Agent's Time Code
A record of redesigning a daily-rotating wallpaper feature that showed yesterday's pick after midnight for some users and switched twice in one night for travelers. I separate the device clock, time zone, and daylight saving time into three distinct failure sources, show how to define a stable day key, defend reward boundaries against a rewound clock, and test it all with an injectable clock — from a solo indie developer's point of view.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →