跳转到内容

Flutter Golden 测试:我们如何验证 ASO.dev 的自适应设计

了解 ASO.dev 如何使用 ff_golden 验证自适应 Flutter 界面,并通过 ff_golden_presenter 发布数千张基准 PNG 图像。

用于 Golden 测试的应用界面,分别显示在手机、平板和桌面设备上用于 Golden 测试的应用界面,分别显示在手机、平板和桌面设备上

ASO.dev 是一款面向 iOS、Android、macOS、Windows 和 Linux 的 Flutter 应用。共享代码库有助于在多个平台上发布产品,但并不会自动让界面具备自适应能力。在大显示器上看起来正常的页面,可能无法完整显示在小手机上。浅色主题可能很精致,而深色主题中的某条边框却消失了。仅仅翻译一个按钮,就可能让一行文字变成两行,破坏整个面板的布局。

每次发布前都手动检查这些组合并不现实。因此,Golden 测试成为我们保障界面质量的基础之一。

Golden 测试会在受控环境中运行 Flutter widget,渲染界面,并将生成的图像与仓库中的基准 PNG 比较。在 Flutter 中,这项工作由 matchesGoldenFile 完成:默认的本地比较器会解码 PNG,并执行逐像素比较

测试有三种结果:

  1. 图像与基准一致:没有视觉回归。
  2. 图像存在差异:测试失败,并保存用于排查的产物。
  3. 变化符合预期:开发者先审查差异,再更新基准图像。

最后一点非常重要。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 中保持一致:

Terminal window
flutter pub add --dev 'ff_golden:^1.0.0'
flutter pub add --dev 'ff_golden_presenter:^1.0.0'

ff_golden 模拟的不只是窗口尺寸。一个测试变体可以包含设备及其 devicePixelRatio、安全区域、平台、主题、语言区域、文字缩放、文字方向、亮度和高对比度设置。对于大型矩阵,可以使用 fullsmokepairwise 或带有组合数量硬上限的优先级采样策略。场景内部可以切换状态,并在多个命名时刻截图;有界的虚拟等待则有助于避免被无限动画卡住。

严格模式仍是默认方式:逐像素比较,并检测 RenderFlex overflow、命名冲突和过期基准。可以有意识地为局部设置容差,但不应借此掩盖原因不明的渲染差异。面向 CI,该包还可以保存计划变体、测试结果和失败产物的 JSON 描述。

如果只为一个大窗口保存一张基准图像,就会产生虚假的安全感。我们构建了共享测试框架,让同一个场景遍历设备、语言区域和主题矩阵。

主要矩阵包括:

  • 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 测试不仅验证异常是否被处理,还验证用户能否看到清晰的标题、说明和操作,以及较长的错误消息是否会破坏布局。

只有同样的代码始终生成同样的图像,Golden 测试才有价值。否则,团队就会逐渐不再信任测试失败。

因此,在测试环境中,我们会:

  • 加载与应用相同的字体;
  • 替换网络、分析、推送、认证和其他外部依赖;
  • 使用固定日期和预先准备的 provider 响应;
  • 等待明确的页面状态,而不是任意延时;
  • 在页面就绪后推进有限帧数,让动画完成;
  • 使用固定的 Flutter 版本和一致的环境运行检查。

页面何时就绪尤其重要。无界的 pumpAndSettle() 可能因后台动画而卡住,而固定延时又可能让测试变慢、不稳定。对于复杂页面,我们等待的是可观察的信号:数据加载完成、表格创建完毕,或所需操作已经出现。随后再给界面几帧时间稳定下来,然后截图。

修改界面时,我们遵循一套简单流程:

  1. 运行与改动页面对应的精确 Golden 测试场景。
  2. 如果测试失败,查看基准图像、新渲染结果和独立差异图。
  3. 判断原因:预期变化、真实回归,还是测试环境不稳定。
  4. 修复代码,或只更新确实应该变化的 PNG。
  5. 重新运行受影响的矩阵,再运行更广泛的测试集。

Flutter 测试会在 GitLab CI 中自动运行。出现差异时,流水线会将失败图像、masterImagetestImageisolatedDiff 收集为单独的产物。这样,即使测试不是在开发者的机器上运行,也能分析差异。

使用 ff_golden_presenter diff 进行本地审查

Section titled “使用 ff_golden_presenter diff 进行本地审查”

此前,我们使用 Git 客户端比较发生变化的 Golden 截图。在准备这篇文章的过程中,我意识到可以让这个流程更方便,把原本分散的操作整合到一个工具中。于是,ff_golden_presenter 1.1.0 推出了 diff 命令:

Terminal window
fvm dart run ff_golden_presenter diff

它会启动一个只能通过 127.0.0.1 访问的本地服务器,并提供用于审查图像变更的浏览器 UI。你可以在其中:

  • 浏览所有发生变化的 Golden 文件并在文件之间切换;
  • 并排比较基准版本和工作区版本,为变化的像素添加高亮,并调整高亮强度;
  • 同步缩放和拖动两侧图像,以便查看完全相同的区域;
  • 将文件加入 Git 暂存区或取消暂存,为提交准备变更;
  • 运行 Golden 测试、查看日志,并分别复制完整日志或错误信息;
  • 直接从界面打开相关的 Dart 测试文件。
ff_golden_presenter diff 本地界面,显示发生变化的 Golden 文件,以及基准图像与工作区图像之间高亮标出的差异

这样,审查过程始终保留在本地,并使用 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、窗口行为、系统拖放或完整用户流程。

本地 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。现在,整个本地流程只需一条项目内命令:

Terminal window
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。生成图库前,可以先检查是否已安装合适的工具:

Terminal window
fvm dart run ff_golden_presenter doctor --profile balanced

如果不需要压缩,可以选择 none 配置。图库仍会生成,但图像副本与原文件逐字节一致,也不需要外部优化工具:

Terminal window
fvm dart run ff_golden_presenter build \
--input test/screens \
--output-directory goldens \
--profile none \
--clean

测试失败后,failures 目录中会留下用于诊断的比较图像。可以使用独立的 clean-failures 命令清理。先预览将被删除的文件,不对磁盘做任何修改:

Terminal window
fvm dart run ff_golden_presenter clean-failures \
--input test/screens \
--dry-run

确认列表正确后,去掉 --dry-run 再执行:

Terminal window
fvm dart run ff_golden_presenter clean-failures --input test/screens

默认只删除名为 failures 的目录中的 PNG。这些目录之外的基准图像和其他诊断文件会被保留。

生成的 HTML 不需要运行时依赖,包含搜索、变体筛选、场景导航、浅色和深色主题、自适应卡片,以及支持键盘操作的图片灯箱。如果 ff_golden 保存了 JSON 清单,presenter 还会展示精确的截图、设备、主题、语言区域、文字缩放、状态、耗时和错误信息。随后,GitLab CI 将生成的目录打包为 nginx 镜像,再由独立部署任务发布到 golden.aso.dev

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 比较机制本身。

Golden 测试特别擅长发现细小但代价高昂的产品缺陷:

  • 一个或多个像素的 RenderFlex overflow
  • 文字被裁切或换行错误;
  • 移动端与桌面布局之间的自适应断点失效;
  • 按钮或表格列消失;
  • 复用共享组件后出现间距错误;
  • 浅色与深色主题不一致;
  • 横屏和竖屏问题;
  • 字体、图标或表格密度被意外修改。

人很容易忽略熟悉页面上一个像素的偏移,逐像素比较却不会。测试还会直接在问题发生的尺寸下展示它。

Golden 测试是在受控环境中运行的 widget 测试,并不是每一台真实设备的照片。它验证的是指定尺寸、主题和语言区域下 Flutter 的共享渲染路径,不能替代:

  • 业务逻辑单元测试;
  • 交互和无障碍 widget 测试;
  • 完整场景集成测试;
  • 原生 API 和 platform channel 检查;
  • 性能分析;
  • 在真实设备上手动验证关键发布场景。

我们将 Golden 测试看作质量策略中的一层。这也符合 Flutter 的建议:以大量单元测试和 widget 测试为基础,再用集成测试覆盖重要用户流程。

最大的缺点并不是一开始就出现的。PNG 是二进制格式。基准图像变化时,Git 无法像保存几行 Dart 代码变更那样高效地存储视觉差异,历史中会增加一个新的二进制对象。

截至 2026 年 8 月 30 日,我们的工作目录中有:

  • 2,031 个测试 PNG;
  • 当前版本的图像合计约 348 MB;
  • 整个应用的本地 .git 目录约 3.6 GB。

并非所有 .git 空间都由 Golden 测试占用:大型跨平台应用还有许多其他二进制资源。但数千张截图及其历史版本,确实是仓库增长的重要来源之一。

开发大约三年后,为了继续使用免费方案,我们不得不将活跃开发迁移到新的 GitLab 仓库。这种迁移可以重新腾出空间,但本身并不是长期存储架构。还需要迁移或关联历史,检查 CI/CD、权限、变量、集成,以及所有团队成员的本地 remote。我们当时这样做,是因为单独迁移比重构已经正常工作的 Golden 测试体系更简单。

每种方案都有取舍。

方案优点缺点
将 PNG 放在主仓库中检出最简单,单次提交,审查方便clone/fetch 数据量和仓库历史不断增长
在同一项目中使用 Git LFS主 Git 仓库只存放小型指针文件,二进制文件单独下载需要 LFS 客户端;仓库和 LFS 共用 GitLab 项目存储额度
使用独立仓库作为 submodule历史和额度独立;主仓库记录基准图像的确切提交两个仓库,额外的认证和 CI 配置,更新需要同步
对象存储或 CI 产物代码仓库几乎不增长需要自行设计版本管理、保留策略和视觉审查界面

Git LFS 确实能提高大型二进制文件的管理效率:Git 保存文本指针,而不是 PNG。不过,在 GitLab 中,Git 仓库与 LFS 的体积会合并计入项目限制。因此,LFS 可以改善 clone/fetch 的体验,但不会带来无限的免费存储。

如果目标就是将基准图像历史与源代码历史分离,同时保持版本之间的精确关联,那么适合。

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 维护和存储增长。到目前为止,这些成本仍低于用户在发布后发现视觉回归所带来的成本。