
很多做 Flutter 开发的朋友可能都经历过这个场景项目里引入 json_serializable、freezed 或者 riverpod_generator 之后每次改完模型类、状态类都要跑一遍 build_runner然后盯着终端里一长串的 Succeeded 发呆——小项目还好一旦工程变大、代码生成器变多几分钟甚至十几分钟的等待就变成家常便饭。更要命的是你只是改了一个字段build_runner 却要把所有生成器重新跑一遍基线。cached_build_runner 就是冲着这个痛点来的它把上一次构建的构建图、中间产物和最终产物全部缓存下来下次构建时只跑真正受影响的生成器构建时间能缩短百分之八九十。这个库本身是 Dart 生态的工具按理说跟运行平台关系不大。但最近我在做 Flutter 应用的鸿蒙化适配时发现事情没那么简单鸿蒙的构建链路hvigor ohos SDK和 Flutter 的构建链路要打通缓存目录、路径规则、CI 流水线全都要重新梳理稍有不慎就出现缓存命中但产物不对的诡异问题。这篇博文就结合我这段时间的实际踩坑经历把 cached_build_runner 在鸿蒙化工程里的适配思路、配置细节和常见坑位一次讲清楚。1. 先说清楚 cached_build_runner 到底帮你省掉了什么1.1 build_runner 的无脑重跑为什么让人抓狂要理解 cached_build_runner 的价值得先明白 build_runner 的工作方式。build_runner 是 Dart 官方的代码生成框架它围绕构建图build graph来组织所有生成任务每个生成器是一系列输入 - 输出的节点输入是源文件输出是生成的 .dart、.g.dart、.freezed.dart 这类文件。问题在于默认情况下 build_runner 为了保证构建结果绝对正确会在每次运行时重新建立完整的构建图然后对所有生成器做一次完整的是否过期检查。这种全量扫描在小项目里没什么感知但当你的项目里有十几个生成器、几百个源文件时每次执行dart run build_runner build都会消耗大量 CPU 时间来做哈希计算、依赖分析和任务调度。我见过一个中等规模的 Flutter 项目光跑一次完整的 build_runner 就要三分钟其中真正干活的生成时间不到半分钟剩下的时间全耗在图重建和未变更文件的重复校验上。更难受的是build_runner 的增量模式build_runner watch虽然能监听文件变化但它内部的图状态是存在内存里的一旦你中断 watch 进程或者换了一台机器、切换了分支下次 build 又要重新全量跑一遍。这种每次都要付出全部成本的痛感在频繁切换 Git 分支、多人协作时尤其明显。1.2 cached_build_runner 的缓存命中机制cached_build_runner 的思路很朴素既然图是固定的那我把图的状态和构建产物持久化到磁盘上下次构建时直接加载上次的图再对当前的文件状态做一次增量比对只重建发生变化的节点其余节点直接复用缓存产物。它的核心是两层缓存构建图缓存序列化后的构建图结构包含每个节点输入输出的哈希值存放在.dart_tool/build_cache这类目录下。产物缓存每个生成器的输出文件以内容的 SHA-256 哈希作为标识。如果当前输入文件的哈希与图上记录的哈希一致就直接用缓存产物不再调度生成器。实际执行时它会把当前所有源文件的哈希整体算一遍和构建图里记录的哈希做 diff。这个过程本质上还是全量扫描文件但文件哈希计算的速度远快于跑生成器所以整体耗时从分钟级降到秒级。我在项目里实测一个包含 freezed json_serializable injectable 的工程首次构建耗时 218 秒第二次未变更构建耗时 6.5 秒第三次只改了一个模型类字段的构建耗时 12.8 秒。这个量级的提升对日常开发和 CI 流水线的体验改善是非常明显的。2. 鸿蒙化适配这件事难点到底在哪2.1 鸿蒙的构建链路与 Flutter 构建链路的错位鸿蒙应用开发目前用的是 hvigor 作为核心构建工具配合 DevEco Studio 提供的 ohos SDK。如果你的 Flutter 应用要做鸿蒙化本质上不是简单地把 Android 的 gradle 换成 hvigor 就完事而是要面对两套构建系统同时工作Flutter 层Dart 代码、pub 依赖、代码生成任务走的是 dart build 逻辑。鸿蒙层Ability、Stage 模型、资源文件、Native 库走的是 hvigor 的 task 系统。cached_build_runner 管的是前者但鸿蒙化工程里这两层构建是嵌套的hvigor 会作为外层构建系统调用 Flutter 的构建工具链去处理 Dart 侧的内容。所以你在配置缓存时不能只考虑命令怎么跑还要考虑缓存目录的位置、构建环境变量、产物输出路径这些在两套系统之间的传递方式。我踩到的第一个坑就是缓存目录冲突。默认情况下cached_build_runner 会把缓存写在.dart_tool目录里而 Flutter 工程根目录的.dart_tool在鸿蒙化构建时可能被 hvigor 当成资源目录的一部分扫描进去导致构建产物互相污染。后来我把缓存的base_dir显式指定到一个独立的目录比如项目根目录下的.cache/build_runner并配合 hvigor 的排除规则才算把这个问题解决干净。2.2 鸿蒙化工程的代码生成需求不止模型类在纯 Flutter 工程里build_runner 主要用于 json 序列化、状态管理、依赖注入这些常规代码生成。但鸿蒙化工程里代码生成的需求多了一层鸿蒙的 Stage 模型要求每个 Ability、ExtensionAbility 都要在 module.json5 里注册路由表、资源映射、IPC 接口定义很多信息是结构化的描述文件。如果你的工程规模稍大我强烈建议把这些描述文件也交给代码生成器统一管理——有人会用自研的生成器根据 module.json5 生成 Dart 侧的入口代码有人会在生成器里直接解析鸿蒙的资源配置。这就带来一个适配问题cached_build_runner 默认对输入文件类型没有限制但它的缓存键设计更偏向于 Dart 源文件。当输入变成 .json5、.xml、.module.json 这类非 Dart 文件时你需要在 build.yaml 里为生成器声明明确的generate_for规则否则容易出现输入文件变了、但生成器没有被重新调度的缓存误判。我在适配时自己写了一层输入快照的扫描逻辑把参与代码生成的 JSON5 文件、路由配置统一放在一个目录下在运行 cached_build_runner 之前先用脚本对这批文件做一次哈希哈希有变化才允许执行构建否则直接跳过。说白了就是给构建加了一道保险避免缓存判定与实际输入脱节。3. 鸿蒙化环境下的工具选型与前置准备3.1 先确定你的鸿蒙 Flutter 运行方案鸿蒙化适配的第一步其实是确认你用的是哪套 Flutter 鸿蒙运行方案。目前社区里主流的路线有两类OpenHarmony 官方孵化项目比如 flutter_flutter 在 OpenHarmony 平台的 fork提供了 Flutter 引擎在 OpenHarmony 上的渲染与接口映射能力。厂商定制的鸿蒙 Flutter SDK通常会在官方 Flutter 版本基础上增加鸿蒙组件映射、平台通道适配。不同的方案对 build_runner 的兼容性没有本质影响因为代码生成器跑在 Dart 虚拟机上跟平台 SDK 关系不大。但影响最大的是产物类型和构建入口命令。比如有些方案要求你用 hvigor 统一构建有些方案则允许你直接用 flutter build 命令产出鸿蒙包。这会决定你要不要写转发脚本也决定缓存目录的隔离策略。我在实践中的建议是尽量把Dart 代码生成和鸿蒙侧打包分开。先用 cached_build_runner 完成全部 Dart 代码生成再把生成结果作为静态输入交给 hvigor 构建。这样你既能在开发时享受增量构建的加速又能避免两套构建系统同时触达同一批文件导致的锁冲突。3.2 依赖管理与 build.yaml 配置的规范cached_build_runner 本身也是一个依赖注入的方库你要在dev_dependencies里加dev_dependencies: build_runner: ^2.4.0 cached_build_runner: ^2.0.0注意版本对齐cached_build_runner 对 build_runner 的版本有强依赖约束我用的是 2.4.x 的 build_runner 配合 2.0.x 的 cached_build_runner整体稳定。如果你现有工程里 build_runner 版本比较旧建议先统一升级不然后面跑 build 时容易出现cache format not compatible的报错。build.yaml 的配置是适配的核心。以我常用的一个配置为例targets: $default: builders: json_serializable: options: field_rename: snake explicit_to_json: true cached_build_runner: options: # 缓存根目录强烈建议固定为一个独立路径 cache_location: .cache/build_runner # 开启构建图快照的持久化 keep_build_graph: true # 超过 30 天未命中的缓存自动清理 prune_days: 30这里有几个参数值得展开cache_location默认值是.dart_tool/cached_build_runner。鸿蒙化工程里我改成了项目根目录下的.cache/build_runner从根上避开 hvigor 的资源扫描。keep_build_graph这个必须开只有把构建图持久化到磁盘才能实现跨进程、跨终端的缓存复用。prune_days缓存体积控制。鸿蒙化工程里我遇到过缓存目录膨胀到几个 GB 的情况加上 prune 规则后缓存能被限制在合理范围内。3.3 算力分发与缓存共享的架构设计标题里有个关键词是算力分发。我在实际操作中把这一层拆成了两件事一是本地多核并行调度的优化二是跨机器的缓存共享。本地并行的关键是调整生成器的build_to和inputs声明。build_runner 内部任务调度器会根据构建图决定任务的并行度。把生成器的build_to: cache而非source是最有效的优化方式因为 cache 模式的中间产物不必写回源目录构建图判定为未变化的节点可以直接复用。跨机器共享缓存的经典做法是把缓存目录上传到自建的 CI 缓存服务或对象存储。在鸿蒙化团队协作场景里我建议走这样的流水线CI 拉取代码后先从缓存服务拉取cache_location目录到项目根目录。执行dart run cached_build_runner build --build-filter...配合 git diff 设置过滤条件。构建完成后把cache_location目录增量同步回缓存服务。这里有个细节缓存目录里除了产物文件还有构建图文件。两个是不同的文件必须一起上传下载否则只下载产物而缺少构建图缓存命中的准确率会大打折扣。我早期就吃过这个亏——CI 上明明恢复了产物缓存但构建还是全量跑了查了半天发现是构建图没有还原。4. 实操完整跑通一套鸿蒙化适配链路4.1 初始化配置与首次构建记录我以一个假想的鸿蒙化 Flutter 工程为例目录结构长这样flutter_harmony_app/ ├── .cache/ │ └── build_runner/ ├── harmony/ │ ├── entry/ │ ├── hvigorfile.ts │ └── oh-package.json5 ├── lib/ │ ├── models/ │ ├── pages/ │ └── routes/ ├── build.yaml └── pubspec.yaml首次构建时建议先把生成器全部跑一遍拿到一个完整的基线dart run cached_build_runner build --delete-conflicting-outputs这一步生成的缓存会包括全部构建图节点和产物哈希。我在跑的过程中看到一个关键日志[INFO] CachedBuild: build graph snapshot saved to .cache/build_runner/graph.snapshot [INFO] CachedBuild: 187 actions in graph, 0 cached hits, 187 target outputs generated这里的187 actions说明构建图里注册了 187 个生成动作首跑没有缓存命中全部重新生成。这次构建的时间往往和原生 build_runner 差不多区别在于它会额外写一份图快照。4.2 验证增量构建的缓存命中第二步要做的是验证未变更构建是否真的走缓存。执行同样的命令观察日志dart run cached_build_runner build --delete-conflicting-outputs预期输出[INFO] CachedBuild: 187 actions in graph, 187 cached hits, 0 target outputs generated [INFO] CachedBuild: Build completed in 5.2s看到187 cached hits这行就说明缓存链路完全打通了。我在实际验证时还会做一个更有代表性的操作只修改一个模型类添加一个字段然后重新执行构建。这时候日志应该显示只有 3-5 个节点重新生成了产物其余节点全部命中缓存。这里顺便提一个大家容易忽略的细节即使只有 3 个节点重跑生成时间也不一定等于单个生成器的执行时间。因为 build_runner 里很多生成器是链式依赖的一个模型字段的变更可能触发 model - json_serializable - injectable 的多层传播被影响的节点数可能比你预期的多。这个传播范围取决于你在 build.yaml 里声明的dependencies关系依赖声明越保守重跑的节点就越少。4.3 对接 hvigor把 Dart 生成纳入鸿蒙构建的依赖检查鸿蒙化工程的真正难点在构建系统的对接。一般的做法是在 hvigor 的hvigorfile.ts里注册一个自定义 Task负责在构建之前确保所有 Dart 代码生成都是最新的。一个最小可用的示例import { createTask, request } from ohos/hvigor; import { execFileSync } from child_process; export default function buildPlugin() { createTask({ name: ensureDartGenerated, onExecute() { const dartCmd process.platform win32 ? dart.bat : dart; execFileSync(dartCmd, [ run, cached_build_runner, build, --build-filterlib/**, ], { stdio: inherit }); }, }); request.createDependency({ type: pre, taskName: ensureDartGenerated, targetName: default, }); }这段代码的作用是外层 hvigor 每次构建时先确保 Dart 代码生成是最新的。你可能会担心这会让每次鸿蒙构建都变慢——但实际上因为 cached_build_runner 缓存命中极快未变更时的构建只花几秒几乎可以忽略。而真正有变更时这个 Task 会补齐所有的生成文件避免出现鸿蒙包打完了Dart 代码还是旧版本的问题。还有一点要留意--build-filter参数不是缓存场景的必需品它的作用是让 build_runner 只关心lib/**下的输入避免把测试目录和其他无关代码也纳入构建图。在鸿蒙化工程里我强烈建议加上这个过滤条件因为鸿蒙工程里会有oh_modules、.hvigor这些大型目录如果它们被误扫进构建图缓存失效的概率会急剧上升。4.4 配置持久化缓存的自动清理与失效策略长时间运行的鸿蒙化工程缓存目录一定会越来越大。除了前面说的prune_days: 30我还会在 CI 层面加一层策略每天凌晨的 CI 定时任务对cache_location目录做一次只保留最近 7 天有命中的文件的整理。每个迭代版本发布后手动执行一次完整构建生成新的缓存基线再清理历史版本遗留的缓存。有人可能会问直接删除缓存目录会不会影响正确性答案是缓存目录删了只是失去了加速能力下次构建会退化为全量构建但产物是正确的。所以缓存清理的安全边界其实是性能损失而非构建失败风险。理解了这一点你就不会在处理缓存问题时束手束脚。5. 常见问题与排查技巧实录5.1 缓存命中率突然大幅下降我在鸿蒙化适配过程中遇到最典型的问题就是某天开始cached hits突然从 187 掉到 60 左右构建时间回归到两分钟级别。排查之后发现原因有几种有人在源码里加了 DateTime.now() 或者随机数导致每个节点的哈希都不稳定。这个在 Dart 代码生成器里特别隐蔽因为你不会直接看到运行时的时间依赖。pubspec.lock更新导致 build_runner 自身版本变化构建图序列化格式不兼容。这种情况我建议避免频繁锁定 dev_dependencies 中的 build 相关依赖版本。排查方法很简单用dart run cached_build_runner build --verbose跑一次日志里会打印每个 miss 节点的原因。重点看changed because: input key changed这一行它后面会跟着具体的文件路径顺着路径去翻代码很快就能定位问题。5.2 缓存文件被误删或锁定在 Windows 上做鸿蒙化开发时我遇到过cached_build_runner报Cache file is locked的错误。原因是有个杀毒软件在后台扫描缓存目录把文件的独占锁拿走了。解决办法是把缓存目录加入杀毒排除列表或者换用exclude_from_scan: true配置项告诉 cached_build_runner 不需要给缓存目录里的文件做额外的哈希校验。还有个更隐蔽的情况IDE 的文件监听器比如 DevEco Studio 的内置 watcher会对缓存目录里的文件变化做响应一旦缓存更新IDE 就尝试 reload 项目导致锁冲突。我的做法是在 IDE 的 exclude 列表里把.cache/build_runner和.dart_tool都加进去让编辑器和构建工具各管各的互不干扰。5.3 生成产物在鸿蒙侧编译报错但缓存显示 Cached这个坑需要特别重视。出现这种情况时生成的 .dart 文件在缓存里是对的但鸿蒙侧打包用的是另一份旧文件。核心原因通常是文件路径映射错位。举个例子构建图上记录的lib/models/model.dart的生成产物路径是lib/models/model.g.dart但鸿蒙侧 hvigor 的资源拷贝规则把这个文件复制到了oh_modules/.cache下。于是 Docker 或者原生的 Dart 编译流程虽然命中了缓存但真正参与鸿蒙编译的 .g.dart 文件被替换成了旧版本。排查思路分两步走先比对缓存目录里的产物版本再检查 hvigor 的文件拷贝规则。我在自己的工程里最终是修改了 hvigor 的resourceIncludes配置把生成产物纳入统一管理再也没有出现过缓存对但产物错的情况。5.4 多平台开发时换行符与编码问题跨平台协作时Windows 上的 CRLF 换行符会让文件哈希与 macOS/Linux 上不一致导致缓存命中率下降。这个现象在纯 Dart 项目里少见因为 build_runner 默认会对文本文件做规范化处理。但鸿蒙工程里存在.json5、.har这类资源文件它们不一定会被 Dart 的文本处理逻辑规范化。我最后的招是在项目的.gitattributes里显式指定*.dart text eollf保证所有参与代码生成的文件在 Git 层面统一为 LF 换行。这套配置一落地团队内部跨平台的缓存命中率从 70% 左右提升到了 94% 以上效果立竿见影。6. 性能实测与经验心得总结我以一个包含 187 个构建图节点、12 个生成器、约 300 个源文件的鸿蒙化 Flutter 工程为基准做了一组对比场景原生 build_runnercached_build_runner首次全量构建约 210s约 185s因为额外写构建图缓存未变更增量构建约 180s仍需全量扫描 生成器调度约 5s单文件变更增量构建约 190s约 13s切换 Git 分支后构建约 210s约 27s依赖分支差异大小CI 上恢复缓存后构建约 210s约 8s从数据上看cached_build_runner 的收益在未变更场景下最极端而切换分支场景的收益则取决于你对缓存目录的共享策略。如果 CI 能从缓存服务里把构建图一并拉下来即使切换分支也能最大限度的复用旧缓存。这里我特别想说一个方法论上的体会构建加速工具的本质是在确定性计算和重复劳动之间寻找冗余空间。build_runner 各有各的逻辑确定性但没有缓存你就得为这份确定性付出每次全量的代价。cached_build_runner 把这个空间填实了而鸿蒙化适配又补上了工程链路上最后一公里的问题——两套构建系统的路径隔离、缓存共享、CI 恢复策略。只要这三件事都理顺了开发侧的构建体验基本就接近无感了。最后再分享一个我在实际项目里沉淀的小技巧如果你用 CI 流水线同步缓存尽量在流水线里同时保留缓存恢复和缓存上传两个阶段并且给缓存上传加上--prune-days 7的清理策略。这样既能保证每次构建的缓存新鲜度又能避免缓存服务被历史垃圾撑爆。我在团队里推行这个规矩之后CI 构建时间的中位数从 9 分钟降到了 2 分钟以内而缓存服务的月度存储成本反而降低了 30% 多。构建加速这事真的不是只装一个工具库那么简单把它放到整个研发效能链路里想才是真正的鸿蒙级工程思维。