ANTIGRAVITY LABEN
記事一覧/アプリ開発
アプリ開発/2026-03-15上級

Antigravity × Android プロダクション設計 — Jetpack Compose + MVI で「出荷できるアプリ」を AI と作る

Antigravity を使って本番品質の Android アプリを設計・実装する上級ガイド。MVI アーキテクチャ、Jetpack Compose のパフォーマンス最適化、テスト戦略、CI/CD まで、AI と協働する実践手法を解説します。

Android27Jetpack Compose6MVIproduction59premium15

取り組みの背景 — 「動くプロトタイプ」と「出荷できるアプリ」の差

Antigravity を使えば Android アプリのプロトタイプは驚くほど速く作れます。しかし、プロトタイプと本番アプリの間には深い溝があります。エラーハンドリング、オフライン対応、メモリ管理、テスト、CI/CD — これらを疎かにすると、Play Store のレビュー欄に「すぐ落ちる」が並びます。


アーキテクチャ設計:MVI パターンの採用

なぜ MVI を選ぶのか

MVVM は Android 開発の定番ですが、状態管理が複雑になると「どの LiveData がどの画面状態を表しているか」が混乱します。MVI(Model-View-Intent)は単一の State オブジェクトで UI 状態を表現し、状態遷移を予測可能にします。

Antigravity に MVI の基盤を生成させるとき、最初に伝えるべきは「このプロジェクトでは MVI パターンを使う」という方針です。AGENTS.md に明記しておけば、以降の全コード生成が MVI に準拠します。

// 基盤となる MVI の型定義
// Antigravity への指示: "MVI の基盤インターフェースを定義して。
// UiState, UiEvent, UiEffect の3つの sealed interface で"
 
/** 画面状態 — UI が「今どうなっているか」を表す唯一の情報源 */
interface UiState
 
/** ユーザーの操作 — ボタンタップ、スワイプ等 */
interface UiEvent
 
/** 一回限りの副作用 — Toast 表示、画面遷移、Snackbar 等 */
interface UiEffect
 
/**
 * MVI ベースの ViewModel 基底クラス
 * State: 画面状態, Event: ユーザー操作, Effect: 一回限りの副作用
 */
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()
 
    /** 状態を安全に更新する(copy パターンを使う) */
    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)
}

実践:商品一覧画面の MVI 実装

// 商品一覧画面の State / Event / Effect を定義
// Antigravity への指示: "商品一覧画面の MVI 定義を作って。
// ローディング・成功・エラーの3状態、検索・リフレッシュ・タップの操作、
// 画面遷移とエラー表示の Effect を含めて"
 
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 の実装
@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  // 二重ロード防止
 
        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 パフォーマンス最適化

Antigravity にパフォーマンス問題を診断させる

本番アプリで最も多いパフォーマンス問題は「不要な再コンポジション」です。Antigravity に Composable を渡して診断を依頼する方法が有効です。

// Antigravity への指示: "この Composable の再コンポジション問題を診断して。
// 不安定なパラメータ、lambda の再生成、derivedStateOf の不足を指摘して"
 
// ❌ 最適化前: 毎フレーム再コンポジションされる
@Composable
fun ProductCard(
    product: Product,                    // data class → 安定 ✓
    cartItems: List<CartItem>,           // List → 不安定 ✗
    onAddToCart: (Product) -> Unit,       // lambda → 毎回再生成 ✗
) {
    // cartItems が変わるたびに全 ProductCard が再コンポジションされる
    val isInCart = cartItems.any { it.productId == product.id }
 
    Card(modifier = Modifier.clickable { onAddToCart(product) }) {
        Text(product.name)
        if (isInCart) {
            Icon(Icons.Default.ShoppingCart, contentDescription = "カートに追加済み")
        }
    }
}
 
