Paparazzi:无需真机的 Android 截图测试
UI 回归是 Android 测试的老大难:单元测试测不了"长得对不对",真机截图又慢又贵还受设备碎片化折磨。Cash App(Square)开源的 Paparazzi(狗仔队)给出了优雅解法——在 JVM 上直接渲染应用界面,不需要物理设备或模拟器。它把 Android 布局引擎(含 layoutlib)跑在本机,截图快如单元测试;配合"录制金标准 → 校验对比"工作流和 HTML 差异报告,UI 回归从此能进 CI 把关。View 与 Jetpack Compose 都支持,Instagram、Cash App 等千万级应用的 UI 都靠它守护。参考 GitHub 仓库。
1、概述:Paparazzi 是什么
官方一句话:"An Android library to render your application screens without a physical device or emulator"(无需真机/模拟器即可渲染应用界面的 Android 库)。名字"狗仔队"很形象——专门给界面拍照存证:
出品 Cash App(Square 系),2019 年开源,Apache 2.0
核心能力 JVM 上渲染 Android 界面并截图,无需设备/模拟器
工作流 record 录制金标准 → verify 像素级比对 → diff 报告
支持 传统 View + Jetpack Compose;JUnit 4 / JUnit 5
报告 HTML 报告展示所有测试运行与截图,失败生成 diff
配置 设备规格(PIXEL_5 等)、主题、语言等可按测试指定
版本 2.0.0-alpha05(Gradle 插件),快照版在 Central Snapshots核心要点:
1. JVM 渲染是最大卖点:不启模拟器,速度接近单元测试
2. View 与 Compose 双支持,迁移期项目两边都能测
3. 金标准(golden value)进版本库,像素变化即测试失败
4. Square 系出品,Cash App 等生产级应用多年验证
2、痛点:UI 回归为什么难
看三种传统方案的困境,就能理解"JVM 渲染"的价值:
方案 问题
------------------------------------------------------------------------
传统单元测试 测逻辑可以,测"界面长什么样"无能为力
真机/模拟器截图 慢(每台设备数分钟)、贵(CI 机器/农场)、
碎片化(多分辨率/多 API 需全覆盖)
Espresso UI 测试 验证行为与断言,但"像素是否漂移"看不见
人工目测 主观、易漏、无法进 CI
Paparazzi 的破局:
把 Android 的 layoutlib(布局渲染库)搬到 JVM 上直接跑
→ 渲染发生在开发机/CI 的普通 JVM 进程里
→ 无需 Boot 模拟器、无需连真机,速度提升一个数量级
→ 确定性渲染:同样输入必得同样像素,diff 才有意义核心要点:
1. 截图测试的本质是"像素级断言"——UI 任何漂移都逃不过
2. JVM 渲染省掉模拟器启动与设备维护,CI 成本骤降
3. 确定性渲染是 diff 可信的前提——同输入必同输出
4. 界面变更的"无意修改"在 MR 里直接现形,防患上线前
3、快速上手:一个测试类看懂全部
README 官方示例——JEP 301 规则 + inflate/snapshot 两个核心 API,View 与 Composable 都一行截图:
class LaunchViewTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = PIXEL_5, // 设备规格
theme = "android:Theme.Material.Light.NoActionBar" // 主题
// ...更多选项见文档
)
@Test
fun launchView() {
val view = paparazzi.inflate<LaunchView>(R.layout.launch)
// 或者直接构造:val view = LaunchView(paparazzi.context)
view.setModel(LaunchModel(title = "paparazzi")) // 喂业务数据
paparazzi.snapshot(view) // 截图!
}
@Test
fun launchComposable() { // Compose 同样简单
paparazzi.snapshot {
MyComposable()
}
}
}核心要点:
1. @get:Rule 挂 Paparazzi:deviceConfig/theme 按测试类配置
2. inflate<T> 从布局 XML 实例化;也可用 paparazzi.context 手动构造 View
3. snapshot(view) 完成渲染与截存;Compose 用 snapshot { } lambda
4. 业务 Model 直接喂进 View——任意状态(空态/错误/边界值)都能摆拍
4、三大 Gradle 任务:record 与 verify 工作流
截图测试的心跳是"录制金标准 → 校验对比"循环,三个任务覆盖全流程:
// ① 跑测试并生成 HTML 报告(本地浏览所有截图)
./gradlew :sample:testDebug
// → 报告位于 sample/build/reports/paparazzi/
// 展示所有测试运行与截图
// ② 录制金标准(golden values)
./gradlew :sample:recordPaparazziDebug
// → 截图存入版本控制的固定位置(默认 src/test/snapshots)
// → 提交进 Git,成为团队共享的"界面基准线"
// ③ 校验:与已录制的金标准对比
./gradlew :sample:verifyPaparazziDebug
// → 运行测试并逐像素比对
// → 不一致即失败,diff 图生成于 sample/build/paparazzi/failures
// → MR 里一眼看出"这次改动让界面变了哪里"核心要点:
1. record 存基准:默认写入 src/test/snapshots 并随代码提交
2. verify 做对比:像素不一致失败,diff 图直观呈现差异
3. 日常节奏:改界面 → 本地 verify → 有意变更则重 record 并提交
4. CI 挂 verify 任务,界面回归与逻辑回归同等级把关
5、JUnit 5 支持与配置项
项目用 JUnit 5?官方给出手动 setup/teardown 的等价写法:
lateinit var paparazzi: Paparazzi
@BeforeEach
fun setup(testInfo: TestInfo) {
paparazzi = Paparazzi().apply {
setup(
testName = TestName(
packageName = testInfo.testClass.get().`package`?.name.orEmpty(),
className = testInfo.testClass.get().simpleName,
methodName = testInfo.testMethod.get().name
)
)
}
}
@AfterEach
fun tearDown() {
paparazzi.teardown()
}
@Test
fun snapshot_example() {
val view = paparazzi.inflate<TextView>(android.R.layout.simple_list_item_1).apply {
text = "Hello Paparazzi"
textSize = 24f
gravity = Gravity.CENTER
}
paparazzi.snapshot(view)
}
// 要点:JUnit 5 无 @Rule 机制,需手动 setup/teardown
// 并用 TestInfo 显式提供 包名/类名/方法名 作为快照命名核心要点:
1. JUnit 5 没有规则机制,手动 setup/teardown 等价实现
2. TestName(包/类/方法)决定快照文件的存储路径命名
3. deviceConfig 可指定 NEXUS_7/PIXEL_5 等预置规格或自定义
4. theme/语言/locale 等按测试配置,多主题多语言矩阵覆盖
6、工程化:Git LFS 管理快照
截图测试的代价是仓库里堆 PNG。官方推荐 Git LFS 管理快照,并给出 CI 完整方案:
# 本地初始化(一次性)
brew install git-lfs
git lfs install --local
git lfs track "**/snapshots/**/*.png" # 只把快照 PNG 交给 LFS
git add .gitattributes
git config lfs.setlockablereadonly false # 优化 checkout 性能(可选)
# CI 防呆:pre-receive 钩子拦截"没用 LFS 提交的 PNG"
# 对比 .gitattributes 标记的文件与实际被 LFS 跟踪的文件,
# 不一致则报错退出,提示开发者正确使用 LFS 重新提交
# CI 构建脚本流程
if [[ is running snapshot tests ]]; then
"$HOOKS_DIR"/pre-receive # 快速失败:检查是否都走了 LFS
git lfs install --local
git lfs pull # 拉取全部快照基准
fi核心要点:
1. 快照 PNG 走 Git LFS,主仓库不被二进制拖垮
2. track 规则只匹配 snapshots 目录,不影响其他图片资源
3. pre-receive 钩子强制 LFS 合规,防"漏网大文件"
4. CI 先 pull 快照再跑 verify——基准齐了对比才有效
7、避坑指南:Lottie 与 Compose 特殊场景
官方 README 记录了两个高频坑及解法,遇到异常先对照这里:
// 坑① Lottie 动画截图抛异常
// 原因:Lottie 默认后台线程执行,与 JVM 渲染环境冲突
// (#494 / #630)
@Before
fun setup() {
LottieTask.EXECUTOR = Executor(Runnable::run) // 强制同步执行
}
// 坑② Compose 里 GoogleMap() 等组件检查 LocalInspectionMode
// 背景:这类组件用它短路成 @Preview 安全版本;
// Paparazzi 故意【不】全局设置 LocalInspectionMode——
// 为了让快照代表真实生产输出(类似对 View 的 isInEditMode 处理)
// 解法:测试里手动用 CompositionLocalProvider 包一层
@Test
fun inspectionModeView() {
paparazzi.snapshot(
CompositionLocalProvider(LocalInspectionMode provides true) {
YourComposable()
}
)
}核心要点:
1. Lottie 需强制同步 Executor,否则渲染异常
2. Paparazzi 刻意不设 LocalInspectionMode——保真优先,这是设计决策
3. 需要"预览态"时用 CompositionLocalProvider 显式开启
4. Jetifier 用户需在 gradle.properties 加 ignorelist 排除内置依赖
8、总结
Paparazzi 把"界面长得对不对"变成了可进 CI 的自动化断言:JVM 渲染免设备提速一个数量级,record/verify 工作流让像素变化有据可查,HTML diff 报告让回归一目了然。
关键要点:
- 定位:JVM 渲染 Android 界面的截图测试库,Cash App/Square 出品
- 核心:layoutlib 跑在本机,无需真机/模拟器,确定性渲染
- API:@Rule + inflate + snapshot;View 与 Compose 双支持
- 工作流:record 录金标准 → verify 像素比对 → failures diff 图
- 工程化:快照走 Git LFS + pre-receive 钩子强制合规
- 兼容:JUnit 4/5(5 需手动 setup/teardown)
- 避坑:Lottie 强制同步执行;LocalInspectionMode 按需手动提供
对于追求"UI 变更可审、回归可拦"的 Android 团队而言,Paparazzi 是截图测试的标杆方案——它证明了 UI 回归不必依赖昂贵的设备农场,一台 CI 机器上的 JVM 进程就足够。从为最核心的两三个界面拍快照开始,建立 record/verify 的肌肉记忆,再用 Git LFS 解决存储后顾之忧;随着覆盖面扩大,你会发现"设计师改了 1px 你都知道"不是玩笑,而是每一条 MR 自动化审查的一部分。配合 Robolectric 同源的 layoutlib 思路,Paparazzi 也示范了"把 Android 框架搬进 JVM 测试"这条路线的上限——快,且足够真。

