Flutter Record Use 实践:基于源码用法的资产树摇与图标子集裁剪集成测试全解析
本文围绕 Flutter 仓库中的 record_use_test_app 集成测试应用展开,完整讲解 "Record Use" 特性如何在构建期记录源码中对资产的实际调用、并通过 Dart 资产钩子(asset hooks)对数据资产和图标字体做树摇(tree-shaking)。读完本文,你将理解 @RecordUse() 注解、hook/link.dart 钩子与 IconTreeShaker 的协作机制,并掌握如何启用相关配置项、运行对应的集成测试来验证资产被正确裁剪。
一、Record Use 要解决什么问题
Record Use 是 Flutter 构建管线中的一个实验特性:它在构建过程中静态记录 Dart 源码对某些资产(数据文件、图标字体)的实际使用,然后在打包阶段把未被引用的部分裁剪掉,从而减小最终产物的体积。
dev/integration_tests/record_use_test_app/README.md 中对该测试应用的定位是:验证 Flutter 能够基于源码中的使用情况对资产做树摇。该特性依赖两个核心机制:
@RecordUse()注解:标注在使用资产的函数上,让编译器/分析器在构建期把对它的每次调用记录(call recordings)落盘;- 资产钩子(asset hooks,即
build.dart与link.dart):Dart 资产管线中的用户可编程扩展点,在构建后期处理对"使用记录"的读取与资产的改写。
本测试由一对应用与包构成:测试应用 record_use_test_app 和它依赖的测试包 record_use_test_package,二者位于同一目录层级并通过相对路径互相引用(见 pubspec.yaml 中的 record_use_test_package: path: ../record_use_test_package)。
二、测试应用:故意保留"被使用"与"未被使用"的分界
应用的入口 lib/main.dart 是理解整套机制的钥匙。它在 main() 中做了三件事:
final String hello = await translate('hello');
final String friend = await translate('friend');
// ignore: invalid_use_of_visible_for_testing_member
final int count = await loadedTranslationsCount();
// Print for integration test.
print('HELLO: $hello');
print('FRIEND: $friend');
print('COUNT: $count');
// Intentionally missing fontFamily to test that icon tree shaking detects
// constant IconData instances with null fontFamily.
const dummyIcon = IconData(0x1234);
print('ICON: ${dummyIcon.codePoint}');
runApp(MyApp(hello: hello, friend: friend, count: count));
(摘自 lib/main.dart)
这里有几个刻意设计的细节:
-
只调用
translate('hello')和translate('friend')两个 key。而测试包里的资产文件 data/translations.json 实际包含 4 个条目(hello、friend、treasure、yes):{ "friend": "Matey", "hello": "Ahoy!", "treasure": "Booty", "yes": "Aye!" }因此如果树摇生效,最终产物中的
translations.json应该只剩下 2 个条目——这正是集成测试的断言点。 -
const dummyIcon = IconData(0x1234)故意不传fontFamily,用于验证IconTreeShaker能检测并记录 codePoint 为 4660(即 0x1234)的常量IconData实例。由于缺少字体族信息,该图标的子集裁剪会失败并打印 trace 信息,但整体构建仍然成功——测试会断言这条日志出现(见后文)。 -
Web 双目标构建(Wasm + JS fallback)场景:应用还包含一段巧妙区分编译目标的常量:
// In dart2js, 1 and 1.0 are identical numbers, so `isWasm` evaluates to false. // In dart2wasm, integer and double representations are distinct, so `isWasm` // evaluates to true. const bool isWasm = !identical(1, 1.0);页面上的图标写作
const Icon(isWasm ? Icons.fastfood : Icons.favorite, ...)。这样在双目标 Web 构建中,Wasm 编译器会记录到Icons.fastfood,JS 编译器会记录到Icons.favorite,两条记录需要被合并保留,而不是相互覆盖。
三、测试包:@RecordUse() 注解与 link 钩子
3.1 被注解的 translate 函数
lib/record_use_test_package.dart 提供了被测试的核心函数:
@RecordUse()
Future<String> translate(String key) async {
final Map<String, String> translations = await _getTranslations();
return translations[key] ?? 'Key not found: $key';
}
(record_use_test_package.dart)
translate 通过 rootBundle.loadString('packages/record_use_test_package/data/translations.json') 加载 JSON 资产。函数上的 @RecordUse() 注解意味着:构建期对 translate(...) 的每次调用都会被记录进使用文件(recorded usages file)。此外包内还有一个 @visibleForTesting 的 loadedTranslationsCount(),用于在运行时返回当前 JSON 里的条目数,供应用打印 COUNT: 日志,间接验证资产内容确实被裁剪过。
3.2 包的依赖声明
record_use_test_package 的 pubspec.yaml 声明了实现该特性所需的三件套:
dependencies:
flutter:
sdk: flutter
hooks: ^2.2.0 # Dart 资产构建/链接钩子框架
data_assets: ^0.20.0 # 数据资产模型(DataAsset 等)
record_use: ^1.1.1 # RecordUse 注解与 Recordings 数据模型
meta: any
3.3 link 钩子:树摇的实际执行者
hook/link.dart 是整套机制的核心。其主流程如下:
void main(List<String> args) async {
await link(args, (input, output) async {
final EncodedAsset? translationAsset = _findTranslationAsset(input);
if (translationAsset == null) {
return;
}
output.dependencies.add(translationAsset.asDataAsset.file);
final Recordings? recordings = await input.recordings;
if (recordings == null) {
// Record use not enabled, return full translations file.
output.assets.data.add(translationAsset.asDataAsset);
return;
}
final Set<String> usedPhrases = _extractUsedPhrases(recordings);
final Map<String, dynamic> allTranslations = await _loadTranslations(translationAsset);
final Map<String, dynamic> filteredTranslations = _filterTranslations(
allTranslations,
usedPhrases,
);
await _writeOutputAsset(input, output, filteredTranslations);
});
}
其中四个关键步骤各有明确的实现:
- 定位目标资产:
_findTranslationAsset在input.assets.encodedAssets中查找id == 'package:${input.packageName}/data/translations.json'的数据资产(link.dart)。 - 读取使用记录:
recordings扩展从input.recordedUsagesFile读取 JSON 并反序列化为Recordings;如果该文件不存在(即 Record Use 未启用),钩子直接放行完整资产,保证功能关闭时的向后兼容(link.dart)。 - 提取被使用的 key:
_extractUsedPhrases用Method('translate', Library('package:record_use_test_package/record_use_test_package.dart'))精确定位被注解方法的调用记录,然后只接受形如CallWithArguments(positionalArguments: [StringConstant(:value), ...])的调用,把字符串常量收集进集合;一旦遇到无法静态求值的参数形式,直接抛出UnsupportedError,宁可构建失败也不输出不确定的裁剪结果(link.dart)。 - 写回裁剪后的资产:
_writeOutputAsset把过滤后的 JSON 写入filtered_translations.json,并以原资产名data/translations.json注册DataAsset指向新文件——对运行时而言资产路径不变,只是内容变小了(link.dart)。
由此完成 README "How it works" 一节描述的全链路:包内有多条翻译 → translate 被 @RecordUse() 标注 → 应用只调用 translate('hello') 和 translate('friend') → 构建期 link.dart 钩子拿到这两次调用的记录 → 过滤出只含 "hello" 与 "friend" 的 JSON,其余条目被树摇掉。
四、另一条路径:图标字体子集裁剪(Icon Tree Shaker)
除数据资产外,record_use_test_app 同时是图标树摇的验证载体。图标侧的实现位于 flutter_tools 的 icon_tree_shaker.dart,IconTreeShaker 包装了"记录的使用"与字体子集工具,从图标字体中剔除未使用的字形。从源码结构看,其启用条件与输入文件有明确约束:
bool get enabled =>
_fontManifest != null &&
_environment.defines[kIconTreeShakerFlag] == 'true' &&
_environment.defines[kBuildMode] != 'debug';
即:必须存在字体清单(通常来自 uses-material-design: true 引入的 Material 图标字体)、显式开启 --tree-shake-icons 标志、且非 debug 构建。它还会合并多个记录文件候选项:
final candidates = <String>[
'recorded_uses.json',
'recorded_uses_js.json',
'recorded_uses_wasm.json',
];
recorded_uses_js.json 与 recorded_uses_wasm.json 并存的设计,正是为双目标 Web 构建准备的——这正是测试应用里 isWasm ? Icons.fastfood : Icons.favorite 那行代码要验证的:合并后的记录必须同时保留 fastfood(57946)和 favorite(57947)两个 codePoint。
五、集成测试:如何验证"资产确实被裁掉了"
README 指出的测试入口是 record_use_flutter_build_test.dart:
bin/flutter test packages/flutter_tools/test/integration.shard/isolated/record_use_flutter_build_test.dart
注意该测试只会在 macOS / Linux / Windows 上运行,且要求 --enable-record-use 与 --enable-dart-data-assets 已在 Flutter 配置中启用——测试自身的 setUpAll 会显式执行这两条配置命令(见 record_use_utils.dart),并在临时目录中复制应用与依赖包、执行 flutter pub get 后逐一构建。
对应地,两个配置项在 features.dart 中的定义是:
enable-dart-data-assets(环境变量FLUTTER_DART_DATA_ASSETS):仅 master 通道可用、默认关闭;enable-record-use(环境变量FLUTTER_RECORD_USE):master / beta / stable 三通道均可用且默认启用。
也就是说,运行此测试的适用前提是使用 master(master/dev 系)版本的 Flutter SDK,或至少自行开启这两个 feature flag。
测试对三种目标分别执行 flutter build -v <target> --release:宿主平台、web、web --wasm,然后做三类断言:
- 图标树摇的边界情况(宿主平台):断言构建输出包含
Expected to find fontFamily for constant IconData with codepoint: 4660,即IconData(0x1234)被记录到了,但因缺少 fontFamily 而只告警不失败(测试文件)。 - Wasm 双目标合并:
web --wasm构建的输出必须同时包含57946和57947,证明 JS 与 Wasm 两个编译器的使用记录被取并集而非互相覆盖(测试文件)。 - 资产裁剪结果:扫描构建产物目录下的
AssetManifest.bin,用StandardMessageCodec解码后确认packages/record_use_test_package/data/translations.json存在,并读取其内容断言条目数等于expectedTranslationCount(值为 2,定义于 record_use_utils.dart)——即 4 条翻译只剩下被@RecordUse()记录命中的 "hello" 与 "friend" 两条(测试文件)。
六、小结:这对你的项目意味着什么
record_use_test_app 虽然只有一百行不到的 Dart 代码,但它把 Record Use 的完整契约演示得相当完整,可作为自建资产裁剪能力时的参考模板:
- 被裁剪侧:在包内放置数据资产(如本地化 JSON),把读取函数标注
@RecordUse(); - 裁剪侧:实现
hook/link.dart,从input.recordedUsagesFile读取Recordings,按静态可判定原则提取参数,写回裁剪后的资产并保持原资产名; - 降级侧:当记录文件缺失时放行完整资产,保证特性关闭时行为不变;
- 验证侧:用集成测试解码
AssetManifest.bin与产物中的资产文件,断言条目数量精确等于被使用的 key 数。
配合 IconTreeShaker 对 recorded_uses.json / recorded_uses_js.json / recorded_uses_wasm.json 的合并处理,Record Use 同时覆盖了"数据资产"与"图标字体"两条资源瘦身路径,这也是该测试应用把 main.dart 中资产调用与 IconData 常量放在一起的原因。
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 StartedRust0623
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