mcpField
D
developing-with-compose-previews

developing-with-compose-previews

github.com/skydoves開発33963 回インストール22 週間前に更新
リポジトリ

developing-with-compose-previews について

Android Compose 開発者向けに、安定して保守しやすい `@Preview` の作り方をまとめたスキルです。状態の分離や実際のモデルに沿ったサンプルデータ、UI 状態と構成のカバーを扱います。

主な機能

  1. 01プレビュー可能なコンポーザブルから状態を分離
  2. 02パラメータプロバイダーで UI 状態を網羅
  3. 03プレビューのテーマと表示領域を設定
  4. 04マルチプレビューで構成を確認
  5. 05環境依存のプレビュー処理に対応

活用シーン

  1. 01ViewModel 依存で失敗するプレビューの修正
  2. 02ローディング、空、エラー、オーバーフロー状態の確認
  3. 03プレビューのサイズやテーマの不一致の解消
  4. 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 マトリックス。

コメント