首页
/ Flutter DeviceLab 实战指南:如何在真机上运行、编写并接入 CI 的 DeviceLab 测试任务

Flutter DeviceLab 实战指南:如何在真机上运行、编写并接入 CI 的 DeviceLab 测试任务

2026-09-04 22:08:51作者:柯茵沙

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.dartflutter_gallery__transition_perf.dart 等启动/过渡性能任务
  • hot_mode_dev_cycle__benchmark.dart 等热重载开发循环基准
  • basic_material_app_android__compile.dartflutter_gallery_win_desktop__start_up.dart 等编译与桌面任务
  • flutter_gallery__back_button_memory.dartfast_scroll_large_images__memory.dart 等内存占用任务

任务命名遵循 {应用/场景}__{度量项}.dart 的惯例,任务名即文件去扩展名后的 basename,这也是后面 -t 参数的取值。

DeviceLab 如何运行测试

任务会声明它所需运行的设备类型(linux_androidmac_iosmac_androidwindows_android 等)。当实验室中某台设备空闲时,它就会领取需要执行的任务。每个任务的结局分三种:

  1. 成功:测试运行器上报成功,并将性能指标上传到 Flutter 基础设施(并非所有任务都记录性能指标);
  2. 失败后自动重跑:只要最近一次重跑成功,任务即按成功上报,但结果中会标记 flake(不稳定);
  3. 全部重跑均失败:上报失败,且收集任何性能指标。

从源码结构看,任务与其宿主之间的协作完全走 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 = 30framework.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.dartab_test.darthost_agent_test.dartmetrics_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,注册了 testupload-metrics 两个子命令(test_runner.dart#L13-L16,实现分别在 dev/devicelab/lib/command/test.dartdev/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 configframework.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.jsonsummarize.dart#L33)。

编写测试

一个任务就是 dev/devicelab/bin/tasks/ 下的一个简单 Dart 程序,通过 package:flutter_devicelab/framework/framework.darttask() 定义并执行。最小骨架:

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!');
  });
}

约定与约束:

  • 每个程序只允许注册一个 tasktask() 内部用 _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)、主机代理与 exechost_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_drone recipe 目标来镜像;
  • 如果你的测试需要在多个操作系统上运行,为每个操作系统各建一个独立目标(这正与"任务声明设备类型"的模型对应)。

将测试加入 presubmit

Flutter DeviceLab 在 presubmit 阶段的容量有限。若要调查将某测试加入 presubmit 的可行性,文档要求先向 team-infra 团队提交 issue 进行沟通,而非直接添加。

迁移到构建/测试分离模型(build and test model)

为更好利用有限的 DeviceLab 测试机资源、缩短提交验证时间,现在支持把构建产物(.apk/.app)与测试它们分离:产物在纯主机 bot 上构建(无设备的 VM 或物理 bot),测试则基于该产物在带设备的测试机上执行。

迁移步骤:

  1. 让任务类继承 BuildTestTask(源码位于 dev/devicelab/lib/tasks/build_test_task.dart,行为测试见 dev/devicelab/test/tasks/build_test_task_test.dart),并覆写四个函数:
    • getBuildArgs
    • getTestArgs
    • parseTaskResult
    • getApplicationBinaryPath
  2. 更新 bin/tasks/{TEST}.dart,使其指向新的任务类;
  3. 本地验证任务:
    • 仅构建: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}
  4. 将任务加入 CI:镜像一个平台为 Linux_build_testMac_build_test 的目标。与普通目标唯一的区别是 artifact 属性——如果省略,将默认使用 task_name
  5. 在 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 端快速获得可信的通过与指标数据。

登录后查看全文
热门项目推荐
相关项目推荐