如果你的 Flutter 工程和鸿蒙应用工程挤在同一个仓库里,发版前要在两三套 CI 之间来回切换,脚本一会儿 Python 一会儿 Shell 一会儿又是 Node Action,那这篇内容大概率对你有用。
核心是 actions_toolkit_dart 这层工具库。这段时间我一直在折腾一件事:用 Dart 统一 GitHub Actions 的编写方式,把 Flutter 构建、鸿蒙 HAP 打包、产物上传、失败通知这些环节全部收进同一套可复用的流水线工程里。配合 composite Action 作为外壳,我们团队现在只维护一套 Dart 代码,就能完成双端构建任务的编排、日志输出、产物收集和状态上报,并且让鸿蒙应用的构建链路和 Flutter 站在同一套治理模型下,不再是谁建谁管、脚本满天飞。
这篇文章会把整个适配过程、架构取舍和踩过的坑完整写出来。适合已经跑通 Flutter 或鸿蒙基础构建、想统一 CI 体系的团队,也适合刚接触 GitHub Actions 但不想从零啃 Node 生态的 Dart 开发者。我会尽量把目录结构、命令、配置和关键代码给完整,你拿过去改改变量就能用。
1. 项目背景与整体思路:为什么用 Dart 统一 CI 编写语言
1.1 跨端流水线的“脚本分裂”现状
先说说最初的问题。一个跨端仓库里通常同时存在 Flutter 模块和鸿蒙模块。Flutter 模块的构建链路相对成熟:pub get、test、build apk 或 ipa,产物路径稳定,社区资料也多。鸿蒙模块就不一样了,它依赖 DevEco Studio 的 Command Line Tools,构建时跑的是 ohpm 和 hvigorw,产物是 HAP/HAR/HSP,签名体系跟 Android 完全不是一回事。把这两套构建链放在同一个 CI 平台里,如果各自为政,流水线会迅速膨胀成一张互相不知道对方在干什么的蜘蛛网。
我用一个生活化类比来解释:这就好比厨房里中餐灶和西餐灶各配了一套锅具和调料架,做一顿饭要同时照顾两个操作台,菜单不统一,同一个厨子得在两套体系里来回切换。早期我们的 CI 正是这样——发版时先手动跑 Flutter 流水线,再手动启动鸿蒙的独立流水线,日期、版本号、构建参数靠人肉同步。这不仅是低效,更危险的是“人肉同步”总有一天会漏。记得有一次 Flutter 侧已经发布了 1.4.0,鸿蒙侧还在用 1.3.2 的版本号发版,线上用户端版本对不上,排查了整整一个下午。
所以要解决的第一个问题,不是“写更好的构建脚本”,而是“统一 CI 的表达方式和管理模型”。标题里说的跨端流水线治理架构,重点不在“多一条流水线”,而是让所有端共享同一套编排逻辑、同一个日志规范、同一份产物上报规则。这样 Flutter 和鸿蒙的差异被隔离在适配层,上层不再关心某个平台特有的细节。
1.2 选型逻辑:actions_toolkit_dart 能解决哪三个问题
方案选型时,我们其实试过几条路。最保守的是继续用 Shell 加 Python,好处是人人都会,坏处是跨平台路径处理、参数校验、日志结构化全得手写,越写越乱。第二条路是拥抱 Node.js 的官方 Actions Toolkit,能力很强,但要求团队里每个人都能读 JavaScript,对我们这种以 Dart 为主的 Flutter 团队并不友好。第三条路就是用 Dart 写 CI 逻辑,于是 actions_toolkit_dart 进入了视野。
第一,语言统一。一个团队只要会 Dart,就能同时维护 Flutter 业务代码和 CI 代码,不用在 JS、Python、Shell 之间横跳。第二,类型与可测试。Dart 的强类型让构建参数、输入输出这些容易出错的地方在编译期暴露出来;而且 CI 编排逻辑本质也是业务逻辑,可以写单元测试。第三,生态复用。Flutter 团队本来就有 pub get、代码分析、格式化工具的使用习惯,把 CI 代码当成一个普通 Dart 包来管理,版本、依赖、lint 全都顺理成章。
这里要坦白一点:actions_toolkit_dart 并不能被 GitHub Actions 直接加载执行。GitHub Actions 原生支持的是 JavaScript、容器和 composite 步骤,Dart 没有官方运行时。所以实际落地时,我们用 composite Action 做外壳,在 workflow 里先安装 Dart SDK,再运行我们自己的 Dart 入口脚本。外壳保持很薄,真正的逻辑全在 Dart 层。这个组合我们跑了很多个版本,稳定性完全能接受。
1.3 鸿蒙适配不是“多一个目标平台”那么简单
说到鸿蒙适配,很多人第一反应是“在 CI 里加一个构建鸿蒙的 job 不就行了”。实际操作下来,问题远多于此。首先是工具链完全不同,Flutter 是 flutter 命令,鸿蒙是 ohpm 加 hvigorw,两者参数体系、依赖方式、增量构建策略都不一样。其次是产物差异,Flutter 的产物是 APK/IPA,鸿蒙是 HAP,还可能涉及 HAR(静态共享包)和 HSP(动态共享包),依赖关系比单包复杂。再次是签名体系,HAP 的签名需要证书、Profile 和描述文件,CI 里怎么安全地注入这些私密文件、怎么处理证书过期,都要提前设计。
还有一个经常被忽略的点:鸿蒙应用和 Flutter 应用往往不在同一个发布节奏,也不一定共用同一套版本号。如果只是把构建命令堆在一起,早晚会因为“版本号要不要一起升”这种问题吵起来。所以我把适配层设计得很薄:每个平台暴露 prepare、build、collectArtifacts 三个动作,版本号、产物路径、签名配置全部通过 workflow 参数注入,而不是在 Dart 代码里写死。这样鸿蒙的适配就不只是“换个命令”,而是把平台差异收进约定好的接口里,后续加新平台也只是多写一个适配器的事。
2. actions_toolkit_dart 的能力边界与鸿蒙桥接设计
2.1 先看这个工具库提供了什么
actions_toolkit_dart 的接口设计明显借鉴了官方 Node Toolkit,核心能力可以分成四块:
- core:日志输出、输入输出读写、失败标记、summary 文件写入。它承担了 Action 和 workflow 之间的信息传递。
- exec:执行外部命令,支持参数列表、工作目录、环境变量。这是构建编排的基础,所有 flutter、ohpm、hvigorw 调用都走这一层。
- io:文件复制、移动、查找、目录处理。产物归档、临时目录处理都得靠它。
- glob:用模式匹配文件,比如轻松找到
**/*.hap,不用自己手写目录遍历。
一个最简单的使用示例:
import 'package:actions_toolkit/actions_toolkit.dart' as actions; void main() { actions.debug('开始执行构建'); actions.info('这是一条普通日志'); // 某个环节出问题时 actions.setFailed('构建失败,终止后续步骤'); }但要注意,Dart 版提供的 API 和 Node 版并不是完全对齐的。比如一些高阶能力(缓存管理、动态矩阵生成)在 Dart 版里没有现成实现。我们的做法是保留一层自己的封装,只依赖 core/exec/io/glob 这几个基础模块,其余能力用 GitHub Actions 自带的环境变量和文件协议补齐。这也是适配鸿蒙时最重要的一个设计原则:工具库是地基,不是天花板。地基决定了稳固程度,但盖几层楼、每层放什么,还是由我们自己定。
2.2 桥接层:让 Dart 脚本在 composite Action 里活起来
GitHub Actions 的输入与输出是围绕环境变量和文件设计的。workflow 里调用某个 Action 时,inputs 会变成INPUT_<大写名称>环境变量传进去;Action 要向后续步骤传值,得往GITHUB_OUTPUT文件里追加内容;要写摘要,往GITHUB_STEP_SUMMARY文件里追加。Node 官方 Toolkit 替开发者做了这些映射,Dart 版没有完整做,所以我们要自己写一个极薄的桥接层。
class ActionsBridge { static Map<String, String> readInputs() { final inputs = <String, String>{}; for (final entry in Platform.environment.entries) { if (entry.key.startsWith('INPUT_')) { inputs[entry.key.substring(6).toLowerCase()] = entry.value.trim(); } } return inputs; } static Future<void> writeOutput(String name, String value) async { final outputFile = Platform.environment['GITHUB_OUTPUT']; if (outputFile == null || outputFile.isEmpty) return; final file = File(outputFile); await file.writeAsString('$name=$value\n', mode: FileMode.append); } static Future<void> writeSummary(String content) async { final summaryFile = Platform.environment['GITHUB_STEP_SUMMARY']; if (summaryFile == null || summaryFile.isEmpty) return; final file = File(summaryFile); await file.writeAsString('$content\n', mode: FileMode.append); } }这个桥接层代码不多,却是整个方案稳定性的关键。踩过的坑包括:INPUT 名称大小写不一致导致读取为空、日志里误打印了带换行符的私密值、写 GITHUB_OUTPUT 时没有处理尾换行导致后续步骤拿到空值,等等。这些问题后面在排查章节详细展开。这里只强调一个原则:桥接层是唯一允许读写环境变量的地方,业务代码禁止直接碰环境变量,否则测试没法写,问题也没法查。
2.3 鸿蒙构建器封装:hvigor 调用与产物收集
鸿蒙工程的构建,常规流程是先跑ohpm install --all拉依赖,再执行hvigorw assembleHap或其他指定任务,产物一般落在entry/build/default/outputs/default/这类路径下。为了支持参数化,我把构建器封装成下面这样一个类:
class HarmonyBuildTool { final String projectRoot; final String task; final String buildMode; final String? signingConfigPath; HarmonyBuildTool({ required this.projectRoot, this.task = 'assembleHap', this.buildMode = 'release', this.signingConfigPath, }); Future<void> build() async { await _run('ohpm', ['install', '--all']); final args = [ task, '--mode', 'module', '-p', 'buildMode=$buildMode', '--no-daemon', ]; if (signingConfigPath != null) { args.add('-p'); args.add('signingConfig=$signingConfigPath'); } await _run('./hvigorw', args); } Future<List<String>> collectHaps() async { final pattern = '$projectRoot/**/build/default/outputs/**/*.hap'; return await actions.findFiles(pattern); } }说几个要点。第一,ohpm install和hvigorw必须按顺序执行,依赖没装好就构建会得到一堆莫名其妙的报错。第二,--no-daemon在 CI 里尽量保留,避免后台进程残留导致 job 卡住。第三,签名配置不要直接写死在工程文件里,通过-p signingConfig=...注入,或者让 CI 提前把配置文件落到指定位置,证书等私密内容由 GitHub Secrets 提供。第四,产物匹配用 glob 模式找 HAP 很省力,但要注意签名和未签名产物的路径差异,最好用最具体的后缀区分,不要图省事用宽泛的**/*.hap一把抓。
3. 实操:从零搭建跨端 GitHub Actions 流水线
3.1 仓库结构与 Action 目录设计
我采用的仓库结构是这样的,关键是把 Action 外壳和 Dart 工具代码分开存放:
cross-platform-repo/ ├── .github/ │ ├── actions/ │ │ └── cross-build/ │ │ ├── action.yml │ │ └── tool/ │ │ ├── pubspec.yaml │ │ ├── bin/ │ │ │ └── main.dart │ │ └── lib/ │ │ ├── bridge.dart │ │ ├── harmony_build.dart │ │ ├── flutter_build.dart │ │ └── pipeline.dart │ └── workflows/ │ └── release.yml ├── harmony/ ├── flutter_app/ └── l10n/.github/actions/cross-build是给 GitHub Actions 看到的外壳,action.yml 声明输入输出和运行方式;tool是一个标准 Dart 包,负责真正干活。这个分离非常关键,因为这样一来 CI 逻辑可以独立测试,甚至可以本地直接跑dart run bin/main.dart调试,而不用反复提交代码推到远程触发 workflow。真实开发时,我经常本地先跑一遍 Dart 脚本,确认无误再改 workflow,迭代速度比“每次都要 push 触发远程构建”快一个数量级。
3.2 定义 cross-build Action 与 Workflow 骨架
action.yml 不需要复杂,核心是声明输入、输出和运行方式。下面示例中的检出、Dart SDK 安装等基础步骤,需要按你们团队实际使用的镜像或封装替换。
name: Cross Build description: 统一的 Flutter/HarmonyOS 构建 Action inputs: platform: description: flutter 或 harmony required: true command: description: build/test/collect default: build build-mode: description: release/debug default: release outputs: artifact-path: description: 构建产物路径 runs: using: composite steps: - name: 检出代码 uses: your-checkout-action@v1 - name: 安装 Dart SDK uses: your-dart-setup-action@v1 - name: 安装 Dart 依赖 shell: bash working-directory: .github/actions/cross-build/tool run: dart pub get - name: 执行跨端构建 shell: bash working-directory: .github/actions/cross-build/tool env: INPUT_PLATFORM: ${{ inputs.platform }} INPUT_COMMAND: ${{ inputs.command }} INPUT_BUILD-MODE: ${{ inputs.build-mode }} run: dart run bin/main.dartworkflow 里调用时,核心是让 job 矩阵化:
jobs: cross-build: runs-on: ubuntu-latest timeout-minutes: 60 strategy: fail-fast: false matrix: include: - platform: flutter command: build - platform: harmony command: build steps: - uses: your-checkout-action@v1 - uses: ./.github/actions/cross-build with: platform: ${{ matrix.platform }} command: ${{ matrix.command }} build-mode: release注意fail-fast: false这个细节。这样 Flutter 端失败不会立刻中断鸿蒙端,两个平台的问题可以一次跑出来,省得修完一个发现另一个也坏了,又要重新触发一轮。矩阵里的platform经过with传递到 Action,再由 Action 里的INPUT_PLATFORM环境变量进入 Dart 脚本,整个链路就是前面桥接层设计的用武之地。
3.3 核心编排逻辑:Flutter 与鸿蒙双端按任务图执行
main.dart 是整个流水线的中枢。它做的事其实不复杂:
Future<void> main() async { final inputs = ActionsBridge.readInputs(); final platform = inputs['platform']; final command = inputs['command']; switch (platform) { case 'flutter': final tool = FlutterBuildTool(projectRoot: 'flutter_app'); await tool.prepare(); await tool.build(); final apkPath = await tool.collectArtifacts(); await ActionsBridge.writeOutput('artifact-path', apkPath); case 'harmony': final tool = HarmonyBuildTool(projectRoot: 'harmony'); await tool.build(); final haps = await tool.collectHaps(); await ActionsBridge.writeOutput('artifact-path', haps.join(',')); default: actions.setFailed('不支持的 platform: $platform'); } }实际工程里当然比这个复杂,比如要区分 build/test/collect 三个 command,要把日志按统一格式输出,要把每一步耗时记录下来,要把产物同时写进 summary 方便人看。但核心思想是一样的:逻辑不要散在 workflow yaml 里,全部集中在 Dart 层。一个额外的心得是:跨端任务编排时,Flutter 构建和鸿蒙构建最好跑在不同 job 里而不是同一个 job 串行,因为两个工具链的 SDK、环境变量、缓存目录都有冲突的可能。矩阵设计天然解决了这个问题;如果非要“一个 job 里依次构建两端”,一定要把环境变量和 PATH 彻底隔离,否则很容易出现 Flutter 找不到 SDK、鸿蒙找不到 Java 这类玄学问题。
3.4 参数化、版本矩阵与产物上报
版本矩阵不只是 platform 一个维度。实际跑起来后,Flutter 版本、鸿蒙 SDK 版本、构建模式、模块名都可以做成矩阵组合。例如鸿蒙侧要分别验证 debug 和 release,或者要分别构建 entry 和 feature 模块,矩阵可以让这些组合并行跑,整体耗时从“串行一小时”缩短到“并行二十分钟”。
strategy: fail-fast: false matrix: platform: [flutter, harmony] build-mode: [debug, release]产物上报这一环,在 composite Action 里通常是把产物路径作为 output 传出,再由后续的上传动作把文件传到 GitHub Actions 的 Artifact 服务。我们的 Dart 层只负责给出路径,不直接上传,职责更清晰。如果产物很大,建议在 workflow 里单独配置上传 job,并设置合理的保留天数,避免仓库的 artifact 空间被撑爆。对于 HAP 这种可能包含签名信息的文件,上传时还要注意权限范围,避免把内部签名资料意外暴露到仓库外部。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| hvigorw: Permission denied | 脚本没有可执行权限 | 构建前先chmod +x hvigorw或执行前显式加这一步 |
| ohpm install 超时 | 依赖源网络波动 | 配置镜像源,或用本地缓存源 |
| INPUT_PLATFORM 为空 | composite 中环境变量没传对 | 检查 env 映射,名称全大写且与 action.yml 一致 |
| 找不到 HAP 产物 | 构建模式不同导致产物路径变化 | 先确认--no-daemon后真实输出目录,再定 glob |
| 不同平台缓存互相污染 | 多个 job 共享~/.cache | 用分层缓存,key 里带 platform 维度 |
| GITHUB_OUTPUT 内容截断 | 多进程并发写同一文件 | 加文件锁,或集中到单一 async 方法串行写 |
| Java 版本不匹配 | hvigor 依赖特定 JDK | workflow 里显式安装并设置 JAVA_HOME |
| Dart 编译报错 | SDK 版本与工具库不兼容 | pubspec 里锁好 Dart SDK 版本范围 |
这张表不是凭空写的,每一条都是真实撞过墙之后总结的。其中“权限”“并发写文件”“缓存污染”这三类问题出现频率最高,而且报错信息往往特别隐蔽。比如缓存污染,你看到的报错是构建突然失败,但根因可能只是上一个 job 留下的~/.gradle或~/.ohpm把环境搞乱了。GitHub runner 默认是干净的,但多个 job 如果用了同一个自定义缓存,就得非常小心 key 的设计。
4.2 典型案例复盘:hvigorw 权限与 GITHUB_OUTPUT 并发写
第一个案例是 hvigorw 权限。我们第一次在全新 runner 上跑 workflow,构建 steps 一直报hvigorw: Permission denied。本地一切正常,远程必现,原因是 git 仓库里脚本文件的可执行位没有被保留。解决方案有两个:要么在 git 里显式保留 file mode,要么在 workflow 里加一个chmod +x harmony/hvigorw的步骤。我们选择了后者,因为前者对历史仓库的迁移改动太大。这个事很碎,但几乎每个团队第一次适配鸿蒙 CI 都会撞上,提前加这一行能省很多排查时间。
第二个案例是 GITHUB_OUTPUT 并发写。当 Dart 脚本里同时启动两个子进程,一个负责收集产物、一个负责上报耗时指标,两边都往同一个 output 文件追加内容,结果有时会发现后续步骤读到的键值对不完整。查看完整日志后,确认两段内容发生了交错写入。最终方案是把所有 output 写入集中到一个 async 方法里,内部用一个简单的任务队列串行处理,彻底避免了并发写文件。GitHub 官方的 Node 实现是同步写入,天然避开了这个问题,但用 Dart 的 async 写法时就要特别留意。
4.3 本地难以复现 CI 问题的三条经验
第一,把日志的冗余度提上来。Dart 脚本里每个关键步骤都打印开始时间和结束时间,失败时把退出码、标准输出、标准错误分开记录,不要只留一句“command failed”。第二,尽量提供最小复现命令。比如鸿蒙构建遇到问题,先在本地跑一遍ohpm install && ./hvigorw assembleHap --no-daemon,如果本地能过而 CI 不能,问题大概率出在环境变量、缓存或权限,而不是构建逻辑本身。第三,把 workflow 拆小。把一个大 Action 拆成几个小步骤,每步只做一件事,出现问题时能快速定位是环境问题还是脚本问题。
另外,HAP 和 APK 的差异也值得注意。上传 Artifact 时如果只是图省事把整个 builds 目录一起传,会把中间产物甚至源码里的资源也带出去,既慢又可能有安全隐患。我们后来只收集最终的*.hap和*.apk,并且校验产物大小大于某个阈值才算成功。这个阈值直接拦截了好几次“构建成功但产物为空”的假成功——特别是鸿蒙侧某些情况下会生成一个空的 HAP 壳文件,如果只看退出码,你根本发现不了问题。
5. 从“一条流水线”到“跨端治理架构”的复盘
5.1 演进路径:脚本、Action、流水线模板、治理平台
很多团队其实卡在第二和第三阶段之间。原因是技术不是难点,组织习惯才是难点——每个人都会写脚本,但不是每个人都愿意接受“CI 也是需要设计评审的代码”。标题里说的治理架构,落到地上其实是一套约定:日志必须结构化的约定、错误码必须稳定的约定、私密信息必须走 Secrets 的约定。没有这套约定,换一个人来维护流水线,架构很快就会退化回脚本堆。
演进路径通常是四步。第一步是裸脚本,所有命令直接写在 workflow 里;第二步是封装 Action,把易变参数抽成 inputs;第三步是流水线模板,把常用的 Flutter 和鸿蒙构建流程固化成标准步骤,团队新增项目时直接复制模板;第四步才是治理,核心是让流水线变得可分析、可审计、可授信。到了第四步,一个新成员接手的就不再是一堆 YAML,而是一个带测试、带文档、带错误码的工程。
5.2 治理的关键设计:分层、可观测、权限边界
在代码层面,我把这套流水线设计成三层。最底层是平台适配层,封装 Flutter 和鸿蒙各自的命令差异,对外暴露相同的方法签名。中间是任务编排层,负责构建、测试、产物收集等任务的组合和调度,只认抽象接口,不关心底层是 flutter 还是 hvigor。上层是 workflow 配置层,只有参数没有逻辑,环境相关的东西全部通过环境变量注入。这样做的直接好处是:新增一个平台,只需要写一个适配器;调整一个流程,只需要改编排层;日常发版,只需要改 workflow 参数。
可观测方面,每个任务都产出一份结构化日志块,包含任务名、耗时、产物路径、退出码。GitHub Actions 自带的时间线和日志检索已经够用,不需要额外搭一堆平台。权限边界则体现在分支保护和 Secrets 的细分上:release 分支的 workflow 只能由维护者触发,签名证书、Profile 等机密全部走 Secrets,Dart 脚本里禁止出现密钥硬编码。这些边界一开始就要画清楚,否则后面想收紧会非常痛苦。
5.3 团队落地时的现实建议
最后给几条我们团队踩出来的现实建议。
- 先把日志规范和错误码定下来,再写任何构建逻辑。错误码设计得越早,后面排查问题的成本越低。
- 从高频构建入手,不要一上来就想把全仓库的流水线全部替换掉。先让 Flutter 主流程切换过来,跑稳两周再纳入鸿蒙。
- 不要把全部逻辑塞进一个 Action。外壳保持薄,逻辑放 Dart 包,这样测试和复用都容易。
- 给 Dart 包写单元测试。哪怕只是给 Bridge 和产物匹配逻辑写测试,也能拦住很多低级错误。
- 版本锁定很重要。pubspec 里锁定 Dart SDK 版本,workflow 里锁定 Flutter 和鸿蒙 SDK 版本,否则今天能跑明天跑不了,没人愿意每天修环境问题。
这些建议都不是什么高深理论,但每一条都对应真实的坑。跳过任何一条,后面都会花几倍的时间补回来。
如果重新做一次,我最大的变化会是:先设计“失败长什么样”,再去想“成功怎么做”。这句话听着空,实际意思是——构建成功只有一条路,失败却有几百条路。我们花了大量时间在排查那些本该一开始就用统一错误码和结构化日志拦下来的问题。另一个小经验是:周日晚上刚上线新版流水线之后,不要立刻把旧脚本删掉,至少留两个版本并行跑一周。新旧结果对照是最便宜的正确性验证。等双端产物全部对得上,再把旧脚本归档也不迟。