mcpField
C
capturing-preview-screenshots-in-ci

capturing-preview-screenshots-in-ci

github.com/skydovesデザイン・UI33964 回インストール22 週間前に更新
リポジトリ

capturing-preview-screenshots-in-ci について

Composeプロジェクト向けのCIスキルです。HotSwanでデバイスまたはエミュレーター上の`@Preview`をレンダリングし、閲覧可能なHTMLスクリーンショットカタログを生成します。デザインレビューやコンポーネント文書化に使えますが、ピクセル差分による回帰検査は行いません。

主な機能

  1. 01すべての`@Preview`をレンダリングして撮影
  2. 02検索可能なHTMLカタログを生成
  3. 03全体および個別の描画待ち時間を設定
  4. 04Demo Modeでシステムバーを固定
  5. 05KDocとパラメータ表を掲載
  6. 06GitHub ActionsとGradle Managed Devicesに対応

活用シーン

  1. 01CIでコミットごとにプレビューカタログを生成
  2. 02デザインやQA向けに閲覧可能な画像を公開
  3. 03ネットワーク画像やアニメーションを含むプレビューを撮影

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill capturing-preview-screenshots-in-ci

インストール後、Claude Code で/capturing-preview-screenshots-in-ciを入力して読み込まれたか確認します。

詳細

CI でのプレビューのスクリーンショット撮影 — コミットごとにデバイスでレンダリングしたカタログを作成

@Preview はエンジニアの IDE 内にあり、再コンパイルが必要で、デバイスとは異なるレンダラー(layoutlib)上に表示されます。このスキルでは、すべての @Preview を実機/エミュレーターでレンダリングし(実際の ART、フォント、画像読み込み、システムバーを使用)、PNG を検索可能な HTML カタログにまとめ、コミットごとに CI で再生成し、GitHub Pages で公開することで、共有アーティファクトにします。このツールは Compose HotSwan の Gradle プラグインです。この機能を活用できるようにプレビューを構成する方法については、../developing-with-compose-previews/SKILL.md を参照してください。

このスキルを使う場面

  • ユーザーが ./gradlew captureAllPreviews を実行し、すべての @Preview をレンダリングして HTML カタログを作成したい場合。
  • ユーザーが IDE を使わず、ADB 経由でシェルスクリプトから 1 つのプレビューをスクリーンショットに撮りたい場合。
  • ユーザーがプレビューカタログを GitHub Pages にデプロイし、デザイナー/QA/PM が URL からコンポーネントを閲覧できるようにしたい場合。
  • ネットワーク画像を読み込むプレビューやアニメーションが落ち着く前にキャプチャされ、描画途中になっているため、レンダリング遅延の調整が必要な場合。
  • ユーザーが「Paparazzi と Roborazzi とデバイスのスクリーンショットのうち、どれを使うべきか、また両方を実行できるか」と尋ねた場合。

このスキルを使わない場面

  • ユーザーがピクセル差分によるゴールデン画像の回帰チェック(参照 PNG を保存し、差分があれば失敗させる)を求めている場合。captureAllPreviews は差分を比較しません。それは Paparazzi/Roborazzi の役割です。両方が必要なら併用してください。下の比較表を参照してください。
  • ユーザーが Compose UI に対する動作のアサーション(ComposeTestRule、ファインダー)を求めている場合は、../../setup/choosing-test-rule-vs-runtest/SKILL.md と compose/finders/ のスキルを使用してください。
  • プレビューがレンダリングされない、または何度も「古い」状態になる理由をユーザーが修正しようとしている場合。これはプレビューの構成に関する問題です。../developing-with-compose-previews/SKILL.md を使用してください。
  • ユーザーが Compose のライブホットリロード(コードを編集し、実行中のデバイスで確認する機能)を求めている場合。これは HotSwan IDE プラグインのリロード機能であり、スクリーンショットのキャプチャとは別の機能です。