// ✅ 最適化後(Antigravity 提案)
@Composable
fun ProductCard(
    product: Product,
    isInCart: Boolean,                    // Boolean → 安定 ✓(親で計算)
    onAddToCart: (String) -> Unit,        // productId だけ渡す → 安定化しやすい
) {
    // isInCart の計算を親コンポーネントに移動し、
    // derivedStateOf で必要な ProductCard だけ再コンポジション
    Card(modifier = Modifier.clickable { onAddToCart(product.id) }) {
        Text(product.name)
        if (isInCart) {
            Icon(Icons.Default.ShoppingCart, contentDescription = "カートに追加済み")
        }
    }
}
 
// 親コンポーネントでの isInCart 計算
@Composable
fun ProductList(
    products: List<Product>,
    cartItems: List<CartItem>,
    onAddToCart: (String) -> Unit,
) {
    // cartItems が変わっても、derivedStateOf により
    // isInCart が変わった ProductCard だけ再コンポジションされる
    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,
            )
        }
    }
}

LazyColumn の本番レベル設定

// Antigravity への指示: "LazyColumn をプロダクション品質にして。
// プリフェッチ、スクロール位置保持、空・エラー・ローディング状態を含めて"
 
@Composable
fun ProductListScreen(viewModel: ProductListViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val listState = rememberLazyListState()
 
    // Effect の処理(Snackbar、画面遷移等)
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is ProductListEffect.ShowError -> {
                    snackbarHostState.showSnackbar(
                        message = effect.message,
                        actionLabel = "再試行",
                    )
                }
                is ProductListEffect.NavigateToDetail -> {
                    // NavController で遷移
                }
            }
        }
    }
 
    // 無限スクロール: 末尾に近づいたら次ページをプリフェッチ
    val shouldLoadMore by remember {
        derivedStateOf {
            val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
            val totalItems = listState.layoutInfo.totalItemsCount
            lastVisibleIndex >= totalItems - 5 && state.hasNextPage && !state.isLoading
        }
    }
    LaunchedEffect(shouldLoadMore) {
        if (shouldLoadMore) viewModel.onEvent(ProductListEvent.LoadNextPage)
    }
 
    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        SwipeRefresh(
            state = rememberSwipeRefreshState(state.isRefreshing),
            onRefresh = { viewModel.onEvent(ProductListEvent.Refresh) },
        ) {
            when {
                state.isLoading && state.products.isEmpty() -> LoadingScreen()
                state.error != null && state.products.isEmpty() -> ErrorScreen(
                    error = state.error!!,
                    onRetry = { viewModel.onEvent(ProductListEvent.LoadInitial) },
                )
                state.products.isEmpty() -> EmptyScreen(query = state.searchQuery)
                else -> LazyColumn(
                    state = listState,
                    contentPadding = padding,
                ) {
                    items(state.products, key = { it.id }) { product ->
                        ProductCard(
                            product = product,
                            isInCart = false, // 実際は ViewModel から取得
                            onAddToCart = { id ->
                                viewModel.onEvent(ProductListEvent.TapProduct(id))
                            },
                        )
                    }
                    if (state.isLoading) {
                        item { LoadingIndicator() }
                    }
                }
            }
        }
    }
}

エラーハンドリング戦略

Result 型による統一エラー管理

// Antigravity への指示: "API レスポンスのエラーを統一的に扱う Result ラッパーを設計して。
// ネットワークエラー、サーバーエラー、認証エラーを区別すること"
 
sealed class AppResult<out T> {
    data class Success<T>(val data: T) : AppResult<T>()
    data class Error(val error: AppError) : AppResult<Nothing>()
}
 
sealed class AppError(val message: String, val cause: Throwable? = null) {
    /** ネットワーク接続なし */
    class NetworkError(cause: Throwable) : AppError("ネットワークに接続できません", cause)
    /** サーバーエラー (5xx) */
    class ServerError(val code: Int) : AppError("サーバーエラーが発生しました (${code})")
    /** 認証エラー — ログイン画面に遷移 */
    class AuthError : AppError("ログインが必要です")
    /** バリデーションエラー — フィールド単位のメッセージ */
    class ValidationError(val fields: Map<String, String>) : AppError("入力内容を確認してください")
    /** 想定外のエラー */
    class UnknownError(cause: Throwable) : AppError("予期しないエラーが発生しました", cause)
}
 
