Flutter Golden 测试:我们如何验证 ASO.dev 的自适应设计
了解 ASO.dev 如何使用 ff_golden 验证自适应 Flutter 界面,并通过 ff_golden_presenter 发布数千张基准 PNG 图像。


ASO.dev 是一款面向 iOS、Android、macOS、Windows 和 Linux 的 Flutter 应用。共享代码库有助于在多个平台上发布产品,但并不会自动让界面具备自适应能力。在大显示器上看起来正常的页面,可能无法完整显示在小手机上。浅色主题可能很精致,而深色主题中的某条边框却消失了。仅仅翻译一个按钮,就可能让一行文字变成两行,破坏整个面板的布局。
每次发布前都手动检查这些组合并不现实。因此,Golden 测试成为我们保障界面质量的基础之一。
Golden 测试验证什么
Section titled “Golden 测试验证什么”Golden 测试会在受控环境中运行 Flutter widget,渲染界面,并将生成的图像与仓库中的基准 PNG 比较。在 Flutter 中,这项工作由 matchesGoldenFile 完成:默认的本地比较器会解码 PNG,并执行逐像素比较。
测试有三种结果:
- 图像与基准一致:没有视觉回归。
- 图像存在差异:测试失败,并保存用于排查的产物。
- 变化符合预期:开发者先审查差异,再更新基准图像。
最后一点非常重要。flutter test --update-goldens 并不会修复测试,它只是将当前结果声明为新的预期状态。如果不经检查就更新图像,Golden 测试很快就会变成一种成本高昂的形式主义。
从 2022 年的实践到公开发布的包
Section titled “从 2022 年的实践到公开发布的包”我从 2022 年开始在 Flutter 中使用 Golden 测试,而 ASO.dev 从应用的第一个版本起就引入了它们。这些年来,测试基础设施与产品一起发展:我们不断积累经验,学习如何稳定地验证不同屏幕尺寸、主题和数据状态,维护数千张基准图像,并方便地审查变化。
我们将这些实践经验整理成了两个公开发布的包,并于 2026 年 8 月 29 日发布了它们的首个稳定版本:
| 包 | 职责 |
|---|---|
ff_golden 1.0.0 | 运行场景、管理变体矩阵、捕获和比较图像 |
ff_golden_presenter 1.0.0 | 收集副本、优化图像、生成 HTML 报告并发布 |
这两个工具都以项目级 dev_dependencies 的形式引入,因此版本会与应用一起锁定,并在开发者机器和 CI 中保持一致:
flutter pub add --dev 'ff_golden:^1.0.0'flutter pub add --dev 'ff_golden_presenter:^1.0.0'ff_golden 模拟的不只是窗口尺寸。一个测试变体可以包含设备及其 devicePixelRatio、安全区域、平台、主题、语言区域、文字缩放、文字方向、亮度和高对比度设置。对于大型矩阵,可以使用 full、smoke、pairwise 或带有组合数量硬上限的优先级采样策略。场景内部可以切换状态,并在多个命名时刻截图;有界的虚拟等待则有助于避免被无限动画卡住。
严格模式仍是默认方式:逐像素比较,并检测 RenderFlex overflow、命名冲突和过期基准。可以有意识地为局部设置容差,但不应借此掩盖原因不明的渲染差异。面向 CI,该包还可以保存计划变体、测试结果和失败产物的 JSON 描述。
我们的屏幕矩阵
Section titled “我们的屏幕矩阵”如果只为一个大窗口保存一张基准图像,就会产生虚假的安全感。我们构建了共享测试框架,让同一个场景遍历设备、语言区域和主题矩阵。
主要矩阵包括:
- iPhone 5S,作为支持范围内最窄的屏幕之一;
- iPhone 11,作为较新的手机型号;
- 平板的竖屏和横屏;
- Full HD 桌面窗口;
- macOS Retina;
- 浅色和深色主题。
大多数场景以英语为主要语言。对于文字长度或界面方向特别重要的页面,我们会添加单独的本地化变体,其中也包括俄语。错误状态通常只需要手机和桌面的精简矩阵,而最重要的已加载页面会运行完整组合。
简化后的测试注册方式如下:
import 'package:ff_golden/ff_golden.dart';
testDeviceGoldens( 'loaded page', (tester, device, locale, theme) => golden.builder( tester, device, locale, theme, scenarioName: 'loaded', scenario: (_) async => golden.waitUntilReady(), ), devices: GoldenTestDevices.bundle, locales: GoldenTestDevices.locales, themes: GoldenTestDevices.themes,);ff_golden 会遍历这些组合,生成独立的 Flutter 测试和命名清晰的 PNG。ASO.dev 的测试封装负责定义支持的设备组合、应用根 widget,以及具体状态的准备方式。因此,添加一个新场景带来的不是一次检查,而是一整套视觉测试矩阵。
我们测试的是状态,不只是页面
Section titled “我们测试的是状态,不只是页面”一个漂亮的 loaded 页面只是界面的一部分。在真实产品中,用户还会遇到更多状态:
- 初始加载;
- 空数据;
- 服务提供方错误;
- 订阅或访问权限限制;
- 打开的对话框、下拉菜单或上下文菜单;
- 使用最少或最多列的表格;
- 已选筛选条件、较长的值和批量操作。
我们为每个重要状态准备固定数据和独立的 Golden 测试场景。这不仅能发现明显的布局变化,还能发现诸如“某个按钮只在错误状态下被表格遮住”之类的问题。
模拟数据让这些检查更方便,也更容易复现。测试中的 provider 无需等待真实 API,而是立即返回所需响应。每个页面的基础测试约定都包含加载状态和几个主要数据场景,例如空结果、常规记录集、边界值或访问受限。同一份固定测试数据(fixture)随后会在所需的屏幕尺寸、主题和语言区域下进行验证。
编写 mock 的需要本身也有助于提高生产代码质量。为了在测试中替换依赖,需要通过明确的接口约定,将 API、存储和其他外部系统的访问与 UI 分离。这样,页面状态更可预测,副作用更可控,大型组件也更不容易在不知不觉中与网络或全局状态耦合。最终,代码会更容易测试、复用和安全修改。
通用错误页面的测试矩阵更加广泛。我们尽量复现实际工作中遇到的几乎每一类错误,包括 App Store Connect 和 Google API 的响应、AI 服务提供方错误、网络和 SSL 错误、平台服务错误,以及 ASO.dev 后端错误。部分场景来自真实的 Sentry 事件,并保存为固定的 JSON fixture。Golden 测试不仅验证异常是否被处理,还验证用户能否看到清晰的标题、说明和操作,以及较长的错误消息是否会破坏布局。
确定性比截图数量更重要
Section titled “确定性比截图数量更重要”只有同样的代码始终生成同样的图像,Golden 测试才有价值。否则,团队就会逐渐不再信任测试失败。
因此,在测试环境中,我们会:
- 加载与应用相同的字体;
- 替换网络、分析、推送、认证和其他外部依赖;
- 使用固定日期和预先准备的 provider 响应;
- 等待明确的页面状态,而不是任意延时;
- 在页面就绪后推进有限帧数,让动画完成;
- 使用固定的 Flutter 版本和一致的环境运行检查。
页面何时就绪尤其重要。无界的 pumpAndSettle() 可能因后台动画而卡住,而固定延时又可能让测试变慢、不稳定。对于复杂页面,我们等待的是可观察的信号:数据加载完成、表格创建完毕,或所需操作已经出现。随后再给界面几帧时间稳定下来,然后截图。
日常开发流程
Section titled “日常开发流程”修改界面时,我们遵循一套简单流程:
- 运行与改动页面对应的精确 Golden 测试场景。
- 如果测试失败,查看基准图像、新渲染结果和独立差异图。
- 判断原因:预期变化、真实回归,还是测试环境不稳定。
- 修复代码,或只更新确实应该变化的 PNG。
- 重新运行受影响的矩阵,再运行更广泛的测试集。
Flutter 测试会在 GitLab CI 中自动运行。出现差异时,流水线会将失败图像、masterImage、testImage 和 isolatedDiff 收集为单独的产物。这样,即使测试不是在开发者的机器上运行,也能分析差异。
使用 ff_golden_presenter diff 进行本地审查
Section titled “使用 ff_golden_presenter diff 进行本地审查”此前,我们使用 Git 客户端比较发生变化的 Golden 截图。在准备这篇文章的过程中,我意识到可以让这个流程更方便,把原本分散的操作整合到一个工具中。于是,ff_golden_presenter 1.1.0 推出了 diff 命令:
fvm dart run ff_golden_presenter diff它会启动一个只能通过 127.0.0.1 访问的本地服务器,并提供用于审查图像变更的浏览器 UI。你可以在其中:
- 浏览所有发生变化的 Golden 文件并在文件之间切换;
- 并排比较基准版本和工作区版本,为变化的像素添加高亮,并调整高亮强度;
- 同步缩放和拖动两侧图像,以便查看完全相同的区域;
- 将文件加入 Git 暂存区或取消暂存,为提交准备变更;
- 运行 Golden 测试、查看日志,并分别复制完整日志或错误信息;
- 直接从界面打开相关的 Dart 测试文件。

