ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用dartle构建OpenHarmony多模块工程:从脚本依赖到DAG编排的实践

用dartle构建OpenHarmony多模块工程:从脚本依赖到DAG编排的实践

先说个最近让我很头疼的场景:一个 OpenHarmony 多组件工程,几十个模块,每次发版都要手动确认先后顺序——先构建某个基础库,再构建依赖它的组件,最后组装成 HAP 包,中间还要穿插资源生成、签名、归档。刚开始靠几段 shell 脚本还能扛,模块一多,脚本开始“各管各”,顺序一乱就出包失败,而且失败之后很难定位到底哪一步出了问题。后来我把整个构建流程迁移到了 dartle 上,把它当作一座“构建大坝”,让所有任务像水流一样按依赖图有序泄洪,才真正把这件事理顺了。

这篇内容我会从 dartle 的核心机制讲起,再重点拆解鸿蒙化适配过程中遇到的工程改造、任务依赖治理、缓存增量,以及最后落到 CI/CD 流水线的完整编排方案。如果你也在做 OpenHarmony 应用或系统的工程化,或者想在多模块 Flutter/ArkTS 工程里引入更可控的构建编排层,这篇文章应该能给你一些可以直接用的思路。

1. 一个频繁失败的多模块工程,让我决定在鸿蒙侧引入构建编排

1.1 压垮脚本的最后一根稻草

起因是我们把一个原本用 Flutter 开发的跨端应用往 OpenHarmony 侧迁移。Flutter 时代用flutter build一把梭,底层依赖、资源、编译流程都由框架兜住,开发同学基本不用关心构建顺序。到了 OpenHarmony 侧,情况完全不一样:工程是典型的多组件形态,有多个 HAP(应用安装包)和 HAR(共享包),还有独立的 C++ 原生库、ArkTS 业务模块、资源目录,甚至还有一部分构建期生成的配置文件。

最初我在 CI 上用 shell 脚本组织流程:

./build_core.sh && ./build_feature_a.sh && ./build_feature_b.sh && ./assemble_hap.sh

看起来简单,实际上问题很多。第一,脚本之间没有真正的依赖声明,assemble_hap.sh依赖前面所有步骤,但 shell 里只能用&&串起来,一旦中间某个模块因为缓存没更新而跳过构建,后面拼接产物时就可能拿到旧文件。第二,并行执行时完全失控,几个模块同时写同一个 build 目录,出现偶发的文件覆盖,构建结果不可复现。第三,增量构建基本靠纯手工判断,某次只改了一个 ArkTS 文件,但整套流程仍然要跑二十分钟。

真正压垮我们的是一次版本发布:基础库 A 先构建,但资源生成任务 B 没有等 A 的产物落地就启动了,导致最终 HAP 里打进了一个半旧的 so 文件。当天排查了几个小时,最后发现根本不是代码问题,是流程本身的时序问题。也就是从那一刻起,我决定不再用脚本“串”任务,而是引入一个有依赖图能力的构建编排层。

1.2 为什么最终选了 dartle,而不是继续堆 shell 或改用 Makefile

选型的时候我对比了好几个方向:继续完善 shell、用 Makefile、用 Gradle,还有后来定下来的 dartle。

shell 脚本的问题是“依赖关系靠人肉维护”,顺序写错了不会有人提醒你。Makefile 虽然能声明依赖,但对缓存、产物指纹、并行调度的支持非常原始,而且语法写复杂了之后可读性很差,团队里不是每个人都熟悉。Gradle 在 Android 侧很强,但放到 OpenHarmony 场景下太重,而且和鸿蒙侧的 hvigor 构建体系是两个体系,强行揉一起反而增加维护成本。

dartle 是一个基于 Dart 的构建系统,它的核心思路和 Gradle 很像:定义 Task,Task 之间声明依赖,dartle 负责按依赖图调度执行。选择它有几个非常实际的理由:

  1. 我们是 Flutter/ArkTS 混合团队,Dart 语言的接受度比 Groovy 或 Makefile 语法高得多,上手成本低。
  2. dartle 天生支持异步并发和缓存,能比较自然的并行执行多个无依赖的 Task。
  3. dartle 的 Task 定义非常灵活,可以在 Task 里自由调用 hvigor、CMake、shell 等任意外部命令,适合作为“编排层”而不是“执行层”。
  4. 它有 UpToDate 检查机制,能根据文件指纹判断 Task 是否真的需要重新执行,这对大工程太关键了。

