首页
/ Flutter Record Use 实践:基于源码用法的资产树摇与图标子集裁剪集成测试全解析

Flutter Record Use 实践:基于源码用法的资产树摇与图标子集裁剪集成测试全解析

2026-09-06 18:42:56作者:姚月梅Lane

本文围绕 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.dartlink.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

这里有几个刻意设计的细节:

  1. 只调用 translate('hello')translate('friend') 两个 key。而测试包里的资产文件 data/translations.json 实际包含 4 个条目(hellofriendtreasureyes):

    {
      "friend": "Matey",
      "hello": "Ahoy!",
      "treasure": "Booty",
      "yes": "Aye!"
    }
    

    因此如果树摇生效,最终产物中的 translations.json 应该只剩下 2 个条目——这正是集成测试的断言点。

  2. const dummyIcon = IconData(0x1234) 故意不传 fontFamily,用于验证 IconTreeShaker 能检测并记录 codePoint 为 4660(即 0x1234)的常量 IconData 实例。由于缺少字体族信息,该图标的子集裁剪会失败并打印 trace 信息,但整体构建仍然成功——测试会断言这条日志出现(见后文)。

  3. 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);
    

    lib/main.dart

    页面上的图标写作 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)。此外包内还有一个 @visibleForTestingloadedTranslationsCount(),用于在运行时返回当前 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);
  });
}

link.dart

其中四个关键步骤各有明确的实现:

  • 定位目标资产_findTranslationAssetinput.assets.encodedAssets 中查找 id == 'package:${input.packageName}/data/translations.json' 的数据资产(link.dart)。
  • 读取使用记录recordings 扩展从 input.recordedUsagesFile 读取 JSON 并反序列化为 Recordings;如果该文件不存在(即 Record Use 未启用),钩子直接放行完整资产,保证功能关闭时的向后兼容(link.dart)。
  • 提取被使用的 key_extractUsedPhrasesMethod('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.dartIconTreeShaker 包装了"记录的使用"与字体子集工具,从图标字体中剔除未使用的字形。从源码结构看,其启用条件与输入文件有明确约束:

bool get enabled =>
    _fontManifest != null &&
    _environment.defines[kIconTreeShakerFlag] == 'true' &&
    _environment.defines[kBuildMode] != 'debug';

icon_tree_shaker.dart

即:必须存在字体清单(通常来自 uses-material-design: true 引入的 Material 图标字体)、显式开启 --tree-shake-icons 标志、且非 debug 构建。它还会合并多个记录文件候选项:

final candidates = <String>[
  'recorded_uses.json',
  'recorded_uses_js.json',
  'recorded_uses_wasm.json',
];

icon_tree_shaker.dart

recorded_uses_js.jsonrecorded_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:宿主平台、webweb --wasm,然后做三类断言:

  1. 图标树摇的边界情况(宿主平台):断言构建输出包含 Expected to find fontFamily for constant IconData with codepoint: 4660,即 IconData(0x1234) 被记录到了,但因缺少 fontFamily 而只告警不失败(测试文件)。
  2. Wasm 双目标合并web --wasm 构建的输出必须同时包含 5794657947,证明 JS 与 Wasm 两个编译器的使用记录被取并集而非互相覆盖(测试文件)。
  3. 资产裁剪结果:扫描构建产物目录下的 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 数。

配合 IconTreeShakerrecorded_uses.json / recorded_uses_js.json / recorded_uses_wasm.json 的合并处理,Record Use 同时覆盖了"数据资产"与"图标字体"两条资源瘦身路径,这也是该测试应用把 main.dart 中资产调用与 IconData 常量放在一起的原因。

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