这样,审查过程始终保留在本地,并使用 Git 仓库的真实状态;同时,完成主要操作时不再需要来回切换 Git 客户端、终端、编辑器和单独的图像查看器。
为什么 AI 辅助开发先从 Golden 测试开始
Section titled “为什么 AI 辅助开发先从 Golden 测试开始”AI 智能体收到界面任务后,往往会选择最直观的路径:构建应用、启动应用,再通过 Computer Use 操作界面,例如切换页面、调整窗口大小和截图。对于完整的端到端场景或原生行为检查,这是有用的工具。但对于局部视觉问题,这条路径通常涉及太多额外步骤。
为了在运行中的应用里复现一个缺陷,智能体可能需要:
- 等待目标平台构建并启动;
- 完成登录和页面导航;
- 准备数据或等待外部服务响应;
- 手动将窗口调整到所需尺寸;
- 打开精确的页面状态;
- 将新截图与预期结果进行视觉比较。
每一步都会增加耗时和新的变量。结果取决于账号状态、网络、窗口大小、数据和截图时机。精确重复这套检查更困难,而图像和较长的操作序列也会增加 AI 智能体的运行成本。
针对具体问题的 Golden 测试可以直接从所需状态开始:
| Computer Use | 针对具体问题的 Golden 测试 |
|---|---|
| 启动整个应用 | 只渲染所需页面或组件 |
| 依赖导航、账号状态和数据 | 使用固定 fixture 和依赖注入覆盖 |
| 需要手动复现窗口尺寸和截图时机 | 在测试中定义设备、devicePixelRatio、主题和语言区域 |
| 由智能体或人工判断差异 | 由 Flutter 生成可复现的逐像素差异图 |
| 不重复相同操作就难以复现 | 同一个测试可以在本地和 CI 中运行 |
因此,在 ASO.dev 中,我们会先让 AI 智能体检查实际的 widget 和状态处理代码,再使用一个命名 Golden 场景复现具体问题。例如,某张表格在窄屏上溢出一个像素时,无需启动整个应用并手动准备状态。智能体只需修改最小的相关布局代码,在指定设备上运行一个场景,然后检查 PNG。之后再运行完整矩阵,确认修复没有破坏其他尺寸和主题。
这种循环通常更快、成本更低,最重要的是可以重复。它会在仓库中留下可审查的产物,并能在未来任何修改后再次运行。当问题无法通过 widget 测试表达时,Computer Use 才成为下一步工具,例如原生对话框、platform channel、窗口行为、系统拖放或完整用户流程。
如何展示所有 Golden 页面
Section titled “如何展示所有 Golden 页面”本地 diff 覆盖了日常的基准图像变更审查:开发者可以比较修改前后的截图、理解变化的含义,并判断哪些界面需要修复,哪些基准应该有意识地更新。
对团队其他成员而言,整体查看应用已经具备哪些内容可能更方便:实现了哪些页面和状态,它们在不同设备和主题下是什么样子。我们使用 ff_golden_presenter 生成图库,提供这样的总览。
对于 ASO.dev,这更像是面向公众的界面展示,而不是开发者日常工作的主要工具。你可以在 golden.aso.dev 查看。
演示图库的发布流程如下:
test/screens/**/*.png → ff_golden_presenter build → goldens/index.html + 优化后的副本 → nginx → golden.aso.dev
以前,项目中的 shell 脚本需要自行查找 PNG、复制目录、检查 pngquant、压缩文件,再调用全局安装的 presenter。现在,整个本地流程只需一条项目内命令:
fvm dart run ff_golden_presenter build \ --input test/screens \ --output-directory goldens \ --report-file index.html \ --profile balanced \ --clean \ --title "ASO.dev Golden 测试"在上面的示例中,build 将基准图像从 test/screens 复制到独立的 goldens 目录,并且只优化副本。测试比较所使用的原始 PNG 不会被修改。
balanced 配置使用 pngquant,在其不可用时可以回退到 ImageMagick。生成图库前,可以先检查是否已安装合适的工具:
fvm dart run ff_golden_presenter doctor --profile balanced如果不需要压缩,可以选择 none 配置。图库仍会生成,但图像副本与原文件逐字节一致,也不需要外部优化工具:
fvm dart run ff_golden_presenter build \ --input test/screens \ --output-directory goldens \ --profile none \ --clean测试失败后,failures 目录中会留下用于诊断的比较图像。可以使用独立的 clean-failures 命令清理。先预览将被删除的文件,不对磁盘做任何修改:
fvm dart run ff_golden_presenter clean-failures \ --input test/screens \ --dry-run确认列表正确后,去掉 --dry-run 再执行:
fvm dart run ff_golden_presenter clean-failures --input test/screens默认只删除名为 failures 的目录中的 PNG。这些目录之外的基准图像和其他诊断文件会被保留。
生成的 HTML 不需要运行时依赖,包含搜索、变体筛选、场景导航、浅色和深色主题、自适应卡片,以及支持键盘操作的图片灯箱。如果 ff_golden 保存了 JSON 清单,presenter 还会展示精确的截图、设备、主题、语言区域、文字缩放、状态、耗时和错误信息。随后,GitLab CI 将生成的目录打包为 nginx 镜像,再由独立部署任务发布到 golden.aso.dev。
发布图库可能很快消耗流量
Section titled “发布图库可能很快消耗流量”Golden 图库由静态文件组成,但这并不意味着对外提供这些文件是免费的。即使经过优化,大量页面截图也可能占用数百兆字节。
以我们的规模为例:截至 2026 年 8 月 31 日,用于生成 ASO.dev 图库的 test/screens 目录包含 1,996 个 PNG,合计约 357 MB。这是优化前的原始基准图像大小,而不是一次页面加载的数据量。发布副本的大小取决于所选优化配置,流量则取决于访客实际加载了多少图像。
例如,Firebase Hosting 为每个项目免费提供最多 10 GB 存储空间和每月 10 GB 数据传输量。传输量既包括缓存未命中的响应,也包括由 CDN 缓存提供的响应;保留的 release 文件也会计入 Hosting 存储用量。因此,大型报告被频繁浏览,或经常重新发布,都可能让项目迅速接近免费额度。在 Spark 方案中,超出传输额度后,网站会在短暂宽限期结束后停用,直到下个月开始。
Firebase 适合小型演示或很少被打开的内部报告,但需要关注存储、流量和保留的 release 数量。对于大型目录,我们选择在自己的基础设施上提供静态文件:CI 生成 goldens/,将目录打包进包含 nginx 的 Docker 镜像,再部署到 golden.aso.dev。Docker 并不会消除网络流量,但这些流量不再消耗 Firebase 额度,而且存储、缓存和访问控制都由我们自己管理。
这里仍需明确分工:ff_golden 运行测试场景并执行基准图像比较;ff_golden_presenter 提供本地审查 UI,并生成用于浏览和发布界面的图库。diff 命令可以启动项目测试,但不会取代 Golden 比较机制本身。
我们能发现哪些问题
Section titled “我们能发现哪些问题”Golden 测试特别擅长发现细小但代价高昂的产品缺陷:
- 一个或多个像素的
RenderFlex overflow; - 文字被裁切或换行错误;
- 移动端与桌面布局之间的自适应断点失效;
- 按钮或表格列消失;
- 复用共享组件后出现间距错误;
- 浅色与深色主题不一致;
- 横屏和竖屏问题;
- 字体、图标或表格密度被意外修改。
人很容易忽略熟悉页面上一个像素的偏移,逐像素比较却不会。测试还会直接在问题发生的尺寸下展示它。
Golden 测试不能保证什么
Section titled “Golden 测试不能保证什么”Golden 测试是在受控环境中运行的 widget 测试,并不是每一台真实设备的照片。它验证的是指定尺寸、主题和语言区域下 Flutter 的共享渲染路径,不能替代:
- 业务逻辑单元测试;
- 交互和无障碍 widget 测试;
- 完整场景集成测试;
- 原生 API 和 platform channel 检查;
- 性能分析;
- 在真实设备上手动验证关键发布场景。
我们将 Golden 测试看作质量策略中的一层。这也符合 Flutter 的建议:以大量单元测试和 widget 测试为基础,再用集成测试覆盖重要用户流程。
代价:仓库不断增长
Section titled “代价:仓库不断增长”最大的缺点并不是一开始就出现的。PNG 是二进制格式。基准图像变化时,Git 无法像保存几行 Dart 代码变更那样高效地存储视觉差异,历史中会增加一个新的二进制对象。
截至 2026 年 8 月 30 日,我们的工作目录中有:
- 2,031 个测试 PNG;
- 当前版本的图像合计约 348 MB;
- 整个应用的本地
.git目录约 3.6 GB。
并非所有 .git 空间都由 Golden 测试占用:大型跨平台应用还有许多其他二进制资源。但数千张截图及其历史版本,确实是仓库增长的重要来源之一。
开发大约三年后,为了继续使用免费方案,我们不得不将活跃开发迁移到新的 GitLab 仓库。这种迁移可以重新腾出空间,但本身并不是长期存储架构。还需要迁移或关联历史,检查 CI/CD、权限、变量、集成,以及所有团队成员的本地 remote。我们当时这样做,是因为单独迁移比重构已经正常工作的 Golden 测试体系更简单。
基准图像应该存放在哪里
Section titled “基准图像应该存放在哪里”每种方案都有取舍。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 将 PNG 放在主仓库中 | 检出最简单,单次提交,审查方便 | clone/fetch 数据量和仓库历史不断增长 |
| 在同一项目中使用 Git LFS | 主 Git 仓库只存放小型指针文件,二进制文件单独下载 | 需要 LFS 客户端;仓库和 LFS 共用 GitLab 项目存储额度 |
| 使用独立仓库作为 submodule | 历史和额度独立;主仓库记录基准图像的确切提交 | 两个仓库,额外的认证和 CI 配置,更新需要同步 |
| 对象存储或 CI 产物 | 代码仓库几乎不增长 | 需要自行设计版本管理、保留策略和视觉审查界面 |
Git LFS 确实能提高大型二进制文件的管理效率:Git 保存文本指针,而不是 PNG。不过,在 GitLab 中,Git 仓库与 LFS 的体积会合并计入项目限制。因此,LFS 可以改善 clone/fetch 的体验,但不会带来无限的免费存储。
Git submodule 适合这个场景吗
Section titled “Git submodule 适合这个场景吗”如果目标就是将基准图像历史与源代码历史分离,同时保持版本之间的精确关联,那么适合。
Submodule 是工作目录中的独立仓库。主项目记录截图仓库的路径、URL 和应该使用的提交。这样,应用的每个提交都能指向一组确切的基准图像,而旧 PNG 不再让主仓库的历史膨胀。
对于 GitLab Free,这还意味着拥有一个独立的存储项目。截至 2026 年 8 月,GitLab.com 为免费命名空间下的每个项目提供 10 GiB。如果本地和 CI 只需要近期图像历史,还可以限制 submodule 的克隆深度。
但 submodule 也有流程成本:
- 普通 clone 不一定会自动获取它;
- CI 必须能够访问第二个私有仓库;
- 需要先将新 PNG 提交到 submodule,再更新主项目中的引用;
- 代码合并请求和视觉差异分散在两个项目中;
- 开发者需要确认本地 submodule 处于预期提交。
所以,它并不是在所有方面都最简单的方案。把 PNG 放在测试旁边,日常操作确实更方便。但在那些真正分离二进制历史,同时保留 Git 版本管理的方案中,独立仓库加 submodule 是最直接、最容易理解的选择之一。
如果从零设计存储方式,我们会从一开始就认真考虑这种结构。但要在不中断当前开发的情况下迁移数千个现有文件及其历史,是另一项工程任务。到目前为止,周期性迁移对我们而言仍是更务实的选择。
Golden 测试不会自动让界面变好。它让视觉决策变得可复现:相同的页面、状态、主题、语言区域和屏幕几何参数,应该产生相同的结果。
对于跨平台的 ASO.dev,这让我们可以在不手动检查数百种组合的情况下交付自适应设计。如今,其他 Flutter 团队也可以通过 ff_golden 使用这套测试流程,并通过 ff_golden_presenter 浏览和发布结果。我们付出的代价包括测试运行时间、fixture 维护和存储增长。到目前为止,这些成本仍低于用户在发布后发现视觉回归所带来的成本。