前提条件

  • アプリがインストールされ、現在の実行中に少なくとも一度起動済みの、接続されたデバイスまたは実行中のエミュレーター。captureAllPreviews は実行中のアプリのプロセスを操作するため、実際の依存関係グラフ(Hilt/Koin、ネットワーク、DB)を使ってプレビューをレンダリングでき、モックは不要です。
  • HotSwan Gradle コンパイラープラグインとプレビューライブラリ(プロジェクトのカタログに記載されているバージョン。執筆時点では 1.3.2):
// libs.versions.toml
[plugins]
hotswan-compiler = { id = "com.github.skydoves.compose.hotswan.compiler", version = "1.3.2" }

// root build.gradle.kts
plugins { alias(libs.plugins.hotswan.compiler) apply false }

// app module build.gradle.kts
plugins { alias(libs.plugins.hotswan.compiler) }
dependencies {
    debugImplementation("com.github.skydoves.compose.hotswan:preview:1.3.2")  // debug only — out of release builds
}
  • CI の場合:エミュレーターのステップ(reactivecircus/android-emulator-runner@v2)または Gradle Managed Devices(../../../instrumentation/managed-devices/running-tests-on-gradle-managed-devices/SKILL.md を参照)。エミュレーターを高速化するため、ランナーで KVM を有効にしてください。

ワークフロー

  • 1. デバイスが必要な理由を確認します。 ホスト JVM レンダラー(Paparazzi、Roborazzi)はデバイスなしで layoutlib を実行します。高速ですが、ピクセルは近似値です。デバイスレンダリングでは ART を経由するため、デバイスのフォントメトリクス、実際の Coil/Glide の読み込み(プレースホルダー、ネットワーク画像、クロスフェードがすべて実行されます)、実際の描画経路での GPU/AGSL/ぼかし、ステータスバーとナビゲーションバーを含む画面全体が反映されます。その代償は CI でエミュレーターが必要になることです。カタログの忠実度が重要な場合に限り、このトレードオフを受け入れてください。そうでなければ、ホスト JVM での差分比較を使い続けてください。

  • 2. ADB 経由でプレビューを 1 つキャプチャします(基本操作)。 デバッグアプリをビルドし、デバイスで一度起動すると、HotSwan プレビューライブラリが HotSwanPreviewActivity を公開します。このアクティビティには、完全修飾された @Preview 関数名を composable 文字列の extra として渡します。

adb shell am start -S \
  -n com.example.app/com.skydoves.compose.hotswan.preview.HotSwanPreviewActivity \
  --es composable "com.example.app.feature.home.PokedexHomePreview"
sleep 1                                  # let it render
adb exec-out screencap -p > home-preview.png

パッケージを applicationId に、extra をプレビュー関数の FQN に置き換えてください。これがすべての上位レベルのキャプチャの基礎となります。プレビューごとに個別に行う方法では規模に対応できないため、ステップ 3 を使ってください。

  • 2a. (任意)IDE での対話的なループ。 HotSwan IDE プラグインをインストールすると、@Preview の横にあるガターアイコンから、再ビルドなしで 0.5 秒以内にデバイス上で起動でき、リフレクションを使って @PreviewParameter プロバイダーを解決します。IDE プラグインが必要なのは、この対話的なフローとライブホットリロードだけです。以下の captureAllPreviews に必要なのは Gradle プラグインだけです。

  • 3. すべてのプレビューを一度にキャプチャします。 アプリを接続済みデバイスで実行した状態で、次のコマンドを実行します。

./gradlew captureAllPreviews

このタスクは、src/ 以下のすべての .kt ファイルをスキャンして @Preview 関数を見つけ、それぞれの FQN と Gradle モジュールを解決します。Android System UI Demo Mode を有効にして時計/バッテリー/電波表示を固定するため、差分には時刻ではなく UI の違いが反映されます。各プレビューについて HotSwanPreviewActivity を起動し、レンダリング待機時間の後に screencap を実行します。終了後、ステータスバーを元に戻し、デバイスをアプリのメイン Activity に戻して、index.html を生成します。すべてのファイルはプロジェクトルートの .hotswan/preview-captures/ に保存されます。カタログには検索、モジュールごとのグループ化、ダーク/ライトの切り替え、全画面表示、ショットごとのデバイスモデルとタイムスタンプが用意されています。sdkModeEnabled = true にすると、各プレビューをラップするコンポーザブルも追跡し、KDoc とパラメータの表をレンダリングします(変化が生じないデザインシステムのリファレンスになります)。テストコードは一切不要です。このタスクは @Preview アノテーションを直接読み取ります。

  • 4. レンダリング待機時間を調整します。小さなグローバル既定値と、プレビューごとの上書きを使います。 コンポーザブルの起動から固定ミリ秒後にショットが撮影されます。短すぎると、ネットワーク画像の読み込み、クエリの実行、アニメーションの開始中の画面が描画途中でキャプチャされます。長すぎると、大規模なカタログの処理に余分な時間がかかります。グローバル既定値は可能な限り低く設定し、必要な箇所だけ待機時間を増やしてください。
// app module build.gradle.kts
hotSwanCompiler {
    preview {
        renderDelayMs.set(1000L)        // ships at 2500L; most previews are static layout — keep the baseline low
        demoMode.set(true)              // pin status bar for deterministic shots
        sdkModeEnabled.set(false)       // true => KDoc + parameter tables in the catalog (design systems)
    }
}
// per-preview override for the slow minority (network/DB/animation): 3000–5000 ms is typical
@Preview
@PreviewScreenshot(renderDelay = 4000)
@Composable
private fun PokemonDetailPreview() { AppTheme { PokemonDetail(pokemon = samplePokemon) } }

@PreviewScreenshot がないプレビューではグローバル値が使われるため、高速さを維持できます。データを読み込む機能画面のプレビューが大半を占める場合は、グローバル値を高めに設定し、静的な少数のプレビューだけ上書きする方法も適切です。多数派に合わせて基準値を設定してください。

  • 5. GitHub Actions でエミュレーター上で実行します。 captureAllPreviews は ADB を操作する通常の Gradle タスクであるため、管理対象の Android デバイスが存在する環境ならどこでも実行できます。タスクの実行前に、アプリをインストールして起動しておく必要があります。
# .github/workflows/preview-screenshots.yml
name: Preview Screenshots
on:
  workflow_dispatch:
  push: { branches: [ main ], paths: [ '**/*.kt', '**/*.xml' ] }
permissions: { contents: write, pages: write, id-token: write }
concurrency: { group: preview-screenshots, cancel-in-progress: true }

jobs:
  capture:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: zulu, java-version: 17 }
      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
      - run: ./gradlew :app:assembleDebug
      - run: rm -rf .hotswan/preview-captures        # so a deleted preview leaves the catalog
      - name: Run emulator and capture
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 31
          arch: x86_64
          profile: pixel_6
          emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
          disable-animations: true
          script: |
            adb install -r app/build/outputs/apk/debug/app-debug.apk
            adb shell am start -n com.example.app/.MainActivity
            sleep 10
            ./gradlew :app:captureAllPreviews
      - uses: actions/upload-artifact@v4
        if: always()                                  # keep whatever rendered even if one preview throws
        with: { name: preview-screenshots, path: .hotswan/preview-captures/, retention-days: 30 }

(Google 組み込みのオプションを使いたい場合は、Gradle Managed Devices も同等の方法です。GMD スキルを参照してください。1 台のデバイスだけなら、ランナーアクションのほうが設定が少なくて済みます。)

  • 6. カタログを GitHub Pages にデプロイします。 ダウンロードする zip はチームの共有資産にはなりませんが、URL なら共有できます。別のジョブでキャプチャしたディレクトリを公開します。
  deploy:
    needs: capture
    runs-on: ubuntu-latest
    environment: { name: github-pages, url: '${{ steps.deployment.outputs.page_url }}' }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: preview-screenshots, path: preview-captures }
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with: { path: preview-captures }
      - id: deployment
        uses: actions/deploy-pages@v4

main にプッシュ → エミュレーターが起動 → すべての @Preview をレンダリングしてスクリーンショットを撮影 → HTML カタログをビルド → Pages が新しいバージョンを配信。

パターン

パターン:アプリを実行せずに CI でキャプチャする
# WRONG — captureAllPreviews on a fresh emulator with nothing installed
- uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 31
    script: ./gradlew :app:captureAllPreviews
# WRONG because: HotSwanPreviewActivity renders inside the *running app's* process. With no APK
# installed and no Activity started, every launch fails and the catalog comes out empty.
# RIGHT — build, install, launch, settle, then capture
    script: |
      adb install -r app/build/outputs/apk/debug/app-debug.apk
      adb shell am start -n com.example.app/.MainActivity
      sleep 10
      ./gradlew :app:captureAllPreviews
パターン:「安全のため」にグローバルのレンダリング待機時間を高く設定する
// WRONG
hotSwanCompiler { preview { renderDelayMs.set(5000L) } }
// WRONG because: every preview now costs 5 s. A 60-preview catalog wastes ~4 extra minutes per run
// to accommodate the handful that actually load remote data.
// RIGHT — low global, opt into the cost where the pixels need it
hotSwanCompiler { preview { renderDelayMs.set(1000L); demoMode.set(true) } }

@Preview @PreviewScreenshot(renderDelay = 4000) @Composable
private fun NetworkImagePreview() { AppTheme { UserAvatar(imageUrl = "https://example.com/p.jpg") } }
パターン:captureAllPreviewsをリグレッションゲートとして扱う
// WRONG — expecting captureAllPreviews to fail the build on a visual change
- run: ./gradlew captureAllPreviews   # then... assume it diffs against last run? it does not.
// WRONG because: captureAllPreviews produces a catalog; it does not store golden images or pixel-diff.
// Nothing fails when a screenshot changes.
// RIGHT — pick the tool for the job; run both if you need both
// Pixel-diff regression gate, no emulator, fast: Paparazzi or Roborazzi (test class per composable, golden PNGs).
// Browsable device-rendered catalog for docs/design review: captureAllPreviews.
// Many projects run Paparazzi/Roborazzi on PRs as a gate AND captureAllPreviews on main for the catalog.

HotSwanのcaptureAllPreviewsとPaparazzi/Roborazziの比較

項目captureAllPreviews(HotSwan)Paparazzi/Roborazzi
レンダリング実機またはエミュレーター(ART)ホストJVM、layoutlib
システムバー含まれる(ステータスバー+ナビゲーションバー)含まれない
ランタイム動作完全 — ネットワーク、画像読み込み、DB、DIがすべて実行されるUIシェルのみ
必要なテストコードなし — @Previewを直接スキャンコンポーザブルごとにテストクラスが必要
出力PNG+検索可能なHTMLカタログ(モジュール別の分類、KDoc、パラメーター)PNG
ピクセル差分によるリグレッション組み込みではないあり — 中核機能
CIのセットアップエミュレーターが必要(GitHub Actions/GMDに対応)エミュレーターは不要
最適な用途ビジュアルカタログ、ドキュメント、デザインレビューゴールデンイメージによるリグレッションテスト

必須ルール

  • 必須: captureAllPreviewsを実行する前に、デバイス/エミュレーター上でデバッグアプリをビルド、インストール、起動してください。実行中のアプリのプロセス内でレンダリングされます。
  • 必須: プレビューライブラリはdebugImplementationに設定し、HotSwanPreviewActivityがリリースビルドに含まれないようにしてください。
  • 必須: グローバルのrenderDelayMsは短く設定し、リモートデータを読み込むプレビューやアニメーションが落ち着くまで待つ必要があるプレビューにのみ@PreviewScreenshot(renderDelay = …)を使用してください。デフォルト値を一律に大きくしないでください。
  • 必須: CI実行前に.hotswan/preview-captures/を消去し、削除済みのプレビューがカタログに残らないようにしてください。また、アーティファクトのアップロードにはif: always()を使用してください。
  • 禁止: captureAllPreviewsをピクセル差分によるリグレッションゲートとして扱わないでください。ゴールデンイメージとの比較機能はありません。必要な場合はPaparazzi/Roborazziと併用してください。
  • 禁止: スクリーンショットをデバイスのフォント、実際の画像読み込み、GPU/AGSLエフェクト、システムUIに一致させる必要がある場合、ホストJVMのスクリーンショットツール(Paparazzi/Roborazzi)に頼らないでください。これらにはデバイスでのレンダリングが必要です。
  • 推奨: 各スクリーンショットでステータスバーが一定になるよう、demoModeを有効にしてください。デザインシステムのモジュールでは sdkModeEnabledを有効にし、カタログにKDocとパラメーターテーブルを含めてください。
  • 推奨: カタログをGitHub Pagesにデプロイし、エンジニア以外も開ける安定したURLを用意してください。

検証

  • アプリを起動した状態で./gradlew captureAllPreviewsを実行すると、.hotswan/preview-captures/index.htmlと、@PreviewごとのPNGが生成される。
  • 単一プレビュー用のADB手順(am start … HotSwanPreviewActivity --es composable <FQN>+screencap)で、そのコンポーザブルの空ではないPNGが取得できる。
  • debugImplementation("com.github.skydoves.compose.hotswan:preview:…")を使用している(implementation/releaseImplementationではない)。リリースビルドにHotSwanPreviewActivityが含まれない。
  • CIジョブでcaptureAllPreviewsの実行前にアプリをインストールして起動している。成功した実行では、アップロードされたアーティファクトが空ではない。
  • GitHub Pagesのデプロイジョブがneeds: captureを実行し、mainへのプッシュ後に公開URLで最新のカタログが表示される。
  • 時間のかかるプレビュー(ネットワーク/DB/アニメーション)に@PreviewScreenshot(renderDelay = …)が設定されている。グローバルのrenderDelayMsは、大半を占める静的プレビューに合わせて最小値に近く設定されている。

参考資料

  • hotswan.dev/blog/compose-preview-screenshots-ci — skydoves、「CI での Compose Preview スクリーンショット:すべてのコミットで実機カタログを作成」(単一プレビューの ADB キャプチャ、captureAllPreviews、レンダリング遅延の戦略、GitHub Actions と emulator-runner、GitHub Pages へのデプロイ、Paparazzi/Roborazzi の比較)。
  • hotswan.dev/blog/compose-preview-driven-development — skydoves、「即時フィードバックを活用した Compose Preview 主導の開発」(Preview Runner、captureAllPreviews、デバイスレンダリングが必要な理由、CI ワークフロー)。
  • github.com/skydoves/compose-hotswan — HotSwan Gradle コンパイラープラグイン(com.github.skydoves.compose.hotswan.compiler)、preview アーティファクト、HotSwanPreviewActivity、captureAllPreviews タスク、hotSwanCompiler { preview { … } }/@PreviewScreenshot API。
  • github.com/skydoves/pokedex-compose — この構成をそのまま実行している参考プロジェクト(screenshot.yml ワークフローと公開済みプレビューカタログ)。
  • developer.android.com/develop/ui/compose/tooling/previews — @Preview と @PreviewParameter(captureAllPreviews がスキャンするアノテーション)。
  • github.com/ReactiveCircus/android-emulator-runner — CI で実際の Android イメージをホストするために使用する GitHub Actions のエミュレーターステップ。
  • 関連スキル:../developing-with-compose-previews/SKILL.md — キャプチャする価値のある @Preview 関数の構成方法(状態のホイスティング、@PreviewParameter、アンチパターン)。
  • 別セット:../../../instrumentation/managed-devices/running-tests-on-gradle-managed-devices/SKILL.md — CI でデバイスをホストするための、android-emulator-runner に代わる Gradle Managed Devices。

コメント