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. 04通过 Demo Mode 固定系统栏
  5. 05输出 KDoc 和参数表
  6. 06支持 GitHub Actions 与 Gradle Managed Devices

适用场景

  1. 01在 CI 中为每次提交生成预览目录
  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 目录。
  • 用户想通过 ADB 从 shell 脚本中截取某个预览的截图,而无需使用 IDE。
  • 用户想将预览目录部署到 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、网络、数据库),无需模拟对象。
  • 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 托管设备(请参阅 ../../../instrumentation/managed-devices/running-tests-on-gradle-managed-devices/SKILL.md)。在运行器上启用 KVM,以加速模拟器。

工作流

  • 1. 确认为什么需要设备。 基于 Host-JVM 的渲染器(Paparazzi、Roborazzi)无需设备即可运行 layoutlib,速度很快,但生成的像素只是近似效果。设备渲染通过 ART 完成:使用设备字体指标、真实的 Coil/Glide 加载(占位图、网络图片和交叉淡入效果都会执行)、在真实路径上运行 GPU/AGSL/模糊处理,并渲染包括状态栏和导航栏在内的整个屏幕。代价是在 CI 中使用模拟器。只有在目录保真度很重要时,才接受这种取舍;否则继续使用 Host-JVM 差异对比。

  • 2. 通过 ADB 截取一个预览(基础操作)。 调试应用已构建并在设备上启动一次后,HotSwan 预览库会提供 HotSwanPreviewActivity,它通过 composable 字符串 extra 接收完整限定名形式的 @Preview 函数名:

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 旁边的 gutter 图标,即可在不到半秒内将其启动到设备上,无需重新构建,并通过反射解析 @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 技能。对于单台设备,runner action 只需更少的配置。)

  • 6. 将目录部署到 GitHub Pages。 下载的 zip 不是团队资产,URL 才是。再用一个 job 发布捕获的目录:
  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
系统栏包含(状态栏 + 导航栏)不包含
运行时行为完整——网络、图片加载、数据库、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 效果或系统界面元素一致时,依赖主机 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 会显示最新目录。
  • 耗时较长的预览(网络/数据库/动画)会使用 @PreviewScreenshot(renderDelay = …);全局 renderDelayMs 接近最低值,以适用于大多数静态预览。

参考资料

  • hotswan.dev/blog/compose-preview-screenshots-ci — skydoves,《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。

评论

支持 Markdown