☰
基于 actions_toolkit_dart 将鸿蒙构建融入 Flutter 跨端 CI 流水线
2026/10/20 5:25:48 网站建设 项目流程

在 Flutter 工程把鸿蒙(HarmonyOS)列为正式构建目标之前,我一直以为流水线只是“多写几个 CI 配置”的问题。等真把鸿蒙塞进发布矩阵,才发现签名工具链、包产物、构建入口和常规的 Android/iOS 流程全对不上,几条流水线各写各的,版本号还会漂移,最后不得不回头重构。这篇实战记录就是我当时摸索出来的方案:用 Dart 生态里的 actions_toolkit_dart 作为公共同步层,把 GitHub Actions 的自动化套件能力收拢起来,将鸿蒙构建深度集成进同一套跨端流水线治理架构。适合正在做 Flutter 多端发布、又不想让 CI 配置失控的团队。

很多人会问,GitHub Actions 的官方 toolkit 明明是基于 Node.js 的,为什么非要跟 Dart 较劲。我把原因拆开之后,发现这个选择对鸿蒙适配的场景其实非常自然,下面会展开讲。

1. 为什么盯上 Dart 系 Actions 套件:跨端 CI 的统一前提

做跨端流水线最容易犯的错,不是某一条平台的流水线写得不漂亮,而是不同平台之间存在大量重复且互不兼容的配置。Teams 里负责 Android 的同学写一套 shell 脚本,负责 iOS 的同学写一套 Ruby/Python,负责鸿蒙的同学又自己造一套批处理。这样不但维护成本高,而且有一个很致命的问题:发布策略无法在全局层面统一。

我们当时定下的原则是:凡是“构建之外”的工作,比如参数校验、版本号解析、产物命名、签名信息注入、失败回调,都属于治理逻辑,必须在同一个语言层面复用。Flutter 团队本身每天都在写 Dart,那么 CI 辅助代码也写在 Dart 里,是成本最低的路径。actions_toolkit_dart 提供的就是这么一层东西:让 Dart 脚本在 GitHub Actions 的环境里读写输入输出、执行外部命令、按 glob 规则匹配文件,以及管理日志和状态码。

选型时其实把常见的 Custom Action 实现方式都过了一遍:

实现方式典型场景主要问题
Node.js 脚本官方文档默认路径团队需要同时维护 JS 生态依赖,类型反馈弱
Shell/Python快速写个小步骤跨平台行为差异大,参数解析靠人品
Dart 单文件编译需要复用业务代码需要额外处理 Dart SDK 环境,但可编译成原生可执行文件
Docker 容器锁定构建环境镜像体积大,调试链路长

对比下来,Dart 的优势不只是语法,而是它能直接读取我们 Flutter 工程里已有的版本管理、工具链探测这类 Dart 代码。也就是说,CI 里的治理逻辑可以和业务代码共用一套模型定义。鸿蒙适配的本质,其实不是重写流水线,而是把鸿蒙特有的构建动作转化成同一套 Dart 逻辑能理解的数据结构。

从架构上看,actions_toolkit_dart 让 CI 更像一个“编程任务”而不是“配置拼盘”。这对鸿蒙这种新接入的目标尤其重要——新平台的工具链还不够稳定,如果每条命令都散落在 YAML 里,后期面对版本漂移时非常痛苦。把它收拢到一个 Dart 入口里,至少在排错时只需要盯住一个文件。

2. 适配前,把 actions_toolkit_dart 的关键能力先盘点清楚

如果在没有搞清楚这个套件能干什么之前就直接上手适配,后面很容易被“明明能设置输出,却没有生效”这类问题耗死。我按实际使用频率把它的能力分成三块:core 通信能力、exec 进程能力、glob/io 文件能力。搞清楚这三块之后,整个鸿蒙流水线的骨架就清楚了。

2.1 core 模块:让 Dart 脚本和 Actions 运行器正确对话

GitHub Actions 的运行器和脚本之间,本质靠输出流里的特殊指令通信。actions_toolkit_dart 里的 core 模块把这些指令封成了方法,不需要手写::set-output name=xxx::yyy之类的裸文本。

