Flutter 框架微基准测试(microbenchmarks):运行方法、参数解析与结果采集机制详解
本篇指南基于 Flutter 仓库中的 dev/benchmarks/microbenchmarks 微基准测试工程,讲解如何在真机上运行全部或部分微基准测试、如何使用 --dart-define 控制测试子集与随机种子、结果日志的 JSON 协议格式,以及测试名称为何不可随意修改。读完本文,你可以独立执行 Flutter 框架核心路径(ChangeNotifier、编解码器、手势、布局、构建管线等)的性能基准,并理解 CI 侧 devicelab 如何从设备日志中解析出机器可读的基准数据。
一、工程定位与基准目录结构
dev/benchmarks/microbenchmarks 是一个标准的 Flutter 应用工程(见其 pubspec.yaml 的 description: Small benchmarks for very specific parts of the Flutter framework.),它依赖 flutter、flutter_test(SDK 包)以及同仓库的路径包 stocks(供 stocks/* 系列基准复用真实业务 UI),并打包了一批 flutter_gallery_assets 图片/视频资源供 ui/image_bench.dart 等基准解码使用。
基准测试按被测的框架子系统分层存放在 lib/ 下:
foundation/:change_notifier_bench、observer_list_bench、standard_message_codec_bench、standard_method_codec_bench、platform_asset_bundle、decode_and_parse_asset_manifest、timeline_bench、clamp、all_elements_bench;geometry/:矩阵变换、圆角矩形包含判断等;gestures/:手势检测器、速度跟踪器;language/:compute隔离、同步循环基准(sync_star_bench);layout/:文本 intrinsic 尺寸测量;stocks/:基于 stocks 应用的动画、构建(build)、布局基准;ui/:图片解码基准。
入口文件 benchmark_collection.dart 用一个 Benchmark = (String name, Future<void> Function() value) 记录类型集中登记了全部 22 个基准,每个基准的注册名就是其相对 Dart 文件路径(如 foundation/change_notifier_bench.dart)。入口处的第一行即有防御性检查:
assert(false, "Don't run benchmarks in debug mode! Use 'flutter run --release'.");
由于 assert 只在 JIT(debug)模式下生效,profile/release(AOT)模式下该行被剔除,这正是运行命令要求加 --profile 的原因——基准必须跑在接近真实的 AOT 编译路径上,数据才有意义。
二、在真机上运行基准测试
按照 README 的原始步骤,先在设备连接好后开一个窗口观察日志,再在另一个窗口运行:
flutter logs
flutter run -d $DEVICE_ID --profile lib/benchmark_collection.dart
结果会打印到设备日志中(即 flutter logs 窗口),而不是终端。这一点可以从入口源码得到印证:跑完所有基准后,程序会打印结束标记 ╡ ••• Done ••• ╞,随后显式 await stdout.flush() 确保缓冲区刷出,再延迟 5 秒等待日志通道把输出“排空”后才 exit(0)(见 benchmark_collection.dart)。
三、运行子集:--dart-define=tests=...
微基准不支持给 main 传命令行参数,因此选择测试集完全靠编译期 Dart 变量。README 给出的子集运行命令为:
flutter run -d $DEVICE_ID --profile lib/benchmark_collection.dart --dart-define=tests=foundation/change_notifier_bench.dart,language/sync_star_bench.dart
对应到源码,入口用 args 包构造了一个 ArgParser,把 String.fromEnvironment('tests') 读取到的值转成 --tests 参数再解析(见 benchmark_collection.dart)。关键行为:
tests是多选(addMultiOption)选项,允许值为已登记基准名的列表,逗号分隔多个;- 默认值是全部 22 个基准名(
defaultsTo: allowed),不传tests即全量运行; - 传入非法基准名会导致
parser.parse报错,因为解析器带了allowed白名单; - 每次编译产物都会固化该选择,改
--dart-define后需要重新编译运行。
四、随机种子:--dart-define=seed=...
为避免测试顺序引入的系统性偏差(如 CPU 频率爬升、缓存冷热、GC 压力随时间变化),入口在选中基准后会用种子做洗牌:
flutter run -d $DEVICE_ID --profile lib/benchmark_collection.dart --dart-define=seed=12345
final List<Benchmark> tests = benchmarks
.where((Benchmark e) => selectedTests.contains(e.$1))
.toList();
tests.shuffle(Random(int.parse(results.option('seed')!)));
(见 benchmark_collection.dart)源码注释说明:seed 默认值为 12345,由基础设施(CI)在一段时间内固定传入同一个种子,以便历史数据可比;换种子即得到不同的运行顺序。每个基准执行前,绑定帧策略会被重置为默认值 fadePointers,再由具体基准自行设置(例如 foundation/all_elements_bench.dart 会切换为 benchmarkLive 并先 runApp(const SizedBox.shrink()) 触发一次 dispose 流程,见 benchmark_collection.dart)。
五、结果输出格式:BenchmarkResultPrinter 的双通道协议
所有基准共用 common.dart 中的 BenchmarkResultPrinter,它是本工程的机器/人类双通道结果协议核心:
BenchmarkResultPrinter printer = BenchmarkResultPrinter();
printer.addResult(
description: 'Average frame time',
value: averageFrameTime,
unit: 'ms',
name: 'average_frame_time',
);
printer.printToStdout();
addResult({description, value, unit, name}):登记单值结果。name是机器可读键(如clamp_clampDouble),description是日志里给人看的描述。addResultStatistics({description, values, unit, name}):登记一组采样值,自动计算均值并额外产出一条<name>_probability_5pct结果——按正态分布数值积分算出“均值落在真值 ±5% 区间内”的概率(见 common.dart),用来量化该次测量的可信度。printToStdout()输出带固定标记的块(见 common.dart):
================ RESULTS ================
:::JSON::: {"clamp_clampDouble": 0.12, "clamp_clampDouble_probability_5pct": 0.98, ...}
================ FORMATTED ==============
clamp - clampDouble: 0.1 us per iteration
clamp - clampDouble - probability margin of error 0.05: 98.0 percent
这三个标记字符串(jsonStart、jsonEnd、jsonPrefix)在源码注释中被明确要求与 dev/devicelab/lib/microbenchmarks.dart 保持同步——因为 CI 侧就是靠它们切分日志。
六、CI 侧如何收割结果:devicelab 的 readJsonResults
仓库中的 devicelab 任务(如 dev/devicelab/bin/tasks/microbenchmarks.dart 的 Android 任务、microbenchmarks_ios.dart 的 iOS 任务)通过 readJsonResults 与设备上的基准进程对接,其流程完整解释了 README 中“结果在设备日志里”这句话的下游去向:
- 逐行监听
flutter run进程的 stdout,stderr 转发到终端; - 见到
================ RESULTS ================进入采集态,把:::JSON:::前缀之后的 JSON 累积起来,直到================ FORMATTED ==============收尾; - 见到结束标记
╡ ••• Done ••• ╞后,先向 stdin 写入q(console runner 的退出指令,对应源码中引用的历史 issue #19208),等待 2 秒后再补发SIGINT,保证基准进程干净退出; - 把所有采集到的 JSON 块合并为
Map<String, double>返回,供任务框架写入指标中心。
换言之,微基准 App 与 devicelab 之间是一条纯 stdout 文本协议:标记行定界、JSON 承载数据、Done 行触发收割。如果你要新增自定义基准,只要遵循 printToStdout 的输出约定,devicelab 侧无需任何改动即可解析。
七、一个基准是怎么写的:以 clamp.dart 与 ChangeNotifier 为例
理解 foundation/clamp.dart 可以快速掌握本工程的基准范式:
const int _kBatchSize = 100000;
const int _kNumIterations = 1000;
...
final watch = Stopwatch();
{
final clampDoubleValues = <double>[];
for (var j = 0; j < _kNumIterations; ++j) {
double tally = 0;
watch.reset();
watch.start();
for (var i = 0; i < _kBatchSize; i += 1) {
tally += clampDouble(-1.0, 0.0, 1.0);
// ... 更多取值,覆盖边界与 NaN
}
watch.stop();
clampDoubleValues.add(watch.elapsedMicroseconds.toDouble() / _kBatchSize);
if (tally < 0.0) {
print("This shouldn't happen.");
}
}
printer.addResultStatistics(
description: 'clamp - clampDouble',
values: clampDoubleValues,
unit: 'us per iteration',
name: 'clamp_clampDouble',
);
}
其中体现了几条值得借鉴的度量手法:大批量循环(单批 10 万次)摊薄计时开销;多次独立采样(1000 轮)后交给 addResultStatistics 做统计;tally 累加结果用于“防 DCE(死代码消除)”——保证 AOT 优化器不能把被测调用整个优化掉,同时校验结果合法性;name(clamp_clampDouble、clamp_Double_clamp)保持稳定,形成可跨版本对比的历史序列。
更复杂的 foundation/change_notifier_bench.dart 则展示了预热(warm-up)模式:每个操作(addListener/notify/removeListener/通知中移除)都先以 _kNumWarmUp = 100 迭代跑一遍 addResult: false 的预热轮,再以 65536 次迭代正式采样,并按 1~5 个监听者数量分档输出 add1..add5、notify0..notify5 等结果,时间精度用 _kScale = 1000 换算成纳秒/次。该基准还特别在注释中提醒:为单独测量 add/notify 而故意不注销监听,依赖最终 GC 回收,这在真实应用中会造成内存泄漏——基准代码与生产代码的写法边界在此有明确交代。
八、为什么不能改基准名称
README 中独立一节强调“避免修改基准名称”:
每个微基准由名称标识,例如 "catmullrom_transform_iteration"。修改传给
BenchmarkResultPrinter.addResult的名称,实际上等同于删除旧基准并创建一个新基准,过程中会丢失旧基准关联的历史数据。
结合本文第五、六节可以看出其机制:devicelab 把每个 name 作为 JSON 键写入指标系统,历史曲线就是按这个键累积的;换一个键,采集器只会把它当作一条全新指标,旧序列从此“断档”。因此,重命名基准文件、调整被测逻辑可以,但 name(以及 description/unit 对应的语义)一旦发布就应当保持稳定;确需变更时,更稳妥的做法是保留旧键并新增新键,让新旧序列并行一段时间再下线旧序列。
九、运行前提与限制
- 必须使用真机 + profile(或 release)模式:
flutter run ... --profile lib/benchmark_collection.dart;debug 模式会直接命中assert(false)终止。 --dart-define是编译期常量:tests/seed通过String.fromEnvironment注入,修改后需重新编译;seed缺省12345,tests缺省全量。- 结果只出现在设备日志:需并行开着
flutter logs(README 原文即如此指导),程序退出前会stdout.flush()并等待 5 秒,确保日志通道完整送达。 - 适用版本前提:本工程 pubspec.yaml 声明 SDK 约束
^3.11.0-0且采用resolution: workspace,属于仓库 monorepo 工作区成员,直接检出本仓库当前版本即可运行;文中基准清单(22 项)与协议标记均以该仓库版本源码为准。
十、小结
dev/benchmarks/microbenchmarks 把 Flutter 框架中 22 个细粒度热点封装成一个可在真机 profile 模式执行的应用:--dart-define=tests 选择子集、--dart-define=seed 固定洗牌顺序,BenchmarkResultPrinter 以“RESULTS 标记 + JSON + FORMATTED 文本”的协议输出结果,devicelab 的 readJsonResults 再按同一组标记收割并入库。掌握这套“注册名即历史序列、stdout 标记即采集协议、warm-up 与防 DCE 即数据可信度”的约定,你就既能手动跑基准验证性能直觉,也能理解 Flutter 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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00