developing-with-compose-previews について
Android Compose 開発者向けに、安定して保守しやすい `@Preview` の作り方をまとめたスキルです。状態の分離や実際のモデルに沿ったサンプルデータ、UI 状態と構成のカバーを扱います。
主な機能
- 01プレビュー可能なコンポーザブルから状態を分離
- 02パラメータプロバイダーで UI 状態を網羅
- 03プレビューのテーマと表示領域を設定
- 04マルチプレビューで構成を確認
- 05環境依存のプレビュー処理に対応
活用シーン
- 01ViewModel 依存で失敗するプレビューの修正
- 02ローディング、空、エラー、オーバーフロー状態の確認
- 03プレビューのサイズやテーマの不一致の解消
- 04頻繁に古くなる Compose プレビューの整理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill developing-with-compose-previewsインストール後、Claude Code で/developing-with-compose-previewsを入力して読み込まれたか確認します。
詳細
Compose プレビューを活用した開発 — プレビュー駆動開発
@Preview は Compose で最も手軽な検証ループです。編集してから約 1 秒でレンダリングを確認でき、ビルド、インストール、画面遷移のサイクルも必要ありません。ただし、プレビューを使い続けるには、コンポーザブルを適切に構成する必要があります。状態を上位に持ち上げる、シグネチャに ViewModel を含めない、現実的なサンプルデータを使う、といった工夫が必要です。このスキルでは、そうした構成と、チームがプレビューを使わなくなる原因となるアンチパターンを説明します。CI でこれらのプレビューをオンデバイスのスクリーンショットとして取得する方法については、../capturing-preview-screenshots-in-ci/SKILL.md を参照してください。
このスキルを使用する場面
- コンポーザブルが
ViewModel、リポジトリ、またはContextに依存する依存関係を引数に取るため、@Previewの「レンダリングに失敗する」、またはクラッシュする場合。 - アプリを実行せずに、読み込み中/空/エラー/テキストのはみ出し/RTL の状態を確認したい場合。
@PreviewParameterが適しています。 - コンポーザブルが Coil/Glide で画像を読み込んでおり、プレビューに何も表示されない場合。
LocalInspectionModeが必要です。 - 「Compose のプレビューを壊れ続けないようにするには、どう構成すればよいですか?」と尋ねられた場合、または「プレビュー駆動開発」に言及された場合。
- プレビューはあるものの、古くなっていたり、「out of date」バナーが頻繁に表示されたりして、チームが削除しようとしている場合。
このスキルを使用しない場面
- デバイス上または CI でプレビューをスクリーンショットとして取得したい場合(HotSwan の
captureAllPreviews、GitHub Pages のカタログ)。../capturing-preview-screenshots-in-ci/SKILL.mdを使用してください。 - ピクセル差分によるゴールデン画像の回帰テスト(Paparazzi/Roborazzi)を行いたい場合。これは別のツールです。デバイスレンダリングとの使い分けについては、
../capturing-preview-screenshots-in-ci/SKILL.mdを参照してください。 - 動作確認用の UI テスト(
ComposeTestRule、ファインダー、アサーション)を作成する場合は、../../setup/configuring-test-dependencies/SKILL.mdと../../finders/finding-nodes-by-tag-text-content/SKILL.mdから始めてください。
前提条件
- メインのクラスパスに
androidx.compose.ui:ui-tooling-previewがあること(@Preview、@PreviewParameter、PreviewParameterProviderを提供します。compose/ui/ui-tooling-preview/を参照してください)。さらに、Android Studio でレンダリングできるようにdebugImplementation("androidx.compose.ui:ui-tooling")が必要です。 LocalInspectionModeはandroidx.compose.ui:uiに含まれています(compose/ui/ui/.../platform/InspectionMode.kt—staticCompositionLocalOf { false })。追加の依存関係は不要です。- Compose が有効なモジュールであること(
org.jetbrains.kotlin.plugin.composeプラグイン、またはbuildFeatures.compose = true)。
ワークフロー
-
1. プレビューする対象を決めます。画面ではなくコンポーネントです。 画面レベルのコンポーザブルは
ViewModel、ナビゲーション、DI を取り込むため、プレビューが最も難しく、その労力に見合う価値も最も低くなります。通常のパラメーターだけを受け取るリーフおよび中間レベルのコンポーザブルをプレビューします。それらを組み合わせる画面コンポーザブル自体に@Previewは必要ありません。 -
2. プレビュー可能なコンポーザブルが通常の値だけを受け取るまで、状態をホイスティングします。 コンポーザブルのシグネチャに
ViewModelが含まれる場合は、呼び出し元でViewModelを所有するステートフルなラッパーと、データを受け取るステートレスなコンポーザブルに分割します。ステートレスな方をプレビューします。(「パターン:シグネチャにViewModelが含まれる場合」を参照してください。) -
3. プレビューの本体をアプリのテーマで囲みます。
MaterialTheme.colorScheme/.typographyを参照するコンポーザブルは、テーマで囲まないとデフォルトの Material 値で描画されるため、実際にアプリに組み込んだときの見た目とは異なるプレビューになります。@Preview関数内では必ずAppTheme { … }(または同等のもの)を使います。 -
4. コンポーザブルが広がる場合は、現実的なビューポートを指定します。
Modifier.fillMaxWidth()を使うコンポーザブルは、コンテンツに合わせてサイズが決まるプレビューでは幅がゼロになります。現実的なサイズで描画されるよう、@Preview(widthDp = 360)(高さも画面いっぱいに広がる場合はheightDpも)を追加します。 -
5.
"Lorem ipsum"ではなく、現実的なサンプルデータを使います。 サンプルデータは、本番モデルの構造とエッジケース(長い名前、空文字列、項目数ゼロのリスト)に合わせます。モデルが変わったときにプレビューのコンパイルが失敗することが重要です。そのコンパイルエラーによって、プレビュー(およびそれが表すドキュメント)が古くなっているとわかります。 -
6.
@PreviewParameterであらゆる UI 状態を網羅します。PreviewParameterProviderを使い、1 つの関数から読み込み中/コンテンツ表示/空/エラー/オーバーフローの状態を並べて描画します。(「パターン:プレビューごとに 1 つの状態」を参照してください。) -
7.
@Previewを繰り返して設定ごとに描画します。@Previewは@Repeatableです。uiMode = UI_MODE_NIGHT_YES、fontScale = 1.5f、device = "spec:width=320dp,height=640dp"、locale = "ar"(RTL)を重ねて指定できます。各アノテーションで 1 つ描画されます。多くのコンポーザブルで再利用する設定のセットには、複数の@Previewを付与したカスタムアノテーションクラスを定義し、そのアノテーションを適用します(「マルチプレビュー」)。 -
8. 避けられない依存関係は
LocalInspectionModeの背後に隔離します。 画像ローダーにはContextとネットワークアクセスが必要です。LocalInspectionMode.currentに応じて分岐し、プレビューではプレースホルダーを描画します。必要な場合に限って使ってください。あらゆる場所で使う必要があるなら、コンポーザブルの状態ホイスティングがまだ不十分です。
パターン
パターン:シグネチャに ViewModel が含まれる場合
// WRONG
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val user by viewModel.user.collectAsStateWithLifecycle()
Column { Text(user.name); Text(user.bio) }
}
@Preview
@Composable
fun ProfileScreenPreview() {
ProfileScreen(viewModel = ???) // can't: needs repository, network, DB
}
// WRONG because: the preview environment has no DI graph. Studio must instantiate
// ProfileViewModel and its whole dependency chain, so the preview fails to compile
// or crashes at render time.
// RIGHT — stateless composable takes plain values; ViewModel stays at the call site
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
val user by viewModel.user.collectAsStateWithLifecycle()
ProfileContent(name = user.name, bio = user.bio, onEditClick = viewModel::onEdit)
}
@Composable
fun ProfileContent(name: String, bio: String, onEditClick: () -> Unit = {}) {
Column { Text(name); Text(bio) }
}
@Preview(widthDp = 360)
@Composable
private fun ProfileContentPreview() {
AppTheme { ProfileContent(name = "Jane Doe", bio = "Android developer") }
}
パターン:プレビューごとに 1 つの状態にする場合と @PreviewParameter を使う場合
// WRONG — three near-duplicate preview functions, easy to let one rot
@Preview @Composable fun UserCardLoadingPreview() { AppTheme { UserCard(UiState.Loading) } }
@Preview @Composable fun UserCardContentPreview() { AppTheme { UserCard(UiState.Content(sampleUser)) } }
@Preview @Composable fun UserCardErrorPreview() { AppTheme { UserCard(UiState.Error("offline")) } }
// WRONG because: each state is a separate function with its own copy of the theme wrapper;
// adding a new state means remembering to add another function. Edge cases get skipped.
// RIGHT — one function, every state generated from the provider
class UserStateProvider : PreviewParameterProvider<UiState> {
override val values = sequenceOf(
UiState.Loading,
UiState.Content(User(name = "Jane Doe", bio = "Short bio")),
UiState.Content(User(name = "A very long display name that overflows the row", bio = "")),
UiState.Empty,
UiState.Error("network unavailable"),
)
}
@Preview(widthDp = 360)
@Composable
private fun UserCardPreview(@PreviewParameter(UserStateProvider::class) state: UiState) {
AppTheme { UserCard(state) }
}
パターン:Context を使わない画像ローダー
// WRONG
@Composable
fun UserAvatar(imageUrl: String) {
AsyncImage(model = imageUrl, contentDescription = "Avatar", modifier = Modifier.size(48.dp))
}
// WRONG because: AsyncImage needs an ImageLoader (Context + network). In a preview it renders
// empty, so a composable built around the avatar looks broken in Studio for no real reason.
// RIGHT — branch on LocalInspectionMode for the preview-only path
@Composable
fun UserAvatar(imageUrl: String) {
if (LocalInspectionMode.current) {
Box(Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant))
} else {
AsyncImage(
model = imageUrl,
contentDescription = "Avatar",
modifier = Modifier.size(48.dp).clip(CircleShape),
)
}
}
パターン:再利用可能なプレビューセットをマルチプレビューアノテーションにする場合
// WRONG — paste the same four @Preview lines onto every composable
@Preview(name = "phone") // duplicated across dozens of files;
@Preview(name = "dark", uiMode = UI_MODE_NIGHT_YES)
@Preview(name = "font 1.5x", fontScale = 1.5f)
@Preview(name = "RTL", locale = "ar")
@Composable fun FooPreview() { AppTheme { Foo() } }
// WRONG because: when the standard set changes (add a tablet size, drop a font scale) you
// have to edit every composable. The set is policy; it belongs in one place.
// RIGHT — define once, apply once. @Preview can annotate an annotation class.
@Preview(name = "phone", showBackground = true)
@Preview(name = "dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Preview(name = "font 1.5x", fontScale = 1.5f, showBackground = true)
@Preview(name = "RTL", locale = "ar")
annotation class AppPreviews
@AppPreviews
@Composable
private fun FooPreview() { AppTheme { Foo() } }
レビューで指摘すべきアンチパターン
- 画面レベルの
@Preview。ViewModel/NavControllerを受け取るコンポーザブルに付与された@Preview。代わりに、それが組み合わせるコンポーネントをプレビューします。 - テーマで囲まれていない。
AppTheme { … }を使わずに、MaterialThemeに依存するコンポーザブルを呼び出しているプレビュー本体。アプリのテーマではなく、デフォルトの Material 値で描画されます。 - 幅がゼロのプレビュー。
fillMaxWidth()/fillMaxSize()を使うコンポーザブルを、widthDp/heightDpを指定せずにプレビューしています。幅がゼロになります。 "Lorem ipsum"/作り物のデータ。 本番モデルの構造やエッジケースを反映していないサンプルデータ。オーバーフローや空状態のバグが見えなくなり、モデルが変わっても再コンパイルを促せません。- プレビュー内でのモック。
@Preview本体内の Mockito/MockK オブジェクト。モックが必要ということは、状態がホイスティングされていないということです。プレビューではなく、コンポーザブルを修正します。
必須ルール
- 必須: プレビュー可能なコンポーザブルのパラメーターに、
ViewModel、リポジトリ、NavController、Contextにバインドされた値を含めないでください。その状態は呼び出し元のステートフルなラッパーにホイストしてください。 - 必須: コンポーザブルが
MaterialThemeの色、タイポグラフィ、シェイプを読み取る場合、すべての@Previewの本体をアプリのテーマで囲んでください。 - 必須: 状態ごとにプレビュー関数を作るのではなく、
@PreviewParameterとPreviewParameterProviderを使用して、UI の状態(読み込み中、コンテンツ、空、エラー、オーバーフロー)をすべてカバーしてください。 - 必須: 幅や高さを埋めるコンポーザブルでは、
@PreviewにwidthDp/heightDpを追加し、現実的なビューポートでレンダリングされるようにしてください。 - 必須: 本番モデルに対応するサンプルデータを使用してください。モデルを変更するとプレビューのコンパイルが失敗することが想定どおりです。
- 禁止:
@Previewの本体にモック(Mockito/MockK)を入れないでください。また、状態のホイストの代わりにLocalInspectionModeのチェックをあちこちに追加しないでください。これは、画像読み込みやセンサーなど、本当に環境に依存する呼び出しに限って使用してください。 - 推奨: プレビュー関数には
privateを指定してください。これらはツールから呼び出すためのエントリーポイントであり、API ではありません。 - 推奨: チーム標準の設定セットを
@Preview付きのアノテーションクラス(マルチプレビュー)としてまとめ、適用してください。
検証
- プレビュー可能なコンポーザブルが
ViewModel/リポジトリ/Contextのパラメーターを受け取っていません。git grep -nE '@Preview' -A3 -- '**/*.kt'を実行すると、プレビュー本体からステートレスなコンポーザブルのみが呼び出されていることを確認できます。 -
MaterialTheme.*を使用するすべての@Preview本体が、アプリのテーマで囲まれています。 - 状態を持つコンポーザブルには、読み込み中/コンテンツ/空/エラー/オーバーフローをカバーする
PreviewParameterProviderがあります。それらのモジュールでgrep -rn "PreviewParameterProvider" src/を実行すると、結果が表示されます。 -
fillMaxWidth/fillMaxSizeを使用するコンポーザブルには、@Preview(widthDp = …)があります。 -
@Preview関数内にmock(/mockk(がありません。 - モデルのフィールド名を変更した後にモジュールをビルドすると、影響を受けるプレビューのコンパイルが失敗します(サンプルデータが Lorem ではなく実際のモデルに基づいていることの証明です)。
参考資料
- developer.android.com/develop/ui/compose/tooling/previews —
@Previewのパラメーター、@PreviewParameter/PreviewParameterProvider、マルチプレビューのアノテーションクラス、deviceの指定文字列。 androidx/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/Preview.kt—@Previewアノテーション(@Repeatable、name、group、widthDp、heightDp、locale、fontScale、showSystemUi、showBackground、backgroundColor、uiMode、device、wallpaper)。androidx/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewParameter.kt—PreviewParameterProvider<T>(values: Sequence<T>、getDisplayName)と@PreviewParameter(provider, limit)。androidx/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/InspectionMode.kt—LocalInspectionMode = staticCompositionLocalOf { false }。- developer.android.com/develop/ui/compose/state-hoisting — 状態のホイスト、ステートフルとステートレスのコンポーザブル。
- hotswan.dev/blog/compose-preview-driven-development — skydoves、「Compose Preview Driven Development with Instant Feedback」(プレビュー可能にするための状態のホイスト、
LocalInspectionMode、@PreviewParameter、アンチパターン、プレビュー再ビルドの煩雑さ)。 - 関連スキル:
../capturing-preview-screenshots-in-ci/SKILL.md— これらのプレビューをデバイスのスクリーンショットとしてレンダリングする方法と CI カタログ。Paparazzi/Roborazzi の適用箇所。 - 関連スキル:
../../setup/configuring-test-dependencies/SKILL.md— Compose のテスト/ツーリング成果物の Gradle マトリックス。