下面是适配过程中最常用的几个调用,我直接摘了当时项目里的一段简化代码:

import 'package:actions_toolkit_dart/actions_toolkit_dart.dart'; Future<void> run() async { final core = ActionsToolkit().core; final target = core.getInput('target', required: true); final releaseTag = core.getInput('release_tag', required: true); core.startGroup('解析构建版本'); core.info('目标平台: $target'); core.info('发布标签: $releaseTag'); core.endGroup(); if (target.isEmpty) { core.setFailed('target 不能为空'); return; } core.setOutput('normalized_version', releaseTag.replaceFirst('v', '')); }

这里有几个点值得注意。getInput在读with里传入的参数,setOutput会把值写回工作流后续步骤能拿到的地方。startGroup和endGroup的配对使用,把大量构建日志折叠成可展开的小组,在鸿蒙工具链刷出几百行日志时特别有用,否则 Actions 页面会变得没法看。

setFailed做的不仅是报错,它会让整个工作流步骤进入失败状态,且不会继续执行后续步骤。所以要养成习惯:所有关键校验失败路径上都要调用它,而不是用exit(1)裸退。

2.2 exec 模块:把 Flutter 和鸿蒙构建工具塞进同一个进程模型

跨端流水线的核心动作,本质上是“按顺序执行多个外部进程”。Flutter 构建是一个进程,鸿蒙构建是另一个进程,上传产物又是另一个进程。actions_toolkit_dart 的 exec 模块负责管理这些进程,并把 stdout、stderr 实时导入日志。

import 'package:actions_toolkit_dart/actions_toolkit_dart.dart'; final core = ActionsToolkit().core; final exec = ActionsToolkit().exec; final buildResult = await exec.exec( 'flutter', ['build', 'bundle', '--release'], stdoutListener: (line) => core.info(line), stderrListener: (line) => core.error(line), ); if (buildResult != 0) { core.setFailed('Flutter bundle 构建失败,退出码 $buildResult'); return; }

这里比较容易踩的坑是:外部命令返回非 0 时,exec 模块本身不抛异常,而是把退出码暴露给调用方。所以在适配鸿蒙构建工具时,不要只看有没有日志,一定要检查退出码,并且根据不同退出码输出不同提示。鸿蒙工具链有时候会因为缓存目录权限不足返回 1,有时候因为签名配置缺失返回 2,这时候把退出码透传到日志里,排错会快很多。

2.3 glob、io:文件匹配与产物迁移的跨端策略

GitHub Actions 的自动清理机制会在每次任务结束后删除 workspace。所以构建产物必须显式上传。actions_toolkit_dart 的 glob 和 io 在这里承担两个职责:找产物、搬产物。

const toolkit = ActionsToolkit(); final core = toolkit.core; final glob = toolkit.glob; final io = toolkit.io; final creators = await glob.glob('build/**/*.hap', root: workspaceRoot); if (creators.isEmpty) { core.warning('没有发现鸿蒙安装包产物,路径可能已变化'); return; } for (final item in creators) { core.info('找到产物: ${item.path}'); }

需要注意的是,Dart 的 glob 返回结果和我们直觉里的路径不太一样,它拿到的是一组匹配条目对象,而且路径根节点取决于调用时的root参数。适配鸿蒙构建时,建议显式指定root为构建输出目录,否则很容易匹配到一堆无关的中间文件。我们后来把“产物发现”阶段单独抽成了一个函数,就是为了避免多个目标平台之间路径规则不一致把流程搅乱。

3. 鸿蒙适配的核心工程:工具链探测、签名注入与产物归一

把 actions_toolkit_dart 的基本能力跑通只是第一步,真正的鸿蒙适配工作量在构建环境的差异处理上。以下三个模块是我认为最值得投入精力的地方。

3.1 构建入口路径的函数式封装

鸿蒙构建工具不像 Android 的 Gradle Wrapper 那样有固定脚本路径,不同开发者机器的安装位置差异很大。在 GitHub Actions 的托管运行器上,我们要依赖环境变量或者 SDK 配置文件来定位入口。

