如果你也在做鸿蒙化适配,应该会遇到一个很尴尬的节点:Flutter代码好不容易在鸿蒙设备上跑起来了,但工程构建链路还停留在“脚本堆砌”阶段。各种.sh、.bat再加上 CI 平台上的自动化任务,项目一多就开始失控。这次我们聊的方案核心是一个叫dartle的 Dart 构建工具,它和 Gradle 很像,但足够轻量,自带任务依赖模型和增量缓存。我花了两周时间,把一套基于 dartle 的 Flutter 组件构建体系整体迁移到 OpenHarmony 生态,不仅解决了任务编排问题,还顺手把 CI/CD 流水线打通了。这篇文章会从整体思路、任务依赖治理、自动化落地到工业级流水线编排,完整复盘整个适配过程。
如果你正打算把 Flutter 工程迁到鸿蒙,或者只是在为“构建脚本越多越难维护”而头疼,这篇文章应该能给你一个比较完整的参考方案。
1. 鸿蒙化适配的整体思路:为什么先从构建工具下手
1.1 dartle 在 Flutter 工程中的角色
先说说 dartle 是什么。它是用 Dart 写的构建工具,核心概念和 Gradle 类似:通过声明任务和任务之间的依赖关系,构建工具自动推导执行顺序、跳过未变化的任务、并行执行无依赖的任务。对于 Flutter 工程来说,默认的flutter build指令只能做最基础的编译打包,一旦工程里要生成路由表、解析多环境配置、批量处理平台资源、生成国际化文件,就会产生大量“额外能力”的需求。
我们团队的 Flutter 组件库,原本就是靠 dartle 把这些额外能力串起来的。比如每次构建前要先生成版本文件、自动拉取 pub 依赖、根据配置模板生成不同渠道的 manifest,之后再执行flutter build,最后归档产物。以前这些都是靠 shell 脚本按顺序跑,后来脚本越来越长,分支判断越来越多,正好用 dartle 做了第一批重构。
所以在做鸿蒙化适配之前,dartle 已经在我们的 Flutter 构建链路里扮演了“构建调度中枢”的角色。这意味着只要把 dartle 的某个节点替换成鸿蒙编译指令,就能让整套构建流水线继续运转,而不是另起炉灶。
1.2 鸿蒙构建链路的现状与适配切入点
OpenHarmony 侧面的构建工具链,和 Flutter 原生生态完全不同。Flutter 用flutter build,鸿蒙应用要用 hvigor 来做编译打包,依赖管理用 ohpm,还得处理鸿蒙 SDK 的签名、HAP/HAR 产物路径等逻辑。两边一碰撞,最直接的问题是:dartle 认识 flutter,认识 dart,但不认识 hvigor。
此时如果只是为了“让代码跑在鸿蒙上”,很多人会选择在 dartle 外面再加一层脚本,或者干脆在 CI 流水线里写死 hvigor 指令。但这样做的问题是,原本 dartle 的任务依赖和增量能力就被绕开了,相当于把一个已经治理好的构建系统,重新打回脚本堆积的状态。
我最后确定了切入点:在 dartle 的任务模型里,新增一组“鸿蒙原生构建任务”。这些任务内部调用 hvigor 命令行工具,但对上层暴露的是 dartle 标准任务接口。上层任务只需要声明依赖关系和输入输出,不需要关心底层是调用了flutter build还是调用了hvigor assembleHap。这样一来,原有的构建编排能力、缓存能力、日志能力都能无缝覆盖到鸿蒙构建链路,也正是这次适配最核心的价值。
1.3 方案选型:自研脚本、直接调用 hvigor、还是改造 dartle
在正式动手前,我把可选方案对比了一遍,避免一开始就走错方向。
第一个方案是自研脚本,也就是写一套 Python 或者 Bash 脚本来替代 dartle。这个方案看起来自由度高,但我在评估时否掉了。原因很简单:脚本难以表达复杂的任务依赖关系,一旦并行构建、失败重试、产物缓存这些需求出现,脚本会越来越像意大利面条。
第二个方案是在 CI 流水线里直接调用 hvigor。优点是实现最快,缺点也很明显:流水线被具体构建细节污染,而且本地开发时依然没有统一的构建入口。团队成员在本地跑一条命令能完成的构建,在 CI 上却要拆成三步五步,非常容易“本地能过,CI 挂了”。
第三个方案,也就是我最终采用的方案:在 dartle 里封装鸿蒙构建任务。dartle 本身是 Dart 生态的工具,Flutter 和鸿蒙侧都支持命令行调用,所以只需要新增一个任务类型,负责把 dartle 的标准输入输出转换成 hvigor 需要的参数。这个方案做得越早,后续接入 CI/CD 时就越省事。事实上,我在流水线侧就只暴露了一个dart run dartle -Ptarget=harmony -Pmode=release命令,流水线根本不需要知道 hvigor 的存在。
2. 任务依赖治理:构建大坝的基石
2.1 “构建大坝”的核心:把隐式依赖变成显式依赖
标题里用了“构建大坝”这个比喻,我很喜欢。大坝的本质是调节水流,构建系统的本质则是调度任务流。如果任务之间的先后顺序只靠脚本里的&&连接,那这个顺序就是隐式的、脆弱的。脚本一旦改动,很可能某个原本靠“运气顺序”工作的任务就会被破坏。
在鸿蒙化适配过程中,我做的第一件事就是把所有构建步骤拆成最小任务单元,然后用依赖关系重新拼起来。例如在原本的脚本链路里,hvigor 打包前必须完成 flutter 依赖拉取、路由表生成、ohpm 依赖安装、SDK 路径设置,但脚本只保证了“当时的执行顺序正确”,并没有把这些关系固化下来。
用 dartle 重构后,每个任务节点都显式声明了dependsOn。这样即便将来要调整任务顺序,也只需要看依赖声明表,而不是读几百行脚本逻辑。更重要的是,显式依赖让构建系统具备真正的“调节能力”:某个上游任务失败,下游任务自动不执行;没有依赖关系的任务可以并行;任务之间也可以插入缓存校验。
2.2 显式声明任务依赖的示例
这里给出一个简化版的 dartle 任务声明,用来表达核心思路。dartle 的任务定义基于 Dart 代码,在setup.dart里配置,这段代码虽然我做了简化,但依赖关系的写法是核心。
import 'package:dartle/dartle.dart'; void main() { task('version') { do { run('dart', args: ['run', 'tool/gen_version.dart']); } }; task('pubGet') { dependsOn('version'); do { run('flutter', args: ['pub', 'get']); run('ohpm', args: ['install']); } }; task('genRoute') { dependsOn('pubGet'); do { run('dart', args: ['run', 'tool/gen_route.dart']); } }; task('compileHarmony') { dependsOn('pubGet', 'genRoute'); do { run('hvigor', args: ['assembleHap', '--mode', 'release']); } }; }关键点在于:compileHarmony不再关心pubGet内部做了什么,只声明依赖它。pubGet自身又依赖version。这样一来,哪怕是新加入的鸿蒙编译任务,也能直接复用已有的依赖体系。
2.3 并行度控制:不是任务定义完就万事大吉
任务依赖关系建好之后,dartle 会自动对无依赖的任务做并行执行。这在提升构建速度时有立竿见影的效果,但也带来一个鸿蒙适配阶段特别容易踩的坑:并行执行的任务之间可能会互相抢资源。
鸿蒙构建工具 hvigor 在编译时会有自己的缓存和锁机制,如果多个 hvigor 任务同时跑,很容易出现文件锁冲突,或者内存占用过高导致构建进程被杀死。这里的经验是,要对鸿蒙编译任务单独设置并发限制,而不是盲目使用全局的默认并行值。
我到最后是把并发数调整为 CPU 核数的一半,并且在鸿蒙编译任务内部加了一个“互斥”逻辑,确保同一时间只有一个 hvigor 实例在执行。虽然这会让鸿蒙侧的任务并行度下降,但换来的是构建稳定性的明显提升。对于需要大内存的编译任务,稳定压倒一切。
除了并发限制,还建议给每个任务标记timeout和retry的次数。特别是依赖安装类任务,网络抖动导致的失败非常普遍,这时候可以允许重试两次。我一般不会给编译类任务开重试,因为编译错误重试多少次都会失败,重试反而会掩盖真正的日志线索。
3. 全链路自动化落地:从编译到产物分发
3.1 封装鸿蒙编译任务的参数传递
依赖治理只是第一步,真正让整套流水线跑起来,是把鸿蒙编译指令封装成 dartle 的任务,并且处理好参数传递。
hvigor 执行时需要知道很多关键参数,包括编译模式(release 或 debug)、目标设备架构、签名文件路径、以及鸿蒙 SDK 的安装位置。在 dartle 里,这几个参数可以通过任务输入(input)声明,也可以通过 Dart 的环境变量传入。我的做法是,将 SDK 路径放在一个harmony.env配置文件中,并在任务里统一读取。CI 流水线的环境变量只负责覆盖默认值,这样本地开发和 CI 行为完全一致。
这里需要注意一个细节:hvigor 默认依赖 node 运行时。在本地开发环境里往往没问题,但 CI 的构建镜像里很容易漏掉。所以我在compileHarmony任务执行前,加了一步环境检测,检查命令node --version能否正常执行,如果检测失败,就直接输出一条明确的错误提示,而不是让 hvigor 自己报一个莫名其妙的错误。
3.2 产物、缓存与增量构建
构建工具的价值,很多时候体现在增量构建上。源代码没变化时,跳过不必要的编译,这在大项目里能节省大量时间。
dartle 支持定义 input 和 output 文件。当一个任务声明了输入文件列表和输出文件列表,dartle 会根据文件哈希和时间戳判断是否需要重跑。在鸿蒙适配中,我是这样处理产物和缓存的:
- 输入是路由配置文件、pubspec.yaml、oh-package.json5、源码目录的代码文件;
- 输出是 HAP 产物路径、生成的路由表文件、缓存文件目录;
- 每次执行时,dartle 会比较输入文件的哈希。如果哈希没变,而且输出文件已存在,就跳过这个任务。
第一版适配的时候,我在产物路径上踩了坑。Flutter 构建输出默认在build目录,而 hvigor 输出默认在entry/build/default/outputs目录。如果 dartle 的输出声明和实际 hvigor 输出目录不一致,缓存判断就会失效——文件已经变了,dartle 却认为没变,结果 CI 上生成了陈旧产物。
解决办法是,给 hvigor 增加一个参数,把产物输出目录重定向到工程根目录下的harmony_out,然后 dartle 的输出声明也指向这个目录。这样两边就能保持同步,缓存判断也准确了。
3.3 自动化校验与质量门禁
全链路自动化,不能只停留在“能编译出包”。对我来说,构建链路的末端应该有一个质量门禁任务,这个任务会把所有质量检查都挂进来,只有全部通过才算构建成功。
在这个项目里,我在 dartle 的任务图上挂了四个质量检查任务:单元测试、静态代码检查、路由表完整性检查、签名产物存在性检查。这些任务都作为qualityGate的前置依赖,而qualityGate又依赖compileHarmony。这样构建流程结束时,如果qualityGate没有执行过,或者执行结果不是成功,CI 就判定这次构建失败。
这比在 CI 流水线里“手动拼步骤”要强得多。因为所有任务都在 dartle 这一层治理,质量门禁可以复用同一套依赖和缓存逻辑。比如,如果源码没变化,静态检查任务会直接跳过,而不需要重跑一遍,这在 CI 上节省的成本非常明显。
4. 工业级 CI/CD 编排方案
4.1 流水线接入:只暴露一条命令
有了 dartle 这一层“构建大坝”,CI/CD 编排就变得非常简单了。我在流水线里的核心步骤,只有一条:
dart run dartle -Ptarget=harmony -Pmode=release这里的-P参数会被 dartle 解析成构建参数,然后下发到各个任务。流水线不需要关心任务内部是调用flutter build还是hvigor assembleHap,也不需要知道路由表怎么生成。这样做的好处是,流水线配置和鸿蒙构建细节彻底解耦,即使鸿蒙工具链之后发生变化,也只需要改 dartle 任务,流水线配置可以保持不变。
对于多模块项目,我建议在流水线里增加一个模块列表的输入参数,dartle 接收后,会动态创建一组相同模板的构建任务,并给每个任务打上模块名标记。这样就能用同一套构建框架支撑多个 Flutter 组件同时构建,CI 日志里也更容易区分每个模块的执行情况。
4.2 高可用与失败重试机制
工业级编排,重点不是“能跑通”,而是“跑得稳”。在我们实际运行过程中,鸿蒙构建任务偶尔会因为下载工具链超时、缓存目录清理不及时等原因失败。对于这类“非代码故障”,必须在任务层面做好重试。
我在 dartle 里给依赖安装任务设置了重试条件:当任务报错信息包含“network timeout”或“ohpm 下载失败”时,自动重试最多 3 次,每次间隔 5 秒。如果重试后仍然失败,再终止整个构建。
但要注意,重试不能滥用。编译错误、代码格式化问题、资源缺失这些问题,重试多少次都一样,反而会因为反复执行而浪费时间。我的建议是,给每个任务分类:纯环境类任务允许重试,编译类任务一律不重试,而是直接输出完整错误日志。这样流水线的失败率会下降,同时也不会因为过度重试而掩盖真正的问题。
4.3 可观测性:日志聚合与指标监控
构建系统一旦接入 CI,可观测性就是隐形需求。以前用脚本时,出了问题就只能打开 CI 日志一点点翻。现在我用 dartle 的任务模型,可以给每个任务输出结构化日志,至少包含任务名、任务开始时间、结束时间、耗时、执行结果、输入文件哈希、输出产物大小。
有了这些结构化数据,就能在 CI 系统里加一个简单的看板:每次构建结束后,把耗时数据写入时间序列数据库,然后展示最近 50 次构建的耗时趋势。哪个任务突然变慢,通过趋势图一眼就能看出来。
另外,依赖图和耗时数据是可以结合分析的。比如有一次我发现compileHarmony任务耗时翻了一倍,单独看任务日志看不出异常,后来把依赖图拉出来,发现上游genRoute任务多生成了一些文件,导致 hvigor 的增量缓存失效。如果没有结构化日志,这种问题就得靠猜了。
5. 常见问题与排雷实录
5.1 问题速查表
这次鸿蒙化适配过程中,我记录了几个高频问题,整理成了一份速查表,后续团队遇到类似情况可以直接参考:
| 问题现象 | 可能原因 | 排查方向与解法 |
|---|---|---|
| hvigor 构建报错,提示找不到 HAP 产物 | hvigor 实际输出目录和 dartle 声明不一致 | 把 hvigor 输出重定向到一个固定目录,并同步更新 dartle 的 output 声明 |
| dartle 缓存看起来总是不命中 | input 声明范围太宽,把易变文件也纳入了 | 只把真正影响构建结果的源码和配置加入 input,排除临时目录 |
| CI 执行 hvigor 时提示命令不存在 | CI 镜像缺失 node 或 hvigor 工具链 | 在任务前增加环境检测,输出明确缺失项 |
| 并行执行时偶发文件锁冲突 | 多个 hvigor 任务同时操作同一缓存目录 | 限制鸿蒙编译任务的并发数,或给该任务加互斥锁 |
| 重试任务后构建日志缺失 | 重试导致同类日志被覆盖 | 输出日志按次数后缀区分,保留每次重试的独立日志文件 |
这张表看起来简单,但每一条背后都是真实踩过的坑。特别是输出目录不一致那个,当时在本地验证没问题,上了 CI 就偶尔出现缓存失效,排查了一个多小时才发现是 HAP 输出目录没有被稳定重定向。
5.2 三个容易踩的坑
第一个坑:任务依赖声明不全,导致“偶发失败”。有一次构建偶尔在genRoute之后直接开始在compileHarmony,但有时又正常。最后发现是因为genRoute和compileHarmony之间的依赖关系没有显式声明,dartle 默认认为二者互不依赖,就并行执行了。由于生成路由文件的速度不稳定,hvigor 有时能读到新文件,有时读不到。这个问题靠加一行dependsOn就解决了,但如果不理解依赖模型,排查起来会非常痛苦。
第二个坑:缓存目录权限问题。dartle 默认会在~/.dartle/cache下保存增量信息,CI 平台的沙箱环境有时候没有这个目录的写入权限。刚开始 CI 一直出现“无法更新缓存”的神秘报错,后来我直接把缓存目录改到项目工作区下的.dartle_cache,并设置.gitignore忽略,问题才消失。
第三个坑:环境变量在本地和 CI 不一致。鸿蒙 SDK 路径我在本地是写死的,团队其他人 clone 工程后跑不出来。后来统一改成从OHOS_SDK_ROOT环境变量读取,如果没有这个变量,再从配置文件读取。这样每个人都只维护一份环境配置,构建行为就完全一致了。
5.3 我的几点心得体会
这次适配下来,我最大的感受是:鸿蒙化适配不只是让 Flutter 代码跨平台,更是让构建链路跨平台。很多团队在移植 Flutter 组件时,只关注编译期和运行期的兼容性,忽略了构建工具链的差异,结果代码层面跑通了,交付和集成环节却被脚本拖了后腿。
如果让我重来一次,我会更早地用 dartle 做统一构建入口,甚至在 Flutter 工程创建初期就规划好任务依赖。因为构建工具的迁移成本会随着任务数量增长而快速上升,越早治理,后续收益越大。
最后分享一个实用的扩展思路:我在适配完成之后,给 dartle 增加了一个“产物聚合任务”,它会把 Flutter 生成的增量产物和鸿蒙侧生成的 HAP 产物都拷贝到一个统一归档目录,再按版本号和时间戳命名。这样不管是在本地开发,还是在 CI 流水线里,都能固定路径取到最新产物,下游分发和归档环节就不再依赖具体构建工具的输出位置了。
这个思路看起来简单,但真正解决了多平台构建产物管理混乱的痛点。如果你也要做类似的多端构建适配,可以考虑参考这个设计。