Flutter DeviceLab 实战指南:如何在真机上运行、编写并接入 CI 的 DeviceLab 测试任务
Flutter 的性能与正确性回归很大程度上依赖真机上的实测数据。DeviceLab 是 Flutter 仓库自带的真机测试实验室框架,负责在实体 Android/iOS/桌面设备上运行任务(task),采集性能指标并上报 CI 基础设施。读完本文,你将能够:在本地以与 CI 相同的方式复现和运行 DeviceLab 测试、对本地引擎构建执行 A/B 性能对比、按规范编写新的测试任务并将其接入持续集成(包括构建与测试分离的 build/test 模型),并能从源码层面理解任务隔离、超时清理与设备重启等机制的实现细节。
DeviceLab 是什么
dev/devicelab/README.md 对它的定义是:DeviceLab 是一个在真机上测试 Flutter 的物理实验室,该包包含了测试框架代码和所有测试。API 层面这些测试统一称为 task,文档中习惯称为 test。包名见 dev/devicelab/pubspec.yaml:
name: flutter_devicelab
description: Flutter continuous integration performance and correctness tests.
所有可执行任务集中在 dev/devicelab/bin/tasks/ 目录,每个 .dart 文件就是一个任务入口,例如:
complex_layout__start_up.dart、flutter_gallery__transition_perf.dart等启动/过渡性能任务hot_mode_dev_cycle__benchmark.dart等热重载开发循环基准basic_material_app_android__compile.dart、flutter_gallery_win_desktop__start_up.dart等编译与桌面任务flutter_gallery__back_button_memory.dart、fast_scroll_large_images__memory.dart等内存占用任务
任务命名遵循 {应用/场景}__{度量项}.dart 的惯例,任务名即文件去扩展名后的 basename,这也是后面 -t 参数的取值。
DeviceLab 如何运行测试
任务会声明它所需运行的设备类型(linux_android、mac_ios、mac_android、windows_android 等)。当实验室中某台设备空闲时,它就会领取需要执行的任务。每个任务的结局分三种:
- 成功:测试运行器上报成功,并将性能指标上传到 Flutter 基础设施(并非所有任务都记录性能指标);
- 失败后自动重跑:只要最近一次重跑成功,任务即按成功上报,但结果中会标记 flake(不稳定);
- 全部重跑均失败:上报失败,且不收集任何性能指标。
从源码结构看,任务与其宿主之间的协作完全走 Dart VM Service Protocol。在 dev/devicelab/lib/framework/framework.dart 中,task() 函数注册任务后调用 keepVmAliveUntilTaskRunRequested()——仅创建一个 RawReceivePort 就能让 VM 存活并维持 VM service server 运行,直到外部通过 VM 协议请求执行任务:
// framework.dart 中注册的 VM service 扩展(第 71 行附近)
registerExtension('ext.cocoonRunTask', (String method, Map<String, String> parameters) async {
final Duration? taskTimeout = parameters.containsKey('timeoutInMinutes')
? Duration(minutes: int.parse(parameters['timeoutInMinutes']!))
: null;
...
final TaskResult result = await run(
taskTimeout,
runProcessCleanup: runProcessCleanup,
runFlutterConfig: runFlutterConfig,
localEngine: localEngine,
localEngineHost: localEngineHost,
);
...
});
这套设计保证了两个关键性质:
- 任务互相隔离:每个任务运行在独立的 Dart VM 中,结果经由 VM service protocol 回报;
- CI 可以超时清理:
keepVmAliveUntilTaskRunRequested()内置 60 秒启动超时(framework.dart#L288-L295),若没有任何连接来请求运行任务则自行退出;任务执行时也支持按timeoutInMinutes参数施加执行超时,超时即以TaskResult.failure('Task timed out after ...')结束。
此外,框架在任务前后做进程巡检:执行前快照所有 dart 进程,执行后比对新出现的进程并终止,防止任务泄漏 Dart 进程(framework.dart#L213-L232);若设备支持,任务期间会把设备日志流写入 dump 目录下的 <deviceId>.log。
设备自动重启机制
本地跑 DeviceLab 测试会对你的环境产生影响,其中值得特别注意的是:同一台设备累计运行一定次数后会被自动重启。实现位于 checkForRebootRequired()(framework.dart#L246-L274):
- 计数持久化在用户主目录的
~/.reboot-count文件中,每次任务结束后 +1; - 达到常量
maximumRuns = 30(framework.dart#L27-L30,注释说明该数值是任意选取的)时删除计数文件并调用devices.workingDevice.reboot()(设备重启实现见 dev/devicelab/lib/framework/devices.dart); noRebootForbidList白名单中的设备(如某台 32 位 iPhone,需手动重启)会被跳过。
另外,本地运行时框架会自动启动/停止 Gradle。因此跑真机测试前请确认这台机器和设备可以承受这些副作用。
在本地运行测试
文档要求:部署到 CI 之前,务必先确保测试在本地通过。以下命令模拟 CI 的运行方式,也适合本地复现 CI 上的失败。
前置条件
- 运行 Android 测试必须设置
ANDROID_SDK_ROOT环境变量指向 Android SDK; - 如果你有本地构建的 Flutter 引擎,则
.../engine/src/third_party/android_tools/sdk下自带一份 Android SDK; - 可用
flutter doctor -v查看本机 Android SDK 的位置。
运行 test/ 下的单元测试
不依赖设备的纯 Dart 测试直接跑即可:
dart test test/{NAME_OF_TEST}
例如 DeviceLab 框架自身的测试位于 dev/devicelab/test/,包括 runner_test.dart、ab_test.dart、host_agent_test.dart、metrics_result_writer_test.dart 等,可以在不连接设备的情况下验证框架行为。
运行指定任务
使用 -t(--task)选项指定任务名。任务名是 dev/devicelab/bin/tasks/ 下文件的 basename(不含 .dart),例如 complex_layout__start_up:
# 在 .../flutter/dev/devicelab 目录下执行
../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t {NAME_OF_TEST}
一次运行多个任务,重复 -t 即可:
../../bin/cache/dart-sdk/bin/dart bin/run.dart -t test1 -t test2 -t test3
从源码看,bin/test_runner.dart 是一个 CommandRunner,注册了 test 与 upload-metrics 两个子命令(test_runner.dart#L13-L16,实现分别在 dev/devicelab/lib/command/test.dart 和 dev/devicelab/lib/command/upload_metrics.dart),参数解析失败时以退出码 64 结束。
关闭自动重试
默认情况下 DeviceLab 测试内置自动重试逻辑:任何失败的测试会额外重试 2 次。加 --exit 选项可跳过自动重试,第一个失败立即退出——排查偶发问题或快速验证时使用:
../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test --exit -t {NAME_OF_TEST}
在 dev/devicelab/bin/run.dart 中该选项对应 exitOnFirstTestFailure 变量。
针对本地引擎构建运行
把任务指向本地引擎构建产物,传入对应参数:
../../bin/cache/dart-sdk/bin/dart bin/run.dart --task=[some_task] \
--local-engine-src-path=[path_to_local]/engine/src \
--local-engine=[local_engine_architecture] \
--local-engine-host=[local_engine_host_architecture]
--local-engine-src-path:本地引擎的src/目录路径;--local-engine:本地引擎构建目标架构,例如android_debug_unopt_x86;--local-engine-host:宿主机侧引擎架构,例如host_debug_unopt。
任务启动时,框架会把 --local-engine / --local-engine-host 透传给 flutter config(framework.dart#L166-L183),使整个测试会话使用本地引擎。
本地复现 CI 上的坏构建
复现步骤:先 git checkout 到出问题的 Flutter 版本,记下失败任务的名称(例如 flutter_gallery__transition_perf),然后直接传给 run.dart:
../../bin/cache/dart-sdk/bin/dart bin/run.dart -t flutter_gallery__transition_perf
这正是 bin/tasks/flutter_gallery__transition_perf.dart 对应的任务。
引擎改动的 A/B 性能对比
run.dart 支持 A/B 模式:对比默认引擎与本地引擎构建的性能。测试对同一个基准任务在两种引擎上各跑指定次数,输出制表符分隔的结果表,并写入 JSON 文件存档,便于后续用表格工具或 summarize.dart 二次处理。
示例(对 Web CanvasKit 基准跑 10 轮 A/B):
../../bin/cache/dart-sdk/bin/dart bin/run.dart --ab=10 \
--local-engine=host_debug_unopt \
--local-engine-host=host_debug_unopt \
-t bin/tasks/web_benchmarks_canvaskit.dart
各参数含义(与 run.dart 中的变量注释一致):
--ab=10:A/B 测试各跑 10 轮;--local-engine=host_debug_unopt:B 组使用的本地引擎构建;A/B 模式必填(源码中--local-engine与--local-web-sdk至少提供一个,见 run.dart#L102-L108);--local-engine-host=host_debug_unopt:运行frontend_server用的宿主引擎构建;--ab-result-file=filename:指定 JSON 结果文件位置,默认ABresults#.json。单个#字符表示串行号占位符——若同名文件已存在则插入序号,否则覆盖;- A/B 模式一次只能跑一个任务,传多个任务会直接报错退出(run.dart#L95-L101)。
输出示例(表格来自 README,各列为:得分项、A 组均值及噪声、B 组均值及噪声、加速比):
Score Average A (noise) Average B (noise) Speed-up
bench_card_infinite_scroll.canvaskit.drawFrameDuration.average 2900.20 (8.44%) 2426.70 (8.94%) 1.20x
bench_card_infinite_scroll.canvaskit.totalUiFrame.average 4964.00 (6.29%) 4098.00 (8.03%) 1.21x
draw_rect.canvaskit.windowRenderDuration.average 1959.45 (16.56%) 2286.65 (0.61%) 0.86x
draw_rect.canvaskit.sceneBuildDuration.average 1969.45 (16.37%) 2294.90 (0.58%) 0.86x
draw_rect.canvaskit.drawFrameDuration.average 5335.20 (17.59%) 6437.60 (0.59%) 0.83x
draw_rect.canvaskit.totalUiFrame.average 6832.00 (13.16%) 7932.00 (0.34%) 0.86x
其中最有价值的列是 Speed-up(加速比),即本地引擎相对默认引擎快多少:小于 1.0 表示变慢,例如 0.5x 表示本地引擎慢一倍,2.0x 表示快一倍,越大越好。
用 summarize.dart 重新处理结果文件
../../bin/cache/dart-sdk/bin/dart bin/summarize.dart --[no-]tsv-table --[no-]raw-summary \
ABresults.json ABresults1.json ABresults2.json ...
dev/devicelab/bin/summarize.dart 从源码看实际支持三个开关,且默认均为开启(defaultsTo: true,见 summarize.dart#L68-L83):
--[no-]tsv-table:以制表符分隔表格打印 A/B 对比摘要,方便粘贴进电子表格(默认开);--[no-]raw-summary:打印 A/B 测试采集的全部逐轮原始数据,同样按制表符排版(默认开);--[no-]ascii-table:打印终端友好的 ASCII 表格摘要(README 未提及,源码中存在,默认开)。
命令后可跟任意多个结果文件名,将依次处理;不传文件名时默认读 ABresults.json(summarize.dart#L33)。
编写测试
一个任务就是 dev/devicelab/bin/tasks/ 下的一个简单 Dart 程序,通过 package:flutter_devicelab/framework/framework.dart 的 task() 定义并执行。最小骨架:
import 'dart:async';
import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main() async {
await task(() async {
... do something interesting ...
// Aggregate results into a JSONable Map structure.
Map<String, dynamic> testResults = ...;
// Report success.
return new TaskResult.success(testResults);
// Or you can also report a failure.
return new TaskResult.failure('Something went wrong!');
});
}
约定与约束:
- 每个程序只允许注册一个 task:
task()内部用_isTaskRegistered标记,重复注册直接抛StateError('A task is already registered')(framework.dart#L48-L52)。但一个 task 内部可以跑任意多个子测试; - 任务独立成败、独立上报:任务有自己的名字,成功/失败独立于其他任务,并独立上报仪表盘;
- 独立 VM 运行:任务在独立 Dart VM 中执行,经 VM service protocol 回报结果——这既保证任务间互不干扰,也让 CI 能超时并清理卡死任务;
- 结果统一封装为
TaskResult(实现见 dev/devicelab/lib/framework/task_result.dart),性能指标经由 dev/devicelab/lib/framework/metrics_center.dart / metrics_result_writer.dart 写出。任务的失败路径也有兜底:_performTask()用Chain.capture捕获任意异常并转成TaskResult.failure,而不是让 VM 崩溃(framework.dart#L305-L328)。
框架的可复用能力还包括:设备管理(devices.dart)、主机代理与 exec(host_agent.dart)、iOS 构建辅助(ios.dart)、浏览器控制(browser.dart)、APK 工具(apk_utils.dart)等。
将测试接入持续集成
先明确边界:纯主机(host only)的测试应加到 flutter_tools,而不是 DeviceLab。
把一个 DeviceLab 任务接入 CI 需要若干 PR。其中 _TASK_ 是你的测试名,必须与 bin/tasks/ 下文件名(去掉 .dart 扩展名)一致。核心一步是更新仓库根目录的 .ci.yaml:
- 找一个现成的
devicelab_dronerecipe 目标来镜像; - 如果你的测试需要在多个操作系统上运行,为每个操作系统各建一个独立目标(这正与"任务声明设备类型"的模型对应)。
将测试加入 presubmit
Flutter DeviceLab 在 presubmit 阶段的容量有限。若要调查将某测试加入 presubmit 的可行性,文档要求先向 team-infra 团队提交 issue 进行沟通,而非直接添加。
迁移到构建/测试分离模型(build and test model)
为更好利用有限的 DeviceLab 测试机资源、缩短提交验证时间,现在支持把构建产物(.apk/.app)与测试它们分离:产物在纯主机 bot 上构建(无设备的 VM 或物理 bot),测试则基于该产物在带设备的测试机上执行。
迁移步骤:
- 让任务类继承
BuildTestTask(源码位于 dev/devicelab/lib/tasks/build_test_task.dart,行为测试见 dev/devicelab/test/tasks/build_test_task_test.dart),并覆写四个函数:getBuildArgsgetTestArgsparseTaskResultgetApplicationBinaryPath
- 更新
bin/tasks/{TEST}.dart,使其指向新的任务类; - 本地验证任务:
- 仅构建:
dart bin/test_runner.dart test -t {NAME_OR_PATH_OF_TEST} --task-args build --task-args application-binary-path={PATH_TO_ARTIFACT} - 仅测试:
dart bin/test_runner.dart test -t {NAME_OR_PATH_OF_TEST} --task-args test --task-args application-binary-path={PATH_TO_ARTIFACT}
- 仅构建:
- 将任务加入 CI:镜像一个平台为
Linux_build_test或Mac_build_test的目标。与普通目标唯一的区别是artifact属性——如果省略,将默认使用task_name; - 在 CI 上验证通过后,移除
bringup: true使目标在PROD生效,并删除旧的非 build+test 模型目标条目。
文档以 gallery 任务为示例说明该流程已实际使用:Linux Android 侧先有"拆分" PR #103550、后有"切换" PR #110533;Mac iOS 侧为 PR #111164。
关键文件速查
| 用途 | 相对路径 |
|---|---|
| DeviceLab 说明文档 | dev/devicelab/README.md |
| 包定义(flutter_devicelab) | dev/devicelab/pubspec.yaml |
| 测试/指标上报命令行入口 | dev/devicelab/bin/test_runner.dart |
| 任务运行器(-t / --ab / --local-engine 等) | dev/devicelab/bin/run.dart |
| A/B 结果再处理工具 | dev/devicelab/bin/summarize.dart |
| 任务注册与执行框架 | dev/devicelab/lib/framework/framework.dart |
| 设备管理与重启 | dev/devicelab/lib/framework/devices.dart |
| 构建/测试分离基类 | dev/devicelab/lib/tasks/build_test_task.dart |
| 全部任务入口 | dev/devicelab/bin/tasks/ |
| 框架单元测试 | dev/devicelab/test/ |
| CI 目标定义 | .ci.yaml |
DeviceLab 的仪表盘展示的是上述任务在物理实验室中的实时运行状态(README 指向 Flutter 官方 CI 仪表盘),当你新增或修改任务后,应先在本地按本文命令验证通过,再走 .ci.yaml 的接入流程,这样能在 CI 端快速获得可信的通过与指标数据。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00