我当时采用的办法是写一个探测函数,按优先级从高到低寻找构建工具:

  1. 工作流显式传入的hvigor-path参数。
  2. 系统环境变量里登记的根路径。
  3. 已知 SDK 安装目录下按版本号倒序查找最新目录。
String resolveHvigorPath(ActionsToolkit toolkit) { final core = toolkit.core; final explicit = core.getInput('hvigor_path'); if (explicit.isNotEmpty) return explicit; final fromEnv = Platform.environment['HOS_BUILD_HOME']; if (fromEnv != null && Directory(fromEnv).existsSync()) { return fromEnv; } const candidateRoots = [ '/opt/hos-toolchain', '$HOME/HarmonyOS/Sdk', '/usr/local/hos', ]; for (final root in candidateRoots) { if (Directory(root).existsSync()) { return root; } } throw Exception('无法定位鸿蒙构建工具,请通过 hvigor_path 传入'); }

这里有个关键的适配逻辑:不要直接拿“找到的路径”去拼命令,而是先把路径归一。因为其后续无论是调用flutter还是鸿蒙打包 CLI,都会用到这个路径,一旦中间有符号链接或者版本目录变化,后续排查会非常头疼。

3.2 签名信息注入:宁可多包一层,也不往源码里塞

鸿蒙应用发布包的签名机制比 Android 更依赖外部配置。我们在流水线里如果直接把证书路径、证书密码全部写进 workflow 文件,等于把发布安全拱手让给仓库权限模型。我当时把签名相关的输入全部设计成 secrets 引用,并在 Dart 脚本里显式解到临时目录。

从 Actions 的视角看,工作流里是这样传的:

- uses: ./.github/actions/harmony_release with: target: harmony signing_cert: ${{ secrets.HOS_CERT_BASE64 }} signing_pwd: ${{ secrets.HOS_SIGN_PWD }}

在 Dart 侧,只需要把 Base64 字符串重新落盘到运行器的临时目录:

final decoded = base64Decode(signingCertStr); final certFile = File('${Directory.systemTemp.createTempSync().path}/release.p12'); await certFile.writeAsBytes(decoded);

这里需要特别注意:GitHub Actions 对 secrets 有掩码机制,但如果把 secrets 值打印到日志里,平台只会掩盖它认为的准确字符串,部分截断内容仍可能泄露。所以我在所有core.info调用之前对签名相关变量做了脱敏处理,这算是一个不大不小的安全习惯。

3.3 产物归一化:所有平台都输出同一套字段

跨端流水线最容易乱的地方是产物路径和命名。Android 输出 APK/AAB,iOS 输出 xcarchive/IPA,鸿蒙输出 HAP,Web 输出静态文件包。如果每条流水线各自定义产物路径,后续的“上传发布包”逻辑就得写四遍。我们把所有平台的产物统一成三类字段:

字段含义Android 示例HarmonyOS 示例
primary_artifact主安装包路径app-release.aabentry-default-signed.hap
secondary_artifact辅助说明/符号包mapping.txtobfuscation.txt
artifact_metaJSON 格式元信息版本号、构建号版本号、构建号

在 Dart 脚本里,build 完成后统一调用一个collectArtifact函数,把各类平台的产物传成标准输出参数。这一步看起来简单,实际上对后面治理架构帮助最大。因为所有下游步骤都只认这三个字段,不再关心当前在构建哪个平台。

4. 自动化套件落地方案:自建 Harmony 发布 Action

“自动化套件”这个说法,落到 GitHub Actions 里就是一组结构清晰的自定义 Action。我们需要把它做成一个可复用组件,而不是把脚本全部塞进某个仓库的.github/workflows下面。这里分享一下我采用的工程目录和运行形态。

4.1 组件目录结构与编译产物策略

一个标准自定义 Action 的最小目录如下:

harmony_release/ ├── action.yml ├── bin/ │ └── release_action.dart ├── dist/ │ └── hos-runner # dart compile exe 产物 ├── Dockerfile └── README.md

action.yml是 Actions 的入口描述,主要声明输入、输出和运行方式。我当时在 Docker 和 composite 之间权衡过,最终选择 Docker 方式,因为鸿蒙构建工具链依赖特定 Linux 镜像,直接打进 Docker 镜像里最稳定。

