Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[DroidKaigi 2026] Making UI specifications visi...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.
Avatar for syarihu syarihu
September 03, 2026

[DroidKaigi 2026] Making UI specifications visible: Android UI development in the AI agent era supported by Compose Screenshot Testing and galleries

Avatar for syarihu

syarihu

September 03, 2026

More Decks by syarihu

Other Decks in Technology

Transcript

  1. Agenda 01 AIエージェント時代の並列実装と 確認コストの問題 02 Compose Screenshot Testing の概要 03

    Compose Screenshot Testingを運用して 見えてきた課題とその解決策 04 生成したスクリーンショットの活用 05 動作確認と、AIに任せるための土台 2
  2. タスクは細かく分解する • 1つのタスクを細分化して AIが並列で実装しやすくする • UI実装を伴うタスクでは、 親 Issue #100 商品検索画面の実装

    サブ Issue #101 TopAppBar サブ Issue #102 絞り込み フィルター サブ Issue #103 価格帯タブ UIコンポーネント単位で Issueを起票 5
  3. UI確認はAndroid Studioでworktreeを開く  Android Studioで worktreeを開く  インデックス 作成を待つ 

     ビルドを待つ アプリを実行して  画面を確認する これが、並行稼働する PRの数だけ毎回発生する 7
  4. Compose Screenshot Testingとは  ProductItem.kt • Android Studioのプレビューと同じLayoutLibで、 @VisibleForTesting @Preview

    @Previewを付けたComposableをエミュレータや実機を @Composable internal fun ProductItemPreview( 使わずJVM上でレンダリングする @PreviewParameter(ProductParamProvider.:class) product: Product ) { ProductItem(product = product) } 14
  5. Compose Screenshot Testingとは  ProductItem.kt • Android Studioのプレビューと同じLayoutLibで、  ProductItemScreenshotTest.kt

    @VisibleForTesting @Preview @Previewを付けたComposableをエミュレータや実機を internal class ProductItemScreenshotTest { @Composable @PreviewTest internal fun ProductItemPreview( 使わずJVM上でレンダリングする @Preview( @PreviewParameter(ProductParamProvider.:class) product: Product name = "ProductItem", ) { locale = "ja", ProductItem(product = product) uiMode = Configuration.UI_MODE_NIGHT_NO, } ) @Composable fun ProductItemTest() { ProductItemPreview(product = ProductParamProvider().values.first()) } } 15
  6. Compose Screenshot Testingとは • updateタスクを実行すると、描画結果が参照画像として 保存される Terminal $ ./gradlew updateDebugScreenshotTest

    BUILD SUCCESSFUL in 1s $ tree src/screenshotTestDebug/reference/ src/screenshotTestDebug/reference/ └── ProductItemScreenshotTest └── ProductItemTest_ProductItem_243dbe2f_0.png 16
  7. Compose Screenshot Testingとは • updateタスクを実行すると、描画結果が参照画像として 保存される Terminal $ ./gradlew updateDebugScreenshotTest

    BUILD SUCCESSFUL in 1s $ tree src/screenshotTestDebug/reference/ src/screenshotTestDebug/reference/ └── ProductItemScreenshotTest └── ProductItemTest_ProductItem_243dbe2f_0.png 17
  8. Compose Screenshot Testingとは • validateタスクを実行すると、最新の描画結果と参照画 像を比較する Terminal $ ./gradlew validateDebugScreenshotTest

    > Task :validateDebugScreenshotTest FAILED There were failing tests. See the report at: build/reports/screenshotTest/preview/debug/index.html 18
  9. Compose Testingを導入する ScreenshotTestConventionPlugin.kt  Screenshot class ScreenshotTestConventionPlugin : Plugin<Project> {

    override fun apply(target: Project) = with(target) { pluginManager.apply("com.android.compose.screenshot") extensions.configure<LibraryExtension> { @Suppress("UnstableApiUsage") experimentalProperties["android.experimental.enableScreenshotTest"] = true } val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") dependencies { add("screenshotTestImplementation", libs.findLibrary("jetpackComposeTooling").get()) add("screenshotTestImplementation", libs.findLibrary("screenshotValidationApi").get()) } } } 24
  10. Compose Screenshot Testingのテストの書き方  ProductItemScreenshotTest.kt internal class ProductItemScreenshotTest { •

    テストクラスはmainと同じパッケージのscreenshotTestソー @PreviewTest @Preview( スセットに置く ▼ src ▶ main ▼ screenshotTest ▼ kotlin name = "ProductItem", locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_NO, ▼ com.example.app ▼ compose ▼ product ProductItemScreenshotTest.kt ) @Composable fun ProductItemTest() { ProductItemPreview( product = ProductParamProvider().values.first() ) } } 28
  11.  ProductItemScreenshotTest.kt Compose Screenshot Testingのテストの書き方 internal class ProductItemScreenshotTest { @PreviewTest

    @Preview( name = "ProductItem", locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_NO, ) @Preview( name = "ProductItemDark", locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xC9000000, ) @Composable fun ProductItemTest() { ProductItemPreview(product = ProductParamProvider().values.first()) } • PreviewはLight/Darkのペアで撮り、Dark側は名前の末尾 にDarkを付ける • Preview名は参照画像のファイル名に使われる • 名前だけでLight/Darkを区別できると、人にもAIにも分かり やすい • PRに貼る差分表やギャラリーなど、画像を名前で扱う場面で そのまま効いてくる } 30
  12.  ProductItemScreenshotTest.kt Compose Screenshot Testingのテストの書き方 internal class ProductItemScreenshotTest { ☀

    生成されるファイル名 (Light) @PreviewTest @Preview( ProductItemTest_ProductItem_243dbe2f_0.png name = "ProductItem", locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_NO, ) @Preview( name = "ProductItemDark", locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xC9000000, 🌙 生成されるファイル名 (Dark) ) @Composable ProductItemTest_ProductItemDark_4a47b0e9_0.png fun ProductItemTest() { ProductItemPreview(product = ProductParamProvider().values.first()) } • PreviewはLight/Darkのペアで撮り、Dark側は名前の末尾 にDarkを付ける • Preview名は参照画像のファイル名に使われる • 名前だけでLight/Darkを区別できると、人にもAIにも分かり やすい • PRに貼る差分表やギャラリーなど、画像を名前で扱う場面で そのまま効いてくる } 31
  13. Compose Screenshot Testingを運用して 見えてきた課題 01 手元のMacとCIで生成されるスクショに差分が出る 02 スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい 03

    PR上で画像の差分が見えないことがある 04 ネットワーク画像が撮れない 05 Preview関数やテストクラスの命名がばらばらになる 33
  14. Compose Screenshot Testingを運用して 見えてきた課題 01 手元のMacとCIで生成されるスクショに差分が出る 02 スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい 03

    PR上で画像の差分が見えないことがある 04 ネットワーク画像が撮れない 05 Preview関数やテストクラスの命名がばらばらになる 34
  15. layoutlibのネイティブ実 装はプラットフォームごと に違う • 描画はlayoutlibのネイティブコード で行われ、Mac版(mac-arm64)と Linux版(x86_64)で丸めの結果が わずかに違う • Composeのコードで制御できる層

    ではないので、アプリ側では治せな い Composeコード layoutlib (mac-arm64版) ネイティブコードで描画 layoutlib (linux-x86_64版) ネイティブコードで描画 画像 (Mac) ローカルでの出力 画像 (CI) CI環境での出力 丸めの結果が わずかに違う 40
  16. 環境を近づける 環境を「近づける」のでは なく「同一にする」 • しきい値 (imageDifferenceThreshold)で ±1を許容する手もあるが、本物の1 ピクセルの変化まで見逃してしまう Mac (arm64)

    ローカル画像 CI (Linux/x86_64) CI画像 ±1の差分が残る 環境を同一にする Docker (Linux/x86_64) ローカル画像 CI (Linux/x86_64) CI画像 バイト単位で一致 ※ Linux向けlayoutlibにarm64版はなく、 揃える先はLinux/x86_64一択 41
  17. 環境を近づける 環境を「近づける」のでは なく「同一にする」 • だから、生成環境そのものを1つに 固定する CIと同じLinux/x86_64環境を Dockerで丸ごと手元に再現する Mac (arm64)

    ローカル画像 CI (Linux/x86_64) CI画像 ±1の差分が残る 環境を同一にする Docker (Linux/x86_64) ローカル画像 CI (Linux/x86_64) CI画像 バイト単位で一致 ※ Linux向けlayoutlibにarm64版はなく、 揃える先はLinux/x86_64一択 42
  18. 環境を近づける 環境を「近づける」のでは なく「同一にする」 Mac (arm64) ローカル画像 CI (Linux/x86_64) CI画像 ±1の差分が残る

    • 再生成してもgit diffが出ない 環境を同一にする 差異を減らすのではなくゼロにする Docker (Linux/x86_64) ローカル画像 CI (Linux/x86_64) CI画像 バイト単位で一致 ※ Linux向けlayoutlibにarm64版はなく、 揃える先はLinux/x86_64一択 43
  19. Dockerの遅さはコンテナ常駐 + キャッシュ永続化で解決 Terminal • コンテナを常駐させてdocker execで実行し、 GradleキャッシュはNamed Volumeに永続化す $

    docker run -d \ .-name screenshot-container \ る -e CI=true \ -v "$MAIN_REPO":/workspace \ 23秒で回る • 単一モジュールならwarm -v gradle-cache:/home/circleci/.gradle \ -w /workspace \ cimg/android:2025.11 \ sleep infinity 45
  20. gradlewの直叩きは仕組みで禁止する Terminal • 実行の入り口はmake update_screenshot / $ ./gradlew updateDebugScreenshotTest make

    validate_screenshotに一本化する ================================================ ❌ Direct execution of screenshot tasks is not allowed • CI以外でのgradlew直接実行は、convention outside CI environment. Please use one of the following commands instead: pluginのガードで失敗させる make update_screenshot (changed modules only) make update_screenshot_all (all modules) • コンテナだけがCI=trueを渡すので、スクショはコ ================================================ ンテナの中でしか生成できない BUILD FAILED • 47
  21. Compose Screenshot Testingを運用して 見えてきた課題 01 手元のMacとCIで生成されるスクショに差分が出る 02 スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい 03

    PR上で画像の差分が見えないことがある 04 ネットワーク画像が撮れない 05 Preview関数やテストクラスの命名がばらばらになる 48
  22. スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい • 失敗したテストの Reference / Actual / Diff を

    PRコメントの表で見せたい • しかしGitHubには、APIから画像をアップロード する方法がない 49
  23. スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい • 失敗したテストの Reference / Actual / Diff を

    PRコメントの表で見せたい • しかしGitHubには、APIから画像をアップロード する方法がない https://github.blog/changelog/2026-09-01-github-cli-media-in-issues-pull-requests-and-comments/ 50
  24. スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい • 失敗したテストの Reference / Actual / Diff を

    PRコメントの表で見せたい • しかしGitHubには、APIから画像をアップロード する方法がない https://github.blog/changelog/2026-09-01-github-cli-media-in-issues-pull-requests-and-comments/ 51
  25. 比較画像をコミットして、 permalinkの表をコメント する • 比較画像は、validateが出力 する実描画(rendered/)と 差分マスク(diffs/)から作る • 消し忘れはCIのチェックとリマ インダーコメントで防ぐ

    1. validate失敗 2. プラグインの成果物から 比較画像を用意 3. [skip ci]でPRブランチに コミット 4. コミットSHA固定のリンクで 表をコメント 5. マージ前にクリーンアップ 54
  26. 比較画像をコミットして、 permalinkの表をコメント する • 比較画像は、validateが出力 する実描画(rendered/)と 差分マスク(diffs/)から作る • 消し忘れはCIのチェックとリマ インダーコメントで防ぐ

    1. validate失敗 2. プラグインの成果物から 比較画像を用意 3. [skip ci]でPRブランチに コミット 4. コミットSHA固定のリンクで 表をコメント 5. マージ前にクリーンアップ 55
  27. 比較画像をコミットして、 permalinkの表をコメント する • 比較画像は、validateが出力 する実描画(rendered/)と 差分マスク(diffs/)から作る • 消し忘れはCIのチェックとリマ インダーコメントで防ぐ

    1. validate失敗 2. プラグインの成果物から 比較画像を用意 3. [skip ci]でPRブランチに コミット 4. コミットSHA固定のリンクで 表をコメント 5. マージ前にクリーンアップ 56
  28. 比較画像をコミットして、 permalinkの表をコメント する • 比較画像は、validateが出力 する実描画(rendered/)と 差分マスク(diffs/)から作る • 消し忘れはCIのチェックとリマ インダーコメントで防ぐ

    1. validate失敗 2. プラグインの成果物から 比較画像を用意 3. [skip ci]でPRブランチに コミット 4. コミットSHA固定のリンクで 表をコメント 5. マージ前にクリーンアップ 57
  29. 比較画像をコミットして、 permalinkの表をコメント する • 比較画像は、validateが出力 する実描画(rendered/)と 差分マスク(diffs/)から作る • 消し忘れはCIのチェックとリマ インダーコメントで防ぐ

    1. validate失敗 2. プラグインの成果物から 比較画像を用意 3. [skip ci]でPRブランチに コミット 4. コミットSHA固定のリンクで 表をコメント 5. マージ前にクリーンアップ 58
  30. 比較画像をコミットして、 permalinkの表をコメント する • 比較画像は、validateが出力 する実描画(rendered/)と 差分マスク(diffs/)から作る • 消し忘れはCIのチェックとリマ インダーコメントで防ぐ

    1. validate失敗 2. プラグインの成果物から 比較画像を用意 3. [skip ci]でPRブランチに コミット 4. コミットSHA固定のリンクで 表をコメント 5. マージ前にクリーンアップ 59
  31. Compose Screenshot Testingを運用して 見えてきた課題 01 手元のMacとCIで生成されるスクショに差分が出る 02 スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい 03

    PR上で画像の差分が見えないことがある 04 ネットワーク画像が撮れない 05 Preview関数やテストクラスの命名がばらばらになる 60
  32. PR作成をskillにして、 before/afterの表を自動生成する • BeforeもAfterも、参照画像はもうリポジトリにコ ミッ トされている • PR作成のskillがその変更を検出して、 SHA固定の permalinkで並べた表を

    PR本文に埋め込む https://github.com/{owner}/{repo}/blob/{SHA}/{path}?raw=true • PRを作るだけで UI差分が載る。 diffビューが壊れて いても確認できる 65
  33. PR作成をskillにして、 before/afterの表を自動生成する • BeforeもAfterも、参照画像はもうリポジトリにコ ミッ トされている • PR作成のskillがその変更を検出して、 SHA固定の permalinkで並べた表を

    PR本文に埋め込む https://github.com/{owner}/{repo}/blob/{SHA}/{path}?raw=true • PRを作るだけで UI差分が載る。 diffビューが壊れて いても確認できる 66
  34. PR作成をskillにして、 before/afterの表を自動生成する • BeforeもAfterも、参照画像はもうリポジトリにコ ミッ トされている • PR作成のskillがその変更を検出して、 SHA固定の permalinkで並べた表を

    PR本文に埋め込む https://github.com/{owner}/{repo}/blob/{SHA}/{path}?raw=true • PRを作るだけで UI差分が載る。 diffビューが壊れて いても確認できる 67
  35. Compose Screenshot Testingを運用して 見えてきた課題 01 手元のMacとCIで生成されるスクショに差分が出る 02 スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい 03

    PR上で画像の差分が見えないことがある 04 ネットワーク画像が撮れない 05 Preview関数やテストクラスの命名がばらばらになる 69
  36. Previewかどうかの判定は、  GmImage.kt 共通の画像 Composableに集約する @Composable fun GmImage(source: ImageSource, ...)

    { • 「Previewかどうか」を知っているのは 共通の画 val isInspectionMode = LocalInspectionMode.current val showComposedPlaceholder = isInspectionMode .& source is ImageSource.Network .& 像 Composableだけ • Inspection Modeのときは、プレースホルダを 自動で描画する source.previewResId .= null val painter = when { showComposedPlaceholder .> null ./ プレースホルダを合成して描画 source is ImageSource.Network .> if (isInspectionMode) { painterResource(requireNotNull(source.previewResId)) } else { rememberAsyncImagePainter(.* ImageRequest(source.uri) ./) } source is ImageSource.Resource .> painterResource(source.resId) else .> null } ./ ... } 73
  37. Previewかどうかの判定は、  GmImage.kt 共通の画像 Composableに集約する @Composable fun GmImage(source: ImageSource, ...)

    { • 「Previewかどうか」を知っているのは 共通の画 val isInspectionMode = LocalInspectionMode.current val showComposedPlaceholder = isInspectionMode .& source is ImageSource.Network .& 像 Composableだけ • Inspection Modeのときは、プレースホルダを 自動で描画する source.previewResId .= null val painter = when { showComposedPlaceholder .> null ./ プレースホルダを合成して描画 source is ImageSource.Network .> if (isInspectionMode) { painterResource(requireNotNull(source.previewResId)) } else { rememberAsyncImagePainter(.* ImageRequest(source.uri) ./) } source is ImageSource.Resource .> painterResource(source.resId) else .> null } ./ ... } 74
  38.  ImageSource.kt 使う側は、 ImageSourceを渡すだけ • • sealed interface ImageSource {

    画像を使う側は、 Previewのことを何も意識しな data class Network( い val uri: Uri, @DrawableRes val previewResId: Int? = null, ) : ImageSource Previewでも実画像を出したいときは、 previewResIdを渡せる data class Resource( @DrawableRes val resId: Int ) : ImageSource } 76
  39. Compose Screenshot Testingを運用して 見えてきた課題 01 手元のMacとCIで生成されるスクショに差分が出る 02 スクショの検証に失敗したとき、 PR上で比較差分を出すのが難しい 03

    PR上で画像の差分が見えないことがある 04 ネットワーク画像が撮れない 05 Preview関数やテストクラスの命名がばらばらになる 78
  40. 85

  41. スクリーンショットギャラリー  gallery_data.generated.js { • 画面・コンポーネント種別・ state・Light/Dark "key": "product.list", "label":

    "商品リスト", を、すべてテストの命名から自動導出する "components": [{ "key": "ProductItem", "label": "商品アイテム", "type": "component", "states": [{ "key": "WithReviewScore", "label": "レビュースコアあり", "light": ".../ProductItemTest_ProductItemWithReviewScore_….png", "dark": ".../ProductItemTest_ProductItemWithReviewScoreDark_….png" }] }] • 人間が編集するのは日本語ラベルのファイル 1 つだけ } 87
  42. テストの参照画像 参照画像を走査して、 命名からデータを作り、 HTMLに組む • 入力はテストの参照画像そのもの 追加の撮影も、管理用のDBもない • 命名から導出したデータと、ビューアの テンプレートHTMLを分離する

    • reference/**/*.png 命名をパースして メタデータを導出 (日本語ラベル ) ※手編集はここだけ データファイルを生成 (keyとラベルを両方含む・自動生成 ) テンプレートHTMLと組み合わせる ※テンプレートは最初に作ったらほぼ固定 同じデータから、リポジトリ参照版も画像 埋め込みの単一HTMLも生成できる • labels.json ふだん人間が編集するのは、日本語ラ ベルのJSONだけ リポジトリ参照版HTML 軽量・ローカル閲覧用 単一HTML (画像埋め込み) 社内共有用 常設URL(Cloud Run + IAP)へ ※ mainへのマージで Actionsが再生成し、 更新PRを自動作成 88
  43. テストの参照画像 参照画像を走査して、 命名からデータを作り、 HTMLに組む • 入力はテストの参照画像そのもの 追加の撮影も、管理用のDBもない • 命名から導出したデータと、ビューアの テンプレートHTMLを分離する

    • reference/**/*.png 命名をパースして メタデータを導出 (日本語ラベル ) ※手編集はここだけ データファイルを生成 (keyとラベルを両方含む・自動生成 ) テンプレートHTMLと組み合わせる ※テンプレートは最初に作ったらほぼ固定 同じデータから、リポジトリ参照版も画像 埋め込みの単一HTMLも生成できる • labels.json ふだん人間が編集するのは、日本語ラ ベルのJSONだけ リポジトリ参照版HTML 軽量・ローカル閲覧用 単一HTML (画像埋め込み) 社内共有用 常設URL(Cloud Run + IAP)へ ※ mainへのマージで Actionsが再生成し、 更新PRを自動作成 89
  44. 92

  45. 94

  46. 開発の起点が Figmaからプロトタイプに変わってきた 以前 今 PdMが起案・ワイヤーを作る 誰かがアイデアを起案 AIで動くプロトタイプを作る デザイナーがデザインを作る Figmaで関係者の認識合わせ デザイナー待ちで

    ここで詰まる PdMやエンジニアが作る デザインガイドラインは 整備している前提 PdMが動くもので確認 デザイン・仕様を Figmaに清書 プロトタイプを起点に開発 エンジニアが実装 デザイナーが細部をチェック PdMが最終確認・リリース PdMが最終確認・リリース 動くプロトタイプで認識合わせを前倒しし、入り口の詰まりが軽減された 95
  47. AIに任せられるのは、 規約を育てているから 1. AIが実装する • 静的解析で直せるものは、 2. 人間がレビューで 指摘する ktlint

    + spotlessで 機械的に整える 同じ指摘を繰り返しにくくなる • 人間がAIにした指摘は、規 約に反映して返す ◦ この規約更新も skillで 4. セルフレビューが 規約に照らして直す 3. 指摘を規約に 反映する Copilotレビューと2段構え 規約更新もskillで習慣化 習慣化している 106
  48. AIに任せられるのは、 規約を育てているから 1. AIが実装する • 規約は一発では守られない 2. 人間がレビューで 指摘する ◦

    claudeのサブエージェント によるセルフレビューと 同じ指摘を繰り返しにくくなる Copilotレビューの 2段構え で、規約に照らして直す 4. セルフレビューが 規約に照らして直す 3. 指摘を規約に 反映する Copilotレビューと2段構え 規約更新もskillで習慣化 107