/** Throwable → AppError の変換 */
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)
}
 
/** UiError: UI表示用に変換 */
data class UiError(val message: String, val isRetryable: Boolean)
 
fun AppError.toUiError(): UiError = when (this) {
    is AppError.NetworkError -> UiError("オフラインです。接続を確認してください", isRetryable = true)
    is AppError.ServerError -> UiError("サーバーに問題が発生しています", isRetryable = true)
    is AppError.AuthError -> UiError("再ログインが必要です", isRetryable = false)
    is AppError.ValidationError -> UiError(message, isRetryable = false)
    is AppError.UnknownError -> UiError("問題が発生しました", isRetryable = true)
}
 
fun Throwable.userMessage(): String = toAppError().toUiError().message

テスト戦略:AI と書く3層テスト

ViewModel テスト(Antigravity 生成)

// Antigravity への指示: "ProductListViewModel のテストを書いて。
// Turbine を使って StateFlow と Effect を検証すること"
 
@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 `初回ロード成功時に商品リストが更新される`() = 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()
            assertThat(initial.isLoading).isFalse()
 
            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 エラー時に 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 で自動デプロイ

# .github/workflows/android-ci.yml
# Antigravity への指示: "Android の CI/CD を GitHub Actions で組んで。
# lint → unit test → build → Play Store 内部テストトラック配信まで"
 
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
 
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: app/build/reports/tests/
 
  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
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
 
      - 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

まとめ

Antigravity を使った Android 開発の本番品質を達成するには、MVI による状態管理の予測可能性、Compose のパフォーマンス最適化、統一されたエラーハンドリング、そして自動化された CI/CD が欠かせません。

最も重要なのは「Antigravity にアーキテクチャ方針を伝えること」です。AGENTS.md に MVI パターン・エラーハンドリング規約・テスト方針を記載し、プロジェクト全体で一貫した出力を得るワークフローを確立してください。

関連記事としてAndroid Studio × Antigravity 入門AI パートナーとの日常開発ワークフローも参考にしてください。

個人開発12年の現場で実感したこと

線引きするときの3つの判断軸

  • 失敗時の影響が金銭やユーザー体験にどれだけ波及するか
  • 復旧オペレーションが明文化されているか
  • 観測ログから人間が再現できる粒度に整っているか
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

アプリ開発2026-07-14
AdMob を載せた画面のスクリーンショットテストを、広告の非決定性から守る
広告つきの画面はスクリーンショットテストが毎回赤くなり、やがて誰も差分を見なくなります。AdMob バナーの非決定性を封じ、レイアウトの崩れだけを検知に残す設計を、Compose の実装例と Antigravity での差分トリアージまで含めてまとめます。
アプリ開発2026-06-26
埋め込みエミュレータでは緑なのに、実機で初めて崩れる — AI 生成 Compose アプリに実機差を塞ぐ検証ゲートを置く
AI Studio はテキストから Kotlin/Compose アプリを生成し、埋め込みエミュレータで動かし、USB で実機へ送るところまで一画面でつなぎました。けれどエミュレータで通った画面が、手元の実機で初めて崩れることがあります。個人開発で複数アプリを抱える立場から、生成物に実機差を塞ぐ検証ゲートを据える設計をまとめました。
アプリ開発2026-06-25
日付が変わった瞬間に、昨日の壁紙がもう一度出た — 時刻処理をエージェント任せにしないための日付境界設計
毎日入れ替わる壁紙が、日付の変わり目に昨日と同じものを出したり、海外移動中の利用者だけ二回切り替わったりしていた問題を、端末時計・タイムゾーン・夏時間の三つに分けて設計し直した記録です。エージェントが書いた素朴な時刻コードのどこが本番で崩れるか、安定した日付キーの作り方、巻き戻し時計への防御、注入可能な時計でのテストまでを個人開発の視点でまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →