4、快照生成与存储:文件格式(PNG)、存储路径、命名约定、版本控制

快照生出来只是个开始,怎么存、怎么命名、怎么管版本,才是真正考验工程水平的地方。我自己被这个问题坑过不止一次——早期一个项目里,开发者随便拉个文件名,结果CI上一跑,基线快照和失败快照混在一起,排查问题简直像大海捞针。后来我们重新设计了存储体系,才算彻底摆脱混乱。

为什么选 PNG?

你可能会问,为什么非得是PNG?用JPEG不行吗?嗯,这个问题我当初也纠结过。直接给结论:PNG是无损压缩,像素级对比时不会引入伪影。Compose绘制的UI就是精确的颜色和形状,任何有损格式都会破坏对比准确性。

格式 压缩方式 适用场景 我的建议
PNG 无损 快照回归(必须) 唯一选择
JPEG 有损 照片类,不适用 不要用
WebP 可无损也可有损 可考虑(但需工具支持) 慎用,生态不统一
BMP 无压缩 调试用,体积大 不推荐

另外要注意:PNG还可以保留透明度(Alpha通道)。如果你的Compose界面用到了透明背景或半透明元素,JPEG一压缩就全废了。所以我在项目中直接硬性规定——快照必须输出PNG,且带Alpha通道。

小技巧:生成快照时,建议把位图格式设为 Bitmap.Config.ARGB_8888,保证颜色深度和透明度完整。

存储路径:别让快照掉进垃圾堆

我见过最糟糕的做法:把所有快照扔到 /build/screenshots/ 下,文件名靠时间戳区分。结果CI跑一次积压几百张,没人知道哪张是基线的、哪张是对比失败的。

一个清晰的路经方案应该包含:模块名 → 测试类 → 测试方法 → 设备/配置。举个例子:

app/build/reports/snapshots/
├── baseline/
│   └── com.example.ui.LoginScreen/
│       └── LoginScreen_initial.png
└── failures/
    └── api33_sw600dp/
        └── com.example.ui.LoginScreen/
            └── LoginScreen_initial.png

这里「baseline」放基线,「failures」下按设备维度再细分。为什么要加上 api33_sw600dp 这样的配置标识?因为不同屏幕尺寸、不同API级别下,Compose的渲染可能不一样。我有个项目就是没加这个维度,结果同事在平板设备上跑出了绿屏,却跟手机基线一比就飘红,浪费时间排查。

核心原则:路径结构必须是确定性的——给定测试类和配置,路径唯一且可预测。

命名约定:一眼看懂快照

文件名是快照的身份证。我的习惯是:{测试类简称}_{场景描述}_{配置后缀}.png。配置后缀用来区分多设备,比如 _light, _dark, _tablet。

文件名 含义
LoginScreen_initial_light.png LoginScreen测试类,初始状态,浅色主题
LoginScreen_error_dark.png 错误提示状态,深色主题
ProfileCard_expanded_tablet.png 展开状态的ProfileCard,平板宽度

你可能会问:直接在测试方法里用 @TestName("initial_light") 这种注解不行吗?行,但我建议文件名里不要带 @ 符号或空格,有些文件系统在CI上会出问题——我曾经因为文件名里有个冒号,在Linux CI上死活跑不通,排查了半天。

另外,我建议所有文件名统一小写字母 + 下划线,避免大小写敏感导致跨平台问题。Windows不区分大小写,但Linux区分,你想想看,如果基线快照叫 LoginScreen.png,新生成的叫 loginscreen.png,Git上就会显示两个文件,对比器根本找不到匹配项。

版本控制:快照也是代码

很多团队把快照扔进 .gitignore,觉得是二进制文件没必要管。但我告诉你:快照回归的核心就是基线快照要作为版本控制的一部分。否则你没法回溯「上次UI是长这样的」。

我的做法:

  • 基线快照全部进Git仓库,跟测试代码放在一起。
  • 每次修改Compose代码后,如果基线更新,必须提交新的基线图片。
  • 对比失败产生的临时快照(failures目录)不进版本控制,它们只是CI过程的中间产物。
避坑指南:我曾经在一家公司的CI上发现快照对比总是失败,后来发现是基线快照被某个同事在本地手动裁剪过,提交后CI拉到的基线跟原始生成的不一致。所以基线快照必须由自动化工具生成,禁止手动修改。

快照生命周期的整体视图

下面这张图总结了从生成到存储再到版本控制的完整流程,你可以把它挂在团队Wiki上。

快照生成与存储流程 代码提交 生成快照 PNG输出 命名约定 文件路径规划 基线存入 Git 对比失败? 临时文件 不提交Git 基线通过 版本控制 提交Git 命名约定示例 LoginScreen_initial _light.png

代码实践:生成文件名

直接上代码,这是我项目中提取的片段:

fun generateSnapshotFileName(
    testClassName: String,
    scenario: String,
    configuration: DeviceConfiguration
): String {
    val configSuffix = buildString {
        if (configuration.isDarkTheme) append("_dark") else append("_light")
        if (configuration.screenWidthDp >= 600) append("_tablet")
        append("_dpi${configuration.densityDpi}")
    }
    return "${testClassName}_${scenario}$configSuffix.png"
}

注意我把 densityDpi 也加进去了。为什么?因为同一个Composable在不同DPI下渲染结果可能不同——比如图标大小、间距。我曾经踩过这个坑:一位同事在模拟器上生成基线,另一位在真机上跑测试,DPI不同导致快照偏移1像素,对比失败。加上DPI标识后,这种问题就再也没出现。

总结一张检查表

  • ✅ 输出格式:PNG(ARGB_8888)
  • ✅ 存储路径:模块/测试类/测试方法/配置维度
  • ✅ 文件名:{类}_{场景}_{主题}_{设备}.png,全小写下划线
  • ✅ 基线快照:提交到Git仓库,禁止手动修改
  • ✅ 失败快照:临时目录,不加入版本控制

做到这几点,你的快照管理就能达到生产级别。下一级就是如何把这些快照跑进CI流水线了——不过那是后面章节的内容,今天先把存储这关守好。