name: 'Harmony Release Suite' description: '统一的鸿蒙自动化套件入口,承担构建、签名、产物上传职责' inputs: target: description: '构建目标:android/ios/harmony/web' required: true signing_config: description: '签名配置文件路径' required: false outputs: primary_artifact: description: '主安装包路径' runs: using: 'docker' image: 'docker://registry.example.com/hos-toolkit:3.2.1' entrypoint: '/opt/hos-runner'

为了让 Docker 镜像尽量小,我没有把 Dart SDK 装进镜像,而是用dart compile exe把脚本编译成原生可执行文件。这样镜像里只有一个二进制、一套鸿蒙构建工具和对应依赖,部署和回滚都更干净。

dart compile exe bin/release_action.dart -o dist/hos-runner docker build -t registry.example.com/hos-toolkit:3.2.1 . docker push registry.example.com/hos-toolkit:3.2.1

4.2 一套工作流把多平台串进矩阵

组件准备好之后,上层工作流就可以写得很薄。核心的发布任务通过矩阵策略分别跑四端,每端的差异全部收敛在 Dart 脚本内部。

name: cross-platform-release on: push: tags: - 'v*' jobs: release-matrix: runs-on: ubuntu-latest strategy: matrix: target: [android, ios, harmony, web] steps: - name: 拉取代码 uses: actions/checkout@v4 - name: 执行自动化套件 id: suite uses: ./.github/actions/harmony_release with: target: ${{ matrix.target }} signing_config: ${{ secrets[format('SIGN_{0}', matrix.target)] }} - name: 上传产物 uses: actions/upload-artifact@v4 with: name: artifact-${{ matrix.target }}-${{ github.ref_name }} path: ${{ steps.suite.outputs.primary_artifact }}

实际上,signing_config在不同平台上结构不一样,无法真用一个 secret 解决。为了保持示例简洁,我当时是在 Dart 脚本里根据 target 去不同的 secret 映射里取配置,真正的工作流只需要透传一个signing_namespace参数。这种设计让上层工作流更稳定,而把复杂度控制在可测试的 Dart 层。

4.3 日志、缓存与失败重试的工程化处理

自动化套件跑起来之后,最常见的问题不是“能不能跑”,而是“跑挂了之后怎么办”。鸿蒙构建偶发性的依赖下载超时、签名服务限流,都会让整条流水线变红。

我在组件内部做了三个层面的增强:

  1. 对所有可能失败的 step 记录结构化日志,比如[step:resolve-toolchain][status:ok],后续可以在日志检索里快速过滤。
  2. 对下载类操作加入指数退避重试,最大重试次数设成 3,避免把网络抖动当成真实故障。
  3. 对签名服务这类外部依赖,通过退出码区分“可以重试”和“不能重试”的类型,不能重试就直接失败。

很多团队会忽略“失败任务的日志也要成为可消费数据”这件事。实际上,当我们把日志用 JSON 键值对输出后,后续做数据分析和报警都变得顺畅很多。

5. 跨端流水线治理架构:从单条工作流到平台矩阵

自动化套件只是“武器”,真正的体系是流水线治理架构。我理解的治理架构,就是让大家在发布多端产品时,能用一个统一的入口、一套版本语义、一个产物标准去管理所有平台,而不是各个团队各唱各的调。

5.1 平台差异收敛:用同一套 YAML 描述所有端

矩阵策略把四个平台塞进同一个 job,但平台差异并没有消失,只是从“四条流水线”变成了“四组参数”。我们需要在 Dart 里显式建模这些差异,否则矩阵只是把复杂度压缩到了代码里。

我当时建了一张纯配置文件来驱动矩阵差异,实际上就是一张静态映射表:

目标平台构建命令签名方式主产物后缀
androidflutter build appbundleAPK Signeraab
iosflutter build ipaApple Exportipa
harmonyhvigorw assembleHapHarmonyOS Signhap
webflutter build web无静态目录

