android-dev について
コーディングエージェント向けの Android および Kotlin Multiplatform 開発スキルです。アーキテクチャや UI 状態、モジュール、テストの指針を示し、既存プロジェクトの規約を優先します。
主な機能
- 01Android と KMP の基本アーキテクチャ
- 02Compose の UI 状態とエフェクトのパターン
- 03モジュールとモデルの構成指針
- 04完全修飾名によるスキル選択
- 05コルーチンとプラットフォーム境界の実践
活用シーン
- 01新しい Android 機能の規約設定
- 02画面状態と一度限りのエフェクトの構成
- 03タスクに合う専用スキルの選択
- 04KMP コードのモジュール境界の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill android-devインストール後、Claude Code で/android-devを入力して読み込まれたか確認します。
詳細
Android / KMP の基本方針
共通のデフォルト設定です。念押しや再確認を求めずに適用してください。プロジェクト固有の規約が異なる場合は、そちらに従ってください。
- DI: Hilt + KSP(プロジェクトで Koin を使っている場合は
android-skills:koin)。非同期処理: Coroutines/Flow — 新しいコードではLiveDataを使いません。JSON:kotlinx.serialization。画像: Coil。 - ネットワーク/ローカルデータ: Android では Retrofit/OkHttp + Room を使用します。KMP の共有部分では Ktor + Room または SQLDelight を使用します。Retrofit は Android 専用です。共有モジュールでは決して使わないでください。
- モジュール: 機能ごとの垂直構成のパッケージとモジュールを使います。
:core:modelは Android 依存関係を持ちません。:feature:*モジュール同士は決して依存しません。 - エラー: リポジトリの境界でドメイン型に変換します。プラットフォームの例外を境界の先へ漏らしてはいけません。UI 状態では loading / success / error を明示的にモデル化します(
android-skills:android-data-layerを参照)。
スキルの選択
タスクに適したスキルを、必ず 完全修飾された android-skills: 接頭辞付きで読み込んでください。短縮名(compose、koin など)は使わないでください。
| 対象 | 読み込むスキル |
|---|---|
Compose の詳細 — 安定性、remember、モディファイア、副作用、リスト、アニメーション、ナビゲーション | android-skills:compose |
| M3 UX — タッチターゲット、アダプティブ/折りたたみ式レイアウト、アクセシビリティと M3 準拠の監査 | android-skills:android-ux |
Coroutines と Flow — 演算子、Channel と SharedFlow の使い分け、構造化された並行処理 | android-skills:kotlin-coroutines、android-skills:kotlin-flows |
| リポジトリ/データレイヤーとエラーモデル | android-skills:android-data-layer |
| ネットワーク | android-skills:android-retrofit(Android) · android-skills:kmp-ktor(KMP) |
| ページング | android-skills:paging |
| 画像の読み込み | android-skills:coil-compose |
| 設定/型付きローカルストレージ | android-skills:datastore |
KMP の expect/actual 境界設計 | android-skills:kmp-boundaries |
| RxJava から Coroutines/Flow への移行 | android-skills:rxjava-migration |
| テスト | android-skills:android-testing |
| Koin による DI | android-skills:koin |
| ビルドロジック/規約プラグイン | android-skills:android-gradle-logic |
| ビルドの高速化、kapt から KSP への移行 | android-skills:gradle-build-performance |
| デバッグ — Logcat、クラッシュ、ANR、プロファイリング | android-skills:android-debugging |
| AOSP/AndroidX のソース検索 | android-skills:android-source-search |
| マルチモジュールの可視性とモジュール境界 | android-skills:modularization |
| プラットフォームの PDF 注釈/ページオブジェクトの編集(API 36.1 / SDK ext 18) | android-skills:pdf-annotations |
新規プロジェクトの UI 規約(グリーンフィールド)
確立された規約がない新規プロジェクトや機能に適用します。既存のコードでは、すでにある方法に合わせてください。下記の「プロジェクト既存の仕組みを再利用する」を参照してください。
UI レイヤーには MVVM を採用し、MVI スタイルで状態とエフェクトを分離します。
- 画面ごとに不変の
UiStateを1つ用意し、StateFlow<UiState>として公開して、以下の4つの区分で構成します。コンテンツコンポーザブルは状態を描画してコールバックを送出するだけです。それ以外は行いません。 - 一度だけ実行する命令的な処理にはエフェクトストリームを1つ使います。ナビゲーション、スナックバー/トースト、スクロール、共有シート、ハプティクスなどが該当します。
SharedFlowではなく、Channel(Channel.BUFFERED).receiveAsFlow()を使います。画面がバックグラウンドにあるときに送出されたエフェクトはバッファーに保持され、破棄されずに復帰時に再生されます。エフェクトはLaunchedEffectで収集し(repeatOnLifecycleでライフサイクルにスコープを限定)、collectAsStateWithLifecycleは使わないでください。ChannelとSharedFlowの使い分けの理由についてはandroid-skills:kotlin-flowsを参照してください。 - 状態かエフェクトかの判断基準は「構成変更後も維持されるか」です。 画面回転やプロセス終了後も有効であり続けるものは状態です(表示するエラーは
UiStateのフィールドです)。UI が一度だけ実行して忘れるものはエフェクトです。これはcompose/references/state-management.mdのdurable-state-over-eventsルールに基づいています。永続性のあるものは状態に保持し、エフェクトストリームは一度だけ実行する命令的な処理に限定します。 - コールバックが約4~5個以上になったとき(または同じコールバック群を複数のコンポーザブル層に渡すとき)は、
@Stable Actionsインターフェースにまとめます。それより少ない場合は個別のラムダのほうがシンプルです。早まって抽象化しないでください。ViewModel がこのインターフェースを実装します。コンテンツコンポーザブルは ViewModel ではなく、FooActionsに依存させてください。そうすることで、純粋性とプレビュー可能性を保てます(プレビューではobject : FooActions {}のような何もしない実装を使います)。
data class FooUiState(/* the four buckets — see below */)
sealed interface FooEffect {
data class NavigateTo(val id: String) : FooEffect
data class ShowSnackbar(val message: String) : FooEffect
}
@Stable // promote here once lambdas pile up (~4-5+)
interface FooActions {
fun onItemClick(id: String)
fun onRefresh()
}
class FooViewModel(/* … */) : ViewModel(), FooActions {
private val _uiState = MutableStateFlow(FooUiState())
val uiState: StateFlow<FooUiState> = _uiState.asStateFlow()
private val _effects = Channel<FooEffect>(Channel.BUFFERED) // not SharedFlow — buffers while backgrounded
val effects = _effects.receiveAsFlow()
override fun onItemClick(id: String) { /* _uiState.update { … } */ _effects.trySend(FooEffect.NavigateTo(id)) }
override fun onRefresh() { /* … */ }
}
@Composable
fun FooScreen(viewModel: FooViewModel = hiltViewModel(), onNavigate: (String) -> Unit) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effects.collect { effect ->
when (effect) {
is FooEffect.NavigateTo -> onNavigate(effect.id)
is FooEffect.ShowSnackbar -> { /* show snackbar */ }
}
}
}
}
FooContent(uiState = uiState, actions = viewModel) // VM passed as FooActions — FooContent sees only the interface
}
Kotlin 2.4+: 明示的なバッキングフィールド(
val uiState: StateFlow<FooUiState>+field = MutableStateFlow(…))を使って、_uiState/uiStateのペアをまとめます。対象はuiStateのみで、エフェクトのChannelには適用しません。イディオムの全容とバージョン要件についてはandroid-skills:kotlin-flowsを参照してください。
4つの区分による状態モデリング
フォーム、計算機、複数ステップのウィザードなど、インタラクションが豊富な画面では、状態を1つのフラットな data class にまとめると管理が難しくなります。UiState を明示的な4つの区分に分け、計算値はコンストラクターパラメーターではなくクラスプロパティとして導出します。
data class CheckoutUiState(
// 1. Editable input — what the user types
val email: String = "",
val cardNumber: String = "",
// 3. Persisted snapshot — last value read from the repository / stored cross-screen
val savedShippingAddress: Address? = null,
// 4. Transient UI-only — flags that must NOT survive the screen
val isSubmitting: Boolean = false,
val showCardScannerOverlay: Boolean = false,
) {
// 2. Derived — getters, NOT constructor params, so no caller can copy() into an
// inconsistent state (e.g. emailValid = false next to a valid email).
val emailValid: Boolean get() = email.isValidEmail()
val canSubmit: Boolean get() = emailValid && cardNumber.passesLuhn() && !isSubmitting
}
フィールドではなく区分によって、ライフサイクルと永続化の方法が決まります。isSubmitting を永続化すると、プロセス終了後もスピナーが回り続けます。canSubmit をクラス外で計算すると、入力値との整合性が失われる可能性があります。cardNumber を画面間で永続化すると、個人を特定できる情報(PII)が漏えいします。区分を混在させると、アーキテクチャ上の問題に見える不具合が発生します。
UI の状態と UI モデルは別々のファイルに置く
UiState は画面と ViewModel の間の契約であり、UI モデルではありません。独立したファイルに置きます(プロジェクトの構成によっては ViewModel のファイルに置きます)。UI モデルをそこにまとめてはいけません。「各型には単一の所有者がある/変更単位は1つである/本当の分割単位はファイルごとの役割である」という考え方は、まさにこの誤りを招きます。状態と、それが保持するモデルは異なる役割を担っています。
モデルは構成関係に基づいてまとめ、画面ごとにはまとめないでください。
- 1つの大きなモデルを構成するモデルは、その大きなモデルと同じファイルに置き、大きなモデルの名前をファイル名に使います。
ChallengeDetailUi.ktには、ChallengeDetailUiと、それを構成するChallengeTaskUi/SponsorshipUiを置きます。 - 独立したモデルは、それぞれ別のファイルに置きます。
FooModels.kt/FooUiModels.ktは使いません。寄せ集めを意味する名前が必要になったということは、集約ルートを見つけられていない証拠です。つまり、型は独立しており、それぞれのファイルに属します。
Kotlin の「関連する宣言は、主要な宣言の名前を付けたファイルにまとめてよい」という規則は、仕組みを示すものであり、状態を主要な宣言として指定し、その後ろにモデルをまとめてよいという許可ではありません。
プロジェクト既存の仕組みを再利用する
イベントディスパッチャー、effects 用の Channel/SharedFlow、ユースケース層、並列の状態フィールドなど、新しい仕組みを追加する前に、同じ機能内の兄弟 ViewModel を開き、既存の仕組みを再利用してください。ここで見落としやすいのは、既存の仕組みを拡張するのではなく重複させることです。たとえば、既存のフラグを汎用化せず、既存の shouldDisplayUndoX フラグの隣に2つ目のフラグを追加することです。既存のコードが「ベストプラクティス」と矛盾する場合は、コードに従い、その不整合を指摘してください。プロジェクトのアーキテクチャを黙って上書きしてはいけません。
コメント — ひとつひとつに理由を持たせる
各コメントについて、読者が隣接するコードからすぐに内容を推測できるかを確かめてください。推測できるなら冗長なので削除します。 コメントを残すのは、コードだけでは伝えられない、わかりにくい 理由、つまり判断、制約、回避策、注意点を伝える場合に限ります。コードが 何をするか を説明してはいけません。わかりやすい名前や小さな関数で、それはすでに伝わります。「よく知られた型や呼び出しが何をするか」は、読者が調べられる 内容 であって、理由 ではありません。
この基準を満たす最小限のコメントだけを書いてください。「読みやすくして」や「初学者向けに」といった指示があっても同じです。読みやすさは命名と構造から生まれます。コードが 何をするか を理解するために新人が必要とするコメントがあるなら、注釈を追加するのではなく、名前を変更するかコードを切り出すべきです。
正当な 理由 を伝えるコメント(// 最初に再スローする — 広範な catch では CancellationException を握りつぶしてしまう)、意外な呼び出し箇所を正当化するコメント、シグネチャだけではわからない情報を加える公開 API の KDoc、そして TODO(owner-or-link) は残してください。ドキュメント作成の明示的な依頼にも従ってください。
以下はコードを見ればすぐに推測できるため、見つけ次第削除してください:
// ---- domain model ---- // section-divider / banner (any width)
/** The user profile as the app cares about it. */ // KDoc restating the class name
val uiState = _uiState.asStateFlow() // private mutable, public read-only (restates the idiom)
} catch (e: IOException) { // no connectivity, timeout, DNS failure (restates what the type means)
KMP
Dispatchers.Main/Dispatchers.IO を直接呼び出すのではなく、あらゆる場所に CoroutineDispatcher を注入してください。-ktx アーティファクトがないと、すべての KMP ターゲットで Dispatchers.Main が利用できるとは限りません。また、ディスパッチャーを差し替えたテストを可能にするのも注入です。プラットフォーム固有の処理(ファイル I/O、プッシュトークン、生体認証)には expect/actual を使用してください。iOS では、不変の共有状態を優先してください。