取り組みの背景 — 「動くプロトタイプ」と「出荷できるアプリ」の差
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つの判断軸
- 失敗時の影響が金銭やユーザー体験にどれだけ波及するか
- 復旧オペレーションが明文化されているか
- 観測ログから人間が再現できる粒度に整っているか