后来我把 dartle 定位成“大坝”,这个比喻是精确的:上游是各种源码变更和触发事件,大坝把水蓄起来,然后按照下游河道(模块产物)的承受能力有序放水。没有大坝的时候,山洪(并行构建)想怎么冲就怎么冲,最后下游(产物组装)必然一片狼藉。

2. 大坝模型拆解:dartle 的任务图是怎么把洪水变成有序水流的

2.1 任务、依赖、缓存:dartle 的三个核心概念

想用好 dartle,先理解它的三个基础概念:Task、RunContext 和依赖声明。

Task 是构建的最小执行单元。定义一个 Task 最常见的方式是继承Task类并覆写run()方法:

import 'package:dartle/core.dart'; class AssembleHapTask extends Task { @override Future<void> run(RunContext context) async { // 在这里调用 hvigor 或直接执行组装命令 await context.runCommand('hvigorw', ['assembleHap']); } }

RunContext提供了执行命令、读取产物指纹、标记缓存是否命中、打印日志等能力。我习惯把所有对外部工具的调用都包在 Task 里,Task 内只做一件事,比如BuildCoreTask只负责核心库构建,GenerateResourceTask只负责资源生成,这样依赖关系才能画得足够清楚。

依赖声明是 dartle 最能体现“大坝”价值的地方,它和张量积一样重要。一个 Task 可以通过dependsOn声明依赖:

class AssembleHapTask extends Task { @override List<Task> get dependencies => [ BuildCoreTask(), BuildFeatureATask(), BuildFeatureBTask(), ]; }

dartle 在执行时会先递归解析所有依赖,构建出一个有向无环图(DAG),然后按照图的拓扑顺序执行。如果 A 依赖 B、B 依赖 C,dartle 会保证 C 先跑完再跑 B,最后跑 A。这个机制背后是图论的拓扑排序,不需要你在脚本里手动维护顺序,只要声明好“谁依赖谁”,剩下的交给调度器。

缓存的逻辑则是用“输入指纹”来判断任务是否需要执行。假如定义了arInputs和arOutputs(实际是artifactInputs/artifactOutputs),dartle 会记录输入文件的 hash,下次运行时如果 hash 没变,这个 Task 就会被标记为 up-to-date,直接跳过。我实际用下来,这是让第二次构建大幅提速的关键:

class BuildCoreTask extends Task { @override Future<void> run(RunContext context) async { await context.runCommand('hvigorw', ['--mode', 'module', '-p', 'module=core@default']); context.saveArtifacts(['core/build/default/outputs/sdk/default']); } @override List<Artifact> get arInputs => [ if (File('core/oh-package.json5').existsSync()) FileArtifact('core/oh-package.json5', verbose: true), if (Directory('core/src/main').existsSync()) DirectoryArtifact('core/src/main', recursive: true), ]; @override List<Artifact> get arOutputs => [ DirectoryArtifact('core/build/default/outputs/sdk/default', recursive: true), ]; }

注意在 dartle 的较新版本里,inputs/outputs已经演化成arInputs/arOutputs(Artifact 的缩写)。老版本里的字符串形式虽然还能用,但我在项目里统一改成了 Artifact 形式,因为它支持目录递归和 verbose 日志,在排查增量失效时信息更全。这块后面踩坑部分还会细说。

2.2 适配层设计:把 hvigor、CMake、签名脚本都变成听话的“支流”

OpenHarmony 的官方构建工具是 hvigor,它在单个模块内部负责编译、链接、资源处理。但 hvigor 解决的是“单模块怎么构建”,解决不了“多模块怎么编排”。所以我的方案是:hvigor 负责微观构建,dartle 负责宏观编排,两者之间做一层适配。

每个外部工具对应一个基类 Task。比如 HvigorTask 统一处理 hvigor 命令的拼接和执行:

class HvigorTask extends Task { final String module; final String mode; final List<String> extraArgs; HvigorTask({required this.module, this.mode = 'module', this.extraArgs = const []}); @override Future<void> run(RunContext context) async { final args = ['--mode', mode, '-p', 'module=$module@default', ...extraArgs]; await context.runCommand('hvigorw', args); } }

CMake 原生库则单独封装:

class CmakeBuildTask extends Task { final String sourceDir; final String buildDir; @override Future<void> run(RunContext context) async { await context.runCommand('cmake', ['-S', sourceDir, '-B', buildDir]); await context.runCommand('cmake', ['--build', buildDir, '--config', 'Release']); } }

签名和归档任务类似,用 Dart SDK 里的 Process 执行已有的签名脚本。这个适配层的价值在于:整个团队只面对 dartle 一套构建协议,至于底层是 hvigor、CMake 还是手工脚本,都被封装成 Task,上层依赖图完全不用关心。

2.3 大坝的闸门:并行度、失败传播与重跑策略

有了依赖图之后,还要控制“放水”的节奏。dartle 默认按依赖顺序执行,但也支持并行。Task类里有个isConcurrent属性,如果设为true,并且两个 Task 之间没有依赖关系,调度器可以并行执行它们:

class BuildFeatureATask extends HvigorTask { BuildFeatureATask(): super(module: 'feature_a'); @override bool get isConcurrent => true; } class BuildFeatureBTask extends HvigorTask { BuildFeatureBTask(): super(module: 'feature_b'); @override bool get isConcurrent => true; }

这里核心经验是:不是所有 Task 都该并行。并行度越高,对宿主机 IO 和内存的压力越大,而且共享资源冲突的概率越高。我当时只在相互独立、且产物目录完全隔离的模块之间启用了并行,涉及同一个输出目录的任务一律保持串行。

失败传播的语义也值得注意。dartle 默认一个 Task 抛异常,整条依赖链都会中断并向上抛错。这实际上是我想要的行为:构建失败就要快速失败,不要带着半成品继续往下跑。但如果你希望某些低风险任务失败时只是警告,可以在RunContext里捕获异常自己吞掉,然后在日志里标记context.log([...])。

重跑策略我通常配置两条:

  1. 失败重跑时,优先只跑失败节点及其下游(dartle 天然支持这种局部重跑,因为依赖图和缓存会过滤掉未变动的任务)。
  2. 全量强制重跑时,给 dartle 命令加一个--clean或手动删掉.dartle缓存目录,防止缓存“假阳性”。

3. 鸿蒙化适配的实操改造:从 Flutter 工程到 OpenHarmony 多组件工程

3.1 工程结构映射与统一构建协议

Flutter 工程通常是单应用 + 若干 package 的扁平结构,构建命令集中在flutter build。OpenHarmony 工程则是 DevEco 风格的多组件结构:根目录下有AppScope、entry、feature等模块,每个模块有自己的oh-package.json5和build-profile.json5。

我的做法是在 dartle 工程里维护一个模块注册表,用一份modules.yaml描述所有模块的元信息:

modules: core: type: har buildMode: module path: core feature_a: type: hap dependencies: - core path: feature_a entry: type: hap dependencies: - feature_a path: entry

然后写一个ModuleRegistry类来解析这个文件,把“模块名 -> 依赖模块”的列表映射到 dartle 的 Task 依赖图上。这个映射是鸿蒙化适配里最核心的架构决策:所有模块依赖不再散落在脚本里,而是集中到一份 YAML 中,既方便人读,也方便 CI 自动生成依赖图。

ModuleRegistry的简化实现长这样:

import 'package:yaml/yaml.dart'; class ModuleInfo { final String name; final String type; final String path; final List<String> dependencies; ModuleInfo(this.name, this.type, this.path, this.dependencies); } class ModuleRegistry { final Map<String, ModuleInfo> modules = {}; ModuleRegistry(String yamlContent) { final yaml = loadYaml(yamlContent); (yaml['modules'] as YamlMap).forEach((key, value) { final info = value as YamlMap; modules[key.toString()] = ModuleInfo( key.toString(), info['type'].toString(), info['path'].toString(), (info['dependencies'] as YamlList).map((e) => e.toString()).toList(), ); }); } List<String> dependsOn(String moduleName) => modules[moduleName]?.dependencies ?? []; }

这样你只需要在 YAML 里维护模块依赖,dartle 工程会自动生成正确的执行顺序。

3.2 环境探测:三个版本的 SDK 你会感谢这个 task

鸿蒙化适配第一道坎就是环境。OpenHarmony 的 SDK 有多个版本线和多个组件形态,有的模块要求 API 10,有的要求 API 12,hvigor 自身的版本也对构建行为有影响。早期我们直接在 CI 机器上预装了一套 SDK,后来发现本地开发机和 CI 的 SDK 路径、版本配置完全不一样,导致同一个 dartle 工程在这两个环境跑出来的产物指纹不同。

解决办法是写一个ProbeEnvironmentTask,放在依赖图的最上游。它负责检查以下信息:

  1. OHOS_SDK_HOME环境变量指向的 SDK 路径是否存在。
  2. hvigorw是否能正常执行,以及执行后打印出的版本号。
  3. DevEco 工程要求的 API 版本是否被当前 SDK 覆盖。
  4. Node.js 和 npm 是否符合鸿蒙工具链要求。
class ProbeEnvironmentTask extends Task { @override Future<void> run(RunContext context) async { final sdkHome = Platform.environment['OHOS_SDK_HOME']; if (sdkHome == null || !Directory(sdkHome).existsSync()) { throw StateError('OHOS_SDK_HOME is not set or does not exist'); } await context.runCommand('hvigorw', ['--version']); context.log([LogItem('probe done', path: 'env')]); } }

这个 Task 我特意设为所有其他 Task 的间接依赖:要么把它作为全局 entry 的dependsOn,要么让根任务统一依赖它。这样只要 CI 换机器或开发者新拉代码,第一时间就能暴露环境问题,而不是等到组装阶段才报一个“找不到 so 文件”的诡异错误。

3.3 产物路径、中间目录的规范与隔离

多模块工程最怕产物互相污染。Flutter 时代 build 目录单一,问题不明显;OpenHarmony 下每个模块有自己的build目录,还有 dartle 自己的缓存目录.dartle。我定的规范是:

  • 各模块产物一律输出到模块自己的build/子目录,任何跨模块 copy 操作必须通过 Task 走 explicit 拷贝,禁止随意在脚本里 cp。
  • dartle 的缓存和中间文件统一放在根目录.dartle/,绝不入 Git(加进.gitignore)。
  • CI 产物归档目录单独设置,打包后再复制到归档目录,避免构建过程中直接写归档区。

产物隔离到位后,并行 Task 才敢放心开。我踩过一次因为feature_a和feature_b共享了同一个输出目录导致产物互相覆盖的坑,解决方式就是把这个共享目录拆分成各自独立的子目录,再让组装 Task 显式组合它们。

代码层面可以这样限制:

class AssembleHapTask extends Task { @override Future<void> run(RunContext context) async { final output = Directory('dist'); if (output.existsSync()) output.deleteSync(recursive: true); output.createSync(recursive: true); // 只允许在这里聚合各模块产物 copyDirectory(Directory('entry/build/default/outputs/default'), output); } }

把“聚合同一位置的逻辑”收敛到单一 Task,是避免偶发性构建错误的最有效手段。

4. 任务依赖治理:先画依赖图,再写 build 代码

4.1 依赖度的四步建模法

我在团队内部总结了一套“先画图,再写码”的建模方法,整个优化过程分四步:

  1. 列出所有构建动作。把从源码到最终产物要做的每一步都写出来,包括编译、资源生成、代码检查、单测、签名、归档。宁可拆细一点,也不要大而全的巨型 Task。
  2. 明确每个动作的输入输出。这一步往往能暴露出隐藏依赖。比如签名 Task 依赖 HAP 的成功产出,而不是“依赖模块构建完成”这种模糊描述。
  3. 画出依赖边。谁需要谁的产物,谁就在依赖图上指向谁。注意区分“构建依赖”和“信息依赖”——比如某个插件需要知道版本号,这是配置依赖,不需要排在编译 Task 前面。
  4. 用代码实现依赖边。dartle 的dependenciesgetter 和inputs/outputs定义好之后,整个图就固化了。

这四个步骤看起来简单,真正执行时很多人会卡在第二步。因为抽象地看,Task 之间可能觉得没有关系,但落到具体文件,A 编译时需要 B 生成的头文件。所以在建模时我强烈建议直接对着文件路径说话,比如“core模块产物sdk/default/ets应该被feature_a引用”,而不是只说“feature_a依赖core”。

4.2 并行边界与共享资源锁:既要快,也要稳

并行不是免费的。我在本机和 CI 上都跑过对比,发现并行度过高时收益反而下降,原因有两个:

  1. 大量 IO 密集任务同时写磁盘,SSD 带宽被打满,总体时间反而更低。
  2. 一些外部工具(比如 hvigor 的 daemon)内部有锁,多进程同时调用时会出现排队或偶发超时。

我的做法是,把 Task 分成三类:

  • 纯计算型(编译、代码生成、单测): 这些任务可并行,且应尽可能并行。
  • 共享写型(写同一个资源目录、生成公共头文件): 串行执行,或者在 Task 内加锁。
  • 聚合型(组装、签名、打包): 永远串行,放在依赖图的最下游。

dartle 里可以通过给共享型 Task 添加同一个Object锁来影响调度,但更稳妥还是靠依赖图控制顺序。有个小技巧是把共享写型任务拆成“产生文件”和“写文件”两个子任务,产生文件并行,写文件串行,既保证速度又保证安全。

4.3 增量与缓存:让第二次构建快 10 倍的细节

dartle 最大的杀手锏就是基于 Artifact 的缓存,但要用好有几个细节。

第一,精度问题。FileArtifact只监听单个文件,适合oh-package.json5、build-profile.json5这类稳定文件。DirectoryArtifact则监听整个目录,适合src/main这种源码目录。如果你把src/main整体作为输入,即使改了一个无关的 readme 文件也会导致缓存失效,所以尽量精确到你真正关心的文件子集。

第二,排除噪音。鸿蒙工程里build、.cxx这类目录变化极其频繁,如果把它们误设为 inputs,会导致缓存永远不命中。我统一在构建 Task 里只监听“源文件”和“配置文件”,输出产物本身不作为 input(否则会形成环,也会让缓存逻辑变得极难懂)。

第三,给你的构建设置约定。dartle 分布式或多阶段编排时,缓存目录.dartle要跟着 CI 的 workspace 走,最好在每次 pipeline 开始时清空一次,确保从纯净状态出发。注意:全量清理的代价是第一次构建必然全跑,所以我在日常开发分支用缓存,在发版分支强制--clean。

实际效果:引入 dartle 缓存之后,日常 local 增量构建从 10-20 分钟降到 2-3 分钟,效果非常直观。

5. 工业级 CI/CD 编排:把它接进流水线的完整方案

5.1 流水线阶段与门禁设计

dartle 只是构建编排层,CI/CD 还需要围绕它设计整个流水线。我的推荐是 Jenkins 或 GitLab CI 里划分为六个阶段:

  1. 环境准备:安装或校验 SDK、Node、hvigor、dartle。
  2. 静态检查:走 ArkTS 规范和原生代码检查,失败直接中断。
  3. 单元测试:运行各模块单测并收集覆盖率。
  4. 构建:调用 dartle 的根任务,产出 HAP/HAR。dartle 的依赖图会保证内部顺序。
  5. 组装与签名:从 dartle 的归档目录拉取制品,执行签名和最终打包。
  6. 归档与发布:上传制品库、记录版本号、发送通知。

核心原则是把“构建”和“校验”分离。校验不通过的代码,根本不让它进入 dartle 构建阶段,这是我最推荐的顺序。但注意:ArkTS 编译器本身也承担一部分类型检查,所以静态检查阶段可以跑两次,一次是 IDE 扫描级别的 lint,一次是语法强校验,后者甚至可以直接用hvigor的 assemble 来隐性验证。

下面是一个 GitLab CI 阶段的 yaml 化简版(只展示关键阶段):

stages: - env_check - static_check - unit_test - build - assemble - publish env_check: stage: env_check script: - dart run dartle --list-tasks - dart run dartle probeEnv static_check: stage: static_check script: - ./scripts/check_arkts.sh - ./scripts/check_native.sh build: stage: build script: - dart run dartle assembleAll --clean artifacts: paths: - dist/ assemble: stage: assemble script: - ./scripts/sign_and_package.sh artifacts: paths: - release/*.hap publish: stage: publish script: - ./scripts/upload_artifact.sh

5.2 制品、版本与可追溯性:每一份 HAP 都能回答“谁、什么时候、从哪个 Commit 生成”

工业级 CI/CD 不能只把包打出来就完事。dartle 构建结束后,我会让归档 Task 在制品目录里生成一个build_manifest.json,内容大致包含:

{ "version": "1.4.0", "commit": "abcdef1234567890", "branch": "release/1.4", "buildTime": "2025-01-18T10:30:00Z", "modules": { "core": {"status": "ok"}, "feature_a": {"status": "ok"}, "entry": {"status": "ok"} }, "sdkVersion": "4.0.0(10)", "hvigorVersion": "4.0.2" }

这个文件要随 HAP 一起归档到制品库,甚至可以用它自动生成部门日报。为什么要做这件事?因为一旦线上问题需要定位,你可以快速从制品库拉回同一个 commit、同一套 SDK 环境下构建的产物做复现,而不是靠“好像那天用的分支是 xxx”来碰运气。

在 dartle 里生成 manifest 的 Task 就这么实现:

class GenerateManifestTask extends Task { @override Future<void> run(RunContext context) async { final manifest = { "version": _readVersion(), "commit": Platform.environment['CI_COMMIT_SHA'] ?? 'local', "buildTime": DateTime.now().toUtc().toIso8601String(), "modules": _collectModuleStatus(), }; File('dist/build_manifest.json').writeAsStringSync(JsonEncoder.withIndent(' ').convert(manifest)); } }

5.3 失败诊断与自愈:把“构建失败”变成一条可读日志

CI 最大的痛点不是失败,而是失败后不知道怎么定位。我做了三件小事让失败诊断效率大幅提升:

  1. dartle 日志规范化。每次执行 Task 都把开始、结束、耗时打成结构化日志,CI 收集后按 Task 名检索,一眼看出哪个模块最耗时。
  2. 失败上下文快照。Task 失败时,把当时的产物目录列表、最近日志片段、相关环境变量一并保存到dist/failure_context/,这样不用登录机器也能离线分析。
  3. 失败自愈。对于偶发的 hvigor 超时或网络抖动,可以在 dartle 外层包一层重试逻辑——但只能在确定不会产生副作用的任务上重试。我在实践中只对ProbeEnvironmentTask和依赖下载类任务做重试,对真正的编译任务一律不重试。

6. 踩坑实录:在鸿蒙化过程中最痛的五个问题

6.1 任务环不报错的“假死”现象

一开始我天真地以为 dartle 会自动检测依赖环,但实际上如果你在dependenciesgetter 里间接引入了环,可能不会立刻爆出异常,而是出现执行栈异常、任务重复调度或者干脆卡死。

我们的真实案例是:ModuleRegistry从 YAML 里解析依赖,某次提交把entry -> feature_a -> core -> entry反向加了一条边,结果 dartle 在解析依赖图时出现死循环式的表现。

教训是:在加载模块注册表后,必须先做一个 DAG 校验,用简单的 DFS 检测环,一旦发现环立刻抛错并打印路径:

void validateNoCycle(ModuleRegistry registry) { final visiting = <String>{}; final visited = <String>{}; void dfs(String node) { if (visiting.contains(node)) { throw StateError('Dependency cycle detected: $node'); } if (visited.contains(node)) return; visiting.add(node); for (final dep in registry.dependsOn(node)) { dfs(dep); } visiting.remove(node); visited.add(node); } for (final name in registry.modules.keys) { dfs(name); } }

6.2 并行 Task 覆盖同一产物目录

这个坑我在 3.3 里提过。这里再补充一个隐藏版本:某次我把 C++ 原生库的CMakeBuildTask和 ArkTS 模块的HvigorTask都设成可并发,但它们共同写到了build/default/intermediates的某个共享子目录,结果出现首页加载偶发崩溃,查了很久才发现 is so 文件版本不一致。

后来我加了一条硬规则:凡是输入输出目录有交集的任务,一律不允许并行。如果两个 Task 需要共享同一个文件,必须通过一个显式的中间产物 Task 串起来,或者在下游统一汇编。

6.3 上下层缓存叠加导致的增量失效

dartle 有自己的缓存,hvigor 也有自己的增量。这两者叠加时容易出现“明明 hvigor 已经跳过执行,dartle 还认为任务执行完毕”的问题。反过来的问题更严重:dartle 认为某 Task 缓存未命中而重新执行,但 hvigor 又因为自己的缓存判断不执行,导致输出文件没有更新,dartle 的下游任务依然拿旧产物。

这个问题的根源是dartle 的缓存判断和 hvigor 的缓存判断不是同一套指纹。我的解决办法是:在 dartle 里调 hvigor 时,统一加--no-daemon或关闭 hvigor 自身的增量缓存,让外层 dartle 成为唯一增量来源:

await context.runCommand('hvigorw', [ '--mode', 'module', '-p', 'module=$module@default', '--no-daemon', ]);

各有各的道理,但如果套了双层缓存,排查成本会飙升。我的建议是在非必要情况下只保留最外层缓存。

6.4 本地过、CI 挂的环境变量污染

现象很典型:本地dart run dartle assembleAll一切正常,推到 CI 就报Cannot find module ...,而且第一次和第二次报错还有细微差别。定位过程让我印象很深。

首先怀疑是 CI workspace 不干净,清理后重跑,还是失败。接着把 CI 的日志文件拉下来和本地对比,发现 hvigor 在 CI 上自动读取的~/.harmony配置和本地不同,里面指向的 SDK 路径完全失效。后来我在ProbeEnvironmentTask里强制设定了DEVECO_SDK_HOME和OHOS_SDK_HOME,并且让所有构建命令都走这个统一环境:

final env = Map<String, String>.from(Platform.environment) ..['DEVECO_SDK_HOME'] = sdkHome ..['OHOS_SDK_HOME'] = sdkHome; await context.runCommand('hvigorw', args, environment: env);

这个“环境变量污染”是本地和 CI 行为不一致的最常见来源,我强烈建议在适配层基类里统一注入环境变量,而不是依赖机器级配置。

6.5 依赖图可视化调试

最后一个小技巧:dartle 支持把任务图输出成文本,但看复杂依赖时还是不够直观。我写了一个printDependencyMap调试脚本,从ModuleRegistry生成一个 Markdown 表格或 SVG 风格的静态图:

  • 在本地开发时,用脚本打印“模块依赖表”,用于 review。
  • 在 CI 日志里,定期输出依赖图摘要,方便事后回溯。

不依赖可视化工具的情况下,直接用表格表达依赖关系也很有效:

模块类型依赖输出
coreHAR无core/build/default/outputs/sdk/default
feature_aHAPcorefeature_a/build/default/outputs/default
entryHAPfeature_aentry/build/default/outputs/default

这张表不管给人看还是给 CI 归档,都远胜一坨只能人肉理解的脚本依赖。

最后再分享一个小技巧

在跑通了这套 dartle 鸿蒙化适配之后,有一个让我最意外的收益是:开发者不再关心“该先跑哪个脚本”了。以前新人入职,光理解构建流程就要一两天;现在只要dart run dartle assembleAll一条命令,依赖、顺序、缓存全部由大坝自己调度。让构建系统替人做确定性决策,这比任何优化参数都重要。

如果你也在做 OpenHarmony 或类似的多模块工程编排,我建议你先不要急着把所有工具都塞进 dartle,就从一个核心链路(比如 core -> entry)开始,把两个 Task 的依赖和缓存跑通,再逐步扩展。踩坑本身就是经验,但有些坑,我希望你一次也别踩。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进