这张表直接编译进 Dart 脚本,所有平台的默认行为都从表里读取。如果某个平台需要临时加环境变量,就在输入参数层追加,而不是再打开工作流 YAML 修改。治理架构的好处是:任何平台的行为差异都能在一个文件中找到,而不是散落在三个地方。

5.2 版本统一与产物溯源

多个平台各自发布时,最恐惧的事情是“各发各的版本号”。为了治理这个问题,我让所有平台统一从 Git tag 里解析版本号。Dart 脚本会做严格校验,要求输入是合法的语义化版本:v1.2.3,然后拆出major.minor.patch。每个平台构建时,都会把版本信息写入当时的构建元数据。

final tagMatches = RegExp(r'^v(\d+)\.(\d+)\.(\d+)$').firstMatch(tag); if (tagMatches == null) { core.setFailed('版本标签格式不合法,请使用 v1.2.3 格式'); return; } final version = '${tagMatches.group(1)}.${tagMatches.group(2)}.${tagMatches.group(3)}';

版本统一带来的直接好处,是在后续“发布确认”阶段,可以自动对比不同平台产物版本是否能对上。如果某个平台构建出的安装包信息和 Git tag 不一致,自动化套件会直接拦截。这一层校验机制在过去手动维护多条流水线时,几乎无法实现。

6. 实测回顾:这些坑我建议你直接绕开

把整个方案落地后,我们回看最耗时的并不是核心逻辑,反而是一些环境层面的边缘问题。下面这几条是实测中让我印象比较深的。

6.1 运行器环境差异:不要在 macOS 上幻想鸿蒙工具链

有一次跑鸿蒙构建,同事的 workflow 用了macos-latest,结果鸿蒙工具链压根没有对应发布包,排错半天。鸿蒙构建工具对操作系统和 CPU 架构有明确要求,跨端矩阵里最好统一使用 Linux 容器执行,尤其当涉及签名时,宿主环境的底层库不一致也会让签名工具静默失败。

我还遇到过一个更隐蔽的问题:Docker 容器里PATH和宿主机不一致。即使宿主机上有flutter,容器里也不一定有。所以在 Dart 脚本里不要偷懒直接用命令名,最好先做路径解析,解析失败时给出人类可读的错误信息,而不是让进程直接抛出command not found。

6.2 日志太多导致 Actions 页面卡顿

鸿蒙构建工具的输出量非常夸张,一度让 Actions 页面加载出现明显卡顿。后来我在 exec 的 stdout 监听器里加了采样逻辑:正常日志只保留摘要,错误行全部保留,调试模式才打印全量日志。这不只是为了观感,也避免日志量过大导致 Actions 有截断机制,把关键错误行冲掉。

6.3 签名信息泄漏比想象中更容易

还有一次差点出问题,是我们在调试时往日志里打印了秘密文件路径。虽然不会直接打印密码,但路径泄露也能帮助攻击者缩小攻击面。后来我要求所有涉及签名的临时文件集中放在一个目录里,结束前统一销毁,并且对目录名做了随机化,确保每次生成的路径都不同。这个习惯符合最小权限原则,也避免了不同运行器之间残留文件的风险。

6.4 重试语义:不是所有失败都值得重试

早期组件里所有exec失败都自动重试 3 次,结果发现有些错误,比如签名配置缺失,重试多少次都不可能成功,反而拖慢了流水线反馈。后来我们给外部进程调用增加了“失败类型分类”,只有遇到网络类、超时类错误时才做指数退避重试,其他错误直接失败。这个设计让流水线反馈速度提升了一个档位。

我在实际推进这套跨端流水线治理架构时,最深的一个体会是:适配一个新平台,表面上是解决“构建通过”的问题,深层次其实是解决“团队如何不为新平台重新发明一套 CI”的问题。actions_toolkit_dart 在这里给我的帮助不是某个魔法 API,而是把 Dart 的工程能力和 GitHub Actions 的执行模型对齐了,让所有平台之间的差异可以在同一个编程框架里有秩序地表达。如果你也在为 Flutter 多端发布发愁,可以先把“产物归一”和“版本统一”这两件小事做透,它们的意义远大于装一套看起来很炫的自动化面板。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询