iOS 视觉回归测试:用 simctl 批量截图,定位多语言布局偏移

iOS 视觉回归测试可以从一条很小的链路做起:用启动参数进入指定页面,用 simctl 批量截图,再把当前画面与基线逐像素比较。它适合检查多语言、明暗主题下的静态布局;要让结果可信,还必须控制截图环境,并逐张审核差异。

最近在 ShotZen 的 Paywall 改造中,我调整了订阅档位顺序,强化推荐徽章,又给终身买断加了一枚 NO RENEWALS 标签。报告把问题指向了一个意料之外的地方:标签的内边距改变了行高,下方订阅行、按钮和页脚随之一起移动。

更有意思的是,把标签改成纯文字后,差异比例反而下降了。代码改动量与画面差异面积,没有我们想象中那么直接的关系。

这篇文章沿着这个案例,拆开一个本地视觉回归引擎:它怎样抵达页面、怎样生成差异、哪些结果可以相信,以及接入持续集成前还缺什么。

一、iOS 视觉回归测试,要先确定检查什么

一个页面在英文亮色模式下正常,不代表它在日文暗色模式下也正常。文字长度、换行、颜色、系统组件外观,都可能影响最终布局。

以项目里的检查矩阵为例,4 种语言、2 种主题、4 个页面,对应 32 个截图状态。覆盖单位应该是"页面及其状态组合",而不只是页面名称。

这个引擎检查的是:在约定环境和输入下,页面当前的静态外观与上次接受的外观有哪些差异。

它不验证购买能否成功,不验证按钮点击后的行为,也不能仅凭截图判断某段价格说明是否正确。即使差异比例为零,业务逻辑仍然可能有问题。

几种常见方式的选择,主要取决于需要观察哪一层:

方式 更适合观察什么 接入时的主要取舍
XCUITest 与截图 交互路径、应用启动和跨页面行为 维护 UI 测试及状态准备;也可以结合启动参数直达页面
swift-snapshot-testing View、ViewController 以及其他值的快照 引入测试依赖,设计渲染配置与断言
DEBUG 注入点与 simctl 模拟器中指定页面的整屏静态外观 在应用内增加页面构造入口,自己维护状态隔离与截图校验

不要把快照库理解成"只能渲染一个孤立 View"。Point-Free 的库支持 ViewController、设备配置和 trait collection,也支持图像以外的快照形式。具体能力可看它的官方说明1

我在这次案例中采用的是第三种方式:把"进入页面"变成一个确定的启动入口,让同一套外部脚本负责截图和比对。接入成本下降的同时,隔离页面所需的初始化工作也转移到了这个入口。

二、三段式架构:启动入口、截图器、差异报告

应用内的 VisualRegressionHarness.swift 负责读取参数、准备语言和主题、构造目标控制器,再把它设为窗口的根控制器。

Mac 上的 capture.py 负责选择模拟器、定位和安装 .app,按语言、主题、页面逐一启动应用并截图。

diff_report.py 负责读取基线与当前 PNG,计算差异比例、绘制区域框,输出 HTML 和 summary.jsonrun.sh 把初始化、基线、检查、打开报告封装成几个命令。 三个部分依靠很少的约定连接起来:

  • -VRScreen-VRLang-VRStyle 是启动参数协议。
  • config.json 中的 screens[].id 对应 Swift 页面注册表的 key。
  • • 图片路径使用 <语言>/<主题>/<页面>.png,两次运行通过相同路径配对。

这种拆分让通用脚本不用知道每个应用的业务结构。新增一个应用时,真正需要适配的是页面构造、固定数据、主题和语言切换。

Swift 侧只使用 UIKit 等应用已有能力;主机侧仍需要 Python、Pillow,以及能提供模拟器和 simctl 的 Xcode 环境。"Swift 侧零额外依赖"不等于整套工具没有运行环境要求。

三、用启动参数直达页面,关键在初始化顺序

截图器会发出这样的启动请求。下面的 UDID 和 bundle ID 需要替换成目标项目的实际值:

复制代码
xcrun simctl launch   \  -VRScreen paywall \  -VRLang ja \  -VRStyle dark

simctl 只负责传递这些参数。它不会因为看到 -VRLang ja 就自动替应用完成语言切换。参数的含义由 Harness 实现。

一个合理的执行顺序是:解析参数、准备固定数据、切换应用语言、同步主题、构造页面、显示窗口。尤其要在构造控制器之前设置语言,否则已经生成的文案未必会重新加载。

把接线放在正常路由之前

下面是供 UIKit、SceneDelegate 生命周期项目参考的接线片段。它依赖项目自己的 Harness、主题管理器和路由器,需要合并到现有初始化函数中:

javascript 复制代码
guard let windowScene = scene as? UIWindowScene else { return }let window = UIWindow(windowScene: windowScene)self.window = window#if DEBUGif VisualRegressionHarness.activateIfRequested(on: window) {    window.makeKeyAndVisible()    return}#endifThemeManager.shared.attach(window)let router = AppRouter(window: window)router.start()

早退使正常路由不再接管当前窗口,因此可以绕过引导、权限引导和业务导航。需要导航栏的页面,应在注册表里用 UINavigationController 包装,而不是事后补一张看起来像导航栏的图片。

但早退也可能跳过页面需要的依赖注入、主题监听或服务初始化。正确做法是明确哪些初始化必须保留,再用固定 fixture 替换网络、相册、登录状态等不稳定输入。

Harness 本体与调用点都应放在 #if DEBUG 内,并检查 Release 配置没有错误定义 DEBUG。这是对编译配置的要求,不能只凭文件名里有"Debug"就认为生产构建一定排除了入口。

主题要与应用自己的状态保持一致

ShotZen 的主题有两层:UIKit 窗口外观,以及应用自己的 ThemeManager。案例中的设置方式如下:

ini 复制代码
// 项目适配片段:在构造目标控制器之前执行ThemeManager.shared.current = .darkwindow.overrideUserInterfaceStyle = .dark

Apple 文档说明,窗口、视图或控制器的 overrideUserInterfaceStyle 可以覆盖系统外观选择。但应用自己保存的主题偏好,仍然由应用代码负责。相关机制见 Apple 的界面外观文档2

如果页面加载时又根据旧偏好应用主题,仅设置窗口外观就可能被覆盖。最后得到一个放在 dark/ 目录里的亮色页面,文件名正确,状态却错了。

语言也有类似问题。使用自定义运行时本地化管理器的项目,应该调用那个管理器;不能假定系统语言参数必然控制了所有文案。 这张实际报告中,英文暗色状态的差异为 3.49%。暗色背景和正文都进入了比对范围,但"页面确实切到了暗色"仍然需要在最初接入时核验。

四、批量截图之前,先把环境固定住

截图工具最容易出现的误区,是把"应用重新启动"当成"环境恢复初始状态"。这两件事并不相同。

应用重启后,UserDefaults、数据库、授权状态仍可能保留;系统弹窗也不一定随着应用进程终止而消失。截图矩阵越大,这些状态的串扰越难凭肉眼及时发现。

固定设备名称,还没有完全固定设备

当前脚本先查找配置指定名称的模拟器;同名候选中优先选择已启动的实例,否则优先选择较新的 runtime。指定名称不存在时,才警告并回退到可用 iPhone。

这比随意复用一台已启动设备稳妥,但它没有把 runtime 和 UDID 完全钉死。同名设备可能对应不同系统版本,安装新 runtime 也可能改变候选结果。

用于稳定门禁时,我建议把设备 UDID、iOS runtime、Xcode 版本和截图尺寸记录到运行清单,基线与当前运行先核对环境身份,再比较像素。这里是改进建议,现有脚本还没有实现完整清单。

复用构建产物时,要防止截到旧代码

脚本通过 xcodebuild -showBuildSettings 获取 BUILT_PRODUCTS_DIRFULL_PRODUCT_NAME,定位已经构建好的 .app。这样可以复用 Xcode 构建产物,而不必每次截图都重新构建。

案例记录提到,这也用于应对特定项目中 CLI 构建碰到的 Embed Pods Frameworks / rsync ... Operation not permitted 问题。但这是利用已有成功产物的工作方式,并没有修复那个构建错误。

更需要留意:当前实现即使收到 --build,构建失败后发现旧 .app 存在,仍可能继续截图。 于是"截图成功"不代表"这次源码已经构建成功"。

本地使用时,应确认 Xcode 刚刚构建的就是目标修改;接入 CI 时,应把构建成功作为前置条件,并绑定本轮构建产物。不能接受构建失败后悄悄比较旧画面。

冻结状态栏,固定业务数据

脚本使用下面的命令控制时间、电量和信号显示:

arduino 复制代码
xcrun simctl status_bar  override \  --time 9:41 \  --batteryLevel 100 \  --batteryState charged \  --cellularBars 4 \  --dataNetwork wifi \  --wifiBars 3

时间具体选几点不影响原理,关键是每次相同。这个操作只控制状态栏外观,不会冻结页面里的日期、倒计时、网络图片或商品价格。

当前截图器还会默认重启模拟器、安装应用、重置应用隐私授权,再冻结状态栏。reboot_simulatorreset_privacy 可以通过配置关闭。需要"已授权"状态的页面,应显式准备对应 fixture,避免重置授权后又触发系统弹窗。

四秒等待是经验值,不是就绪证明

每个状态的主要流程是:终止上一轮应用、传参启动、等待 settle_seconds、执行截图。配置中的 4 秒只是固定等待。

按 32 个状态计算,仅这部分等待就需要 128 秒,还没算构建、模拟器启动、安装和截图耗时。因此,不应该在没有计时数据时宣称它比其他工具快一个数量级。

关闭 UIView 动画可以减少过渡状态,但不会自动停止所有定时器、异步任务或其他渲染活动。复杂页面更适合在固定数据与最终布局完成后发出明确的就绪信号,由截图器等待该信号;这是下一步能力,当前实现仍采用固定等待。

五、像素差异算法:比例到底在计算什么

核心代码很短,下面是一个可以独立运行的 Python 示例。环境需已安装 Pillow:

scss 复制代码
from PIL import Image, ImageChopsPIX_TOL = 24def changed_percent(baseline: Image.Image, current: Image.Image) -> float:    if baseline.size != current.size:        raise ValueError("图片尺寸不同,不能按同一坐标逐像素比较")    base = baseline.convert("RGB")    cur = current.convert("RGB")    # 先求各通道绝对差,再把差值图转为灰度    gray = ImageChops.difference(base, cur).convert("L")    mask = gray.point(lambda value: 255 if value > PIX_TOL else 0)    changed = mask.histogram()[255]    return 100.0 * changed / (mask.width * mask.height)base = Image.new("RGB", (10, 10), "white")cur = base.copy()cur.putpixel((0, 0), (0, 0, 0))print(f"{changed_percent(base, cur):.2f}%")  # 1.00%

计算可以拆成三步:对相同坐标求 RGB 绝对差,把差值图转为灰度,再统计灰度值大于 24 的像素比例。Pillow 的绝对差定义见 ImageChops 文档3

这里有一个容易被注释误导的细节:24 作用于转换后的灰度差值,并非对每个 RGB 通道分别设置容差。

RGB 转灰度采用加权转换,近似为 0.299R + 0.587G + 0.114B,具体定义见 Pillow Image.convert 文档4。这是"通道绝对差的加权值",也不等同于先把两张原图灰度化再求差。

我用单像素输入检查了这个边界:黑色与 (0, 0, 100) 比较,蓝通道虽然变化了 100,灰度差只有约 11,低于阈值,最终没有被标记;相同幅度的红通道或绿通道变化则会被标记。

这个实验说明当前算法会对某些颜色变化不够敏感。如果项目需要任一通道超差就报警,可以考虑对三通道差值取最大值后再阈值化。更改算法之后,需要重新评估噪声和门禁阈值。

两种阈值不能混在一起

PIX_TOL = 24 决定一个像素是否算变化;diff_threshold_pct = 0.5 决定整张图片的变化面积是否触发门禁。

前者抑制细小色值差异,后者容忍少量变化面积。提高任意一个,都可能减少报警,也可能掩盖问题。

案例记录中,非 Paywall 页面最大差异为 0.335%,引擎使用 0.5% 作为全图门槛。这只是该次记录,不能推导为所有 iOS 项目的通用参数,也不能证明小于 0.5% 的变化都无害。

更稳妥的办法是先在同一构建、同一环境下重复采样,查看噪声出现的位置,再决定是否需要区域忽略或更严格的关键区域检查。关键价格文案只错一个字符,面积可能很小,业务影响却很大。

红框表示定位区域,不表示问题数量

现有报告把二值差异图切成 48 × 48 像素的网格,对每个网格使用 getbbox() 找到变化边界,再在当前图上画框。

因此,一处连续变化可能跨过多个格子,产生一串红框。它们不代表多个独立缺陷,也不对应 UIKit 控件边界。

使用网格是降低实现复杂度的选择。连通域分析并非理论上必须依赖 NumPy 或 SciPy;这里仅仅是选择了更简单、只依赖 Pillow 的区域定位办法。

六、Paywall 实战:一枚标签怎样移动整片布局

先看第一轮英文亮色状态的实际报告: 图中显示 32 个状态参与比较,8 个状态触发门禁,英文亮色 Paywall 的差异为 3.55%。左边是基线,中间是当前画面,右边是在当前画面上叠加的差异框。

这里的"8 个回归"是脚本对超阈值状态的命名,并不意味着人工已经确认有 8 个产品缺陷。有意调整的档位顺序也会进入差异统计。

第一次判断,忽略了变化的传播范围

当时的改动包括:把档位顺序调整为终身、年、月;强化推荐徽章;给终身档增加 NO RENEWALS 标签。

最初很容易把约 3.5% 的差异理解成换行序的正常结果。但如果只看数字,就忽略了一个线索:CTA 按钮和页脚也出现了差异,而这些区域并没有计划改动。

案例复盘记录了两轮结果:

观察项 带底色标签的第一轮 标签改为纯文字后
Paywall 差异比例 约 3.5% 约 0.65%---0.88%
年订阅行的记录坐标 1168,基线为 1163 回到 1163
CTA 的记录坐标 1513,基线为 1508 回到 1508

第二轮比例和坐标来自项目复盘记录;上方截图展示的是第一轮,不能把它当作第二轮结果的截图证明。

根因是行高变化,放大器是下方内容

根据案例记录,带内边距的标签抬高了终身订阅行,下方元素随之出现约 5 像素的位移。去掉标签底色和内边距、保留纯文字后,后续行与 CTA 的记录坐标恢复到基线位置。

这里不能把"3 pt 内边距"直接换算成"5 px 位移"。UIKit 的点、截图像素、实际约束和显示缩放是不同因素。需要以原始截图和布局信息验证具体位移,而不能仅凭内边距数值做线性推导。

位移为什么会放大差异?因为整片内容平移之后,许多文字边缘都会落到原来背景的位置;原来的文字位置又变成背景。被统计进去的,不只是新增标签那一点面积。

这次我选择保留 NO RENEWALS 的信息,把它改成不带底色的纯文字。复盘记录中的结果是:下方布局恢复,差异面积降低。至于这个视觉选择是否提高购买转化率,当前案例没有实验数据,不能从截图推导。

多语言检查,重点看连锁影响

简体中文亮色报告显示 3.48%,也能看到按钮和页脚附近的差异框。不同语言的数字不必相同,文案长度、字形和换行都会影响变化面积。

因此,审图时我会先找"本来不应该变化,却跟着一起动了"的区域,再判断文案调整本身是否符合预期。差异比例用于排序,最终接受与否要落到具体变化及其原因上。

七、接入步骤:先做一个状态,再扩成矩阵

下面的命令针对这套已经安装在本机的 mufeng-ios-visual-regression 引擎。这个路径是本机安装约定,并不意味着读者的电脑已经有对应文件。

第一步,在真正的 iOS 项目根目录初始化:

javascript 复制代码
~/.claude/skills/mufeng-ios-visual-regression/run.sh init

第二步,填写 VisualRegression/config.json。先用一个页面、一种语言和亮色主题跑通。以下是配置示例,项目名、bundle ID、模拟器名称都要替换:

json 复制代码
{  "xcodeproj": "MyApp.xcodeproj",  "scheme": "MyApp",  "configuration": "Debug",  "bundle_id": "com.example.myapp",  "source_dir": "MyApp",  "simulator_name": "iPhone 16",  "settle_seconds": 4.0,  "diff_threshold_pct": 0.5,  "languages": ["en"],  "styles": ["light"],  "screens": [    { "id": "paywall", "name": "Paywall" }  ]}

需要 workspace 的项目,用 "workspace": "MyApp.xcworkspace" 替换 xcodeproj 字段。自动语言模式只扫描配置目录直接包含的 *.lproj,排除 Base,并让英文优先;如果项目使用其他本地化资源组织方式,就显式填写语言列表。

第三步,把 Harness 模板加入应用 target,填写页面注册表与项目自己的主题、本地化和 fixture 逻辑,然后接到正常路由之前。

第四步,在 Xcode 中成功构建目标 Debug 模拟器产物,生成首批基线:

javascript 复制代码
~/.claude/skills/mufeng-ios-visual-regression/run.sh baseline

打开图片,确认页面、语言、主题和数据状态均正确,再把基线纳入版本控制。先跑通一个状态,能够及时发现"截到了错误页面"之类的基础问题。

第五步,逐步扩展语言、主题和页面,再进行日常检查:

javascript 复制代码
~/.claude/skills/mufeng-ios-visual-regression/run.sh check~/.claude/skills/mufeng-ios-visual-regression/run.sh open

项目内建议提交 config.json、Harness 和 baselines/;把 current/report/ 作为运行产物。CI 中保留报告供审核,本地可以忽略它们。初始化脚本只在特定条件下向已有 .gitignore 追加规则,因此仍应核对最终忽略配置。

基线是已审核的期望外观。更新基线意味着接受变化。 不要用 baseline 消除尚未理解的红框。

八、现有实现的三个漏检入口,决定它能否作为门禁

差异算法能找到像素变化,并不代表整个检查流程已经可靠。当前脚本有四种报告状态:

状态 现有实现中的含义 是否阻止通过
compared 两张同尺寸图片已比较 四舍五入到三位小数的比例大于阈值才阻止
new 只有当前图,没有基线 不阻止,需要人工建立基线
missing 有基线,没有当前图 阻止
size-mismatch 两张图尺寸不同 阻止,比例记为 100%

其中 size-mismatch 的 100% 是状态表示,不是逐像素统计结果。

漏检一:两边都缺失的状态不会出现在报告里

当前报告从基线与当前目录里实际存在的 PNG 推导比较集合,没有用配置中的完整矩阵校验覆盖率。

我构造了一个最小检查:配置声明 homepaywall,但两个目录都只放 home.png。报告只返回 home,不会生成一条缺失的 paywall

所以"报告没有回归"还可能意味着"漏掉的状态从未进入报告"。解决方向是由配置先生成预期矩阵,再核对本轮产物,而不是只扫描已有文件。

漏检二:旧截图可能掩盖本轮失败

截图器不会在每轮开始时清空 current/。启动失败时会跳过当前状态;截图命令的返回码也没有逐项检查,却会继续增加计数。

如果同一路径还留着上轮 PNG,比对器可能读到旧文件,missing 便无法保护这一轮。正确的门禁需要本轮独立输出目录,或者在截图前安全移除本轮目标旧文件,再检查命令退出状态、PNG 可读性和预期数量。

同样,Swift 示例对未注册的页面 ID 会返回 false,继续普通启动。simctl launch 可能正常成功,最后截到首页。指定了视觉测试参数却无法构造目标页面时,应有明确失败信号,不能靠"启动成功"代替"页面正确"。

漏检三:数据状态随运行顺序漂移

语言、主题和授权处理如果写入持久化偏好,下一次状态可能继承它们。结束进程不等于清理存储。

fixture 需要覆盖的是完整页面输入:登录状态、订阅选择、商品数据、排序、日期、权限和本地缓存,而不只是给控制器传一个空参数。

这几个问题不影响工具作为本地审图助手的价值,但影响它作为无人值守门禁时的可信度。进入 CI 前,应优先补齐构建身份、环境身份、状态完整性和截图失败处理,再谈更复杂的图像算法。

九、让差异报告真正参与开发决策

一次有效的视觉检查,我会按三个问题来审阅:

    1. 这次比较是否成立? 构建、设备、系统版本、页面与 fixture 是否正确,本轮截图是否完整。
    1. 变化是否符合意图? 档位换序是有意的,按钮和页脚一起下移是否也在设计范围内。
    1. 接受依据是什么? 通过截图和布局解释确认改动,再更新基线,把决定留在代码审查里。

静态视觉回归仍有覆盖边界:未注册弹窗、滚动后的内容、动态字体、横屏和其他机型,不会自动获得保护;购买行为、可访问性语义和交互路径也需要其他测试补充。

这套引擎最有用的地方,是把"我看了一眼好像没问题",变成可重复生成、可对照查看、可追溯接受过程的画面证据。ShotZen 这次标签改造说明,一个很小的布局决定,可能把整片内容一起推走;只有把差异放回具体页面,数字才开始有解释力。

参考资料

  • • Apple:Choosing a specific interface style for your iOS app2
  • • Point-Free:swift-snapshot-testing 官方仓库与使用说明1
  • • Pillow:ImageChops 通道运算3与 Image.convert 灰度转换4

你在多语言适配、暗色主题或快照测试里遇到过哪些"改动很小,布局却跟着移动"的问题?欢迎在评论区留下具体场景和处理办法,也给其他国内开发者多一个可参考的案例。如果这篇文章对你有用,点个「在看」,或分享给正在做 iOS 界面迭代的同事。

写 AI,写成长,偶尔写投资。 关注沐风,不定期更新,全是干货。

2026.09.05 20:37 沪 · 赵巷

引用链接

[1] 官方说明: *

github.com/pointfreeco... [2] Apple 的界面外观文档: *

developer.apple.com/documentati... [3] ImageChops 文档: *

pillow.readthedocs.io/en/stable/r... [4] Pillow Image.convert 文档: *

pillow.readthedocs.io/en/stable/r...

相关推荐
residual_fan2 小时前
深度渐进收缩学习(DPSL):面向强噪声与类内分散的机械复合故障诊断方法
人工智能·算法·数据挖掘·数据分析
等一朵映山红2 小时前
扩散模型在跨模态生成任务中的时序一致性优化
人工智能·机器学习
顿哥GPT2 小时前
ChatGPT Plus / Pro + Codex 深度实战指南(2026年9月5日):从模型能力对比到 Codex 自动化编程工作流全解析
人工智能·chatgpt·自动化
奈斯先生Vector2 小时前
AIGC 视频生成换个拍法:用 Kling Video 把一张人物图变成可剪辑的短故事
开发语言·人工智能·windows·python·aigc·音视频
IT_陈寒2 小时前
Java的HashMap竟然不是线程安全的,现在才知道!
前端·人工智能·后端
IT_陈寒2 小时前
React hooks闭包陷阱让我加了一宿班
前端·人工智能·后端
用户5274675614212 小时前
Agent 能跑不等于岗位还合格:给 AI 员工做一次可回滚的上岗发布
人工智能
beiju3 小时前
别让 Agent 只会写脚本:用 Producer 模式编排内容生产
人工智能