做 Flutter 组件适配鸿蒙这件事,很多人第一反应是"要不要重写一套",其实真正的工程量往往不在组件本身,而在它周边的自动化链路。这次我把actions_toolkit_dart这个组件整体迁移到 HarmonyOS 平台,顺带搭了一套 GitHub Actions 深度集成的跨端流水线治理架构。整个过程踩了不少坑,也沉淀出一些可复用的方案,整理出来给同样在做鸿蒙适配的同学参考。
先说结论:组件本身改造量不大,真正的难点在于自动化套件的选型、平台判断逻辑的梳理,以及多端流水线的一致性问题。这篇文章会把这几个层次拆开讲,从组件改造到流水线编排,再到治理架构的搭建,按实操顺序来。
1. 项目拆解:actions_toolkit_dart 到底是什么,为什么要做鸿蒙适配
1.1 组件定位与核心能力
actions_toolkit_dart是 GitHub Actions 生态里少有的纯 Dart 实现工具包,主要封装了 Actions 运行时需要的几类基础设施:输入参数解析、输出结果写回、日志分级输出、命令行调用封装、文件路径处理、哈希计算等。它做的不是某一个具体功能,而是给所有跑在 Actions 里的 Dart 脚本提供一套统一底座。
我自己维护的几个 Flutter 自动化脚本,比如版本号自动 bump、CHANGELOG 自动生成、多仓库依赖同步,都跑在这套工具之上,所以这次鸿蒙适配不是"某个组件要不要支持新平台"的小事,而是我整个自动化体系能不能在鸿蒙生态里延续的大事。
1.2 鸿蒙适配的痛点在哪
HarmonyOS 侧对 Flutter 的支持已经逐步落地,但第三方组件的适配程度参差不齐。actions_toolkit_dart的问题比较典型:它大量依赖 Node.js 风格的路径规则、Unix 风格的软链接和权限位操作,这些在标准 Linux 环境上没问题,到了鸿蒙的设备侧编译环境里,文件系统语义和进程行为都有差异,直接编译会报一堆环境断言错误。
适配的难点从来不在于"能不能跑通 Demo",而在于"能不能在 CI 环境里稳定重复地跑出相同结果"。鸿蒙的构建环境、签名流程、SDK 路径都跟传统 Android 不一样,工具链必须感知这些差异。
1.3 场景判断:适配到什么程度才算完成
我给自己定的适配标准是三层:第一层是组件在鸿蒙 SDK 环境下能编译、能跑单元测试;第二层是组件能感知目标平台是 HarmonyOS,并自动切换文件路径和命令调用策略;第三层是把这个能力接入 GitHub Actions 的 Workflow,实现 HarmonyOS 构建任务与 Flutter 主端任务在同一个流水线里编排。三层全部跑通,才算真正的"鸿蒙适配",而不是只保证能编译。
2. 整体设计思路:自动化套件方案的选型与架构取舍
2.1 方案选型:为什么坚持在 Dart 层做统一
一开始有人建议我直接用鸿蒙官方的 hvigor 构建能力绕开组件层的适配,把自动化逻辑拆成两套,一套跑 Flutter 的,一套跑鸿蒙的。我做了一次小范围验证后发现,拆两套的维护成本会随着脚本数量线性上升,而且两端行为很容易漂移,出现"Flutter 侧正常、鸿蒙侧同一份逻辑输出不一样"的诡异问题。
所以我坚持在actions_toolkit_dart这一层做平台抽象,让所有上层脚本继续用同一套 API。组件内部通过一个抽象的PlatformContext接口屏蔽差异,Flutter 构建环境实现一套,鸿蒙构建环境实现另一套,上层业务代码完全无感。这个设计思路跟"适配"这件事的本质是匹配的:适配的意义不是制造分支,而是消除分支。
2.2 架构分层
整个架构分四层:
- 组件核心层:
actions_toolkit_dart的公共能力,包括参数解析、日志、命令执行等,这些逻辑与平台无关,只做标准化; - 平台适配层:针对 Flutter 环境、鸿蒙环境各自实现的路径规则、命令调用策略、环境变量读取方式;
- 任务脚本层:具体业务,比如版本管理、构建触发、产物收集,这层调用的是核心层的统一 API,不直接跟平台打交道;
- 流水线编排层:GitHub Actions 的 Workflow,负责把各平台的任务串起来,处理依赖关系和产物传递。
这个分层的好处是,组件核心层保持纯净,平台相关的坑都收敛在适配层,上层脚本永远不会因为平台细节的变动而跟着改。
2.3 为什么用 GitHub Actions 做编排底座
GitHub Actions 最大的优势是生态统一:Flutter 和鸿蒙的构建动作都能在同一个 Workflow 里以 job 粒度编排,事件触发、定时触发、手动触发都天然支持。相比单独维护一套 Jenkins 加一套自建 CI,共用一个 Actions 编排层可以把"跨端流水线治理"从口号变成实际可操作的东西。
具体来说,我的流水线里有一个build_matrix策略,同一份检入代码同时触发 Flutter Android 构建、Flutter iOS 构建、鸿蒙构建,三个 job 并行跑。产物的版本号、构建号、提交哈希都必须保持一致,这块统一逻辑就放在actions_toolkit_dart的版本模块里,保证三个 job 的输出不会各说各话。
3. 核心细节实现:组件改造的关键代码路径
3.1 平台抽象层的落地方式
组件的平台感知不能靠到处写if (Platform.isLinux)这类散弹式判断,而是在初始化阶段一次性确定环境类型,然后注入对应的策略对象。核心代码如下:
/// 平台类型枚举,扩展自原生 Platform 判断 enum ToolkitPlatform { flutterAndroid, flutterIos, harmonyOS, unknown, } /// 根据环境变量与文件系统特征判定当前运行平台 ToolkitPlatform detectPlatform() { // 鸿蒙构建环境有特定的 SDK 环境变量标记 if (Platform.environment.containsKey('HARMONY_SDK_HOME') && Platform.environment['DEVECO_SDK_HOME'] != null) { return ToolkitPlatform.harmonyOS; } // Flutter 环境以 flutter 命令是否存在为判断依据 if (Platform.environment.containsKey('FLUTTER_ROOT')) { return Platform.isAndroid ? ToolkitPlatform.flutterAndroid : ToolkitPlatform.flutterIos; } return ToolkitPlatform.unknown; }判断逻辑本身不复杂,复杂的是"判断错了怎么办"。我给detectPlatform加了一层防御性校验:拿到平台类型后,再去检查对应的 SDK 目录是否真实存在,否则直接抛异常并给出可读的排查信息。这一步在 CI 环境里太重要了,因为 CI 环境变量往往是批量注入的,很容易出现"环境变量存在但路径无效"的假阳性。
3.2 路径与命令的鸿蒙化适配
组件里最常用的一个能力是"定位项目根目录下的某个文件"。原本的实现直接拼接路径然后做存在性检查,在鸿蒙环境出了个很隐蔽的问题:鸿蒙侧对ohpm配置目录和entry模块目录的布局要求跟 Flutter 的lib目录布局完全不一样,同一个项目里两种目录共存,工具脚本如果按 Flutter 的逻辑去找,会命中错误的文件。
适配层的做法是维护一份"平台文件映射表":
class HarmonyPathPolicy implements PathPolicy { @override String resolveProjectFile(String projectRoot, String relativePath) { // 鸿蒙模块的源码入口通常在这几个目录 const harmonySourcePaths = [ 'entry/src/main/ets', 'entry/src/main/resources', 'oh_modules', ]; for (final p in harmonySourcePaths) { final candidate = '$projectRoot/$p/$relativePath'; if (File(candidate).existsSync()) { return candidate; } } return '$projectRoot/$relativePath'; } }命令调用那边同样要做适配。鸿蒙的构建命令入口是hvigorw,而 Flutter 构建入口是flutter build,组件里的runCommand统一入口需要把命令解析与执行分开,允许适配层传入"命令候选列表",按优先级逐个尝试:
final candidates = platform.commandCandidates('build'); for (final cmd in candidates) { final result = await processRunner.run(cmd, args); if (result.exitCode == 0) { return result; } }3.3 日志与产物规范的一致性处理
组件还承担了日志聚合的职责。以前 Flutter 环境的脚本输出用的是标准stdout,鸿蒙的hvigorw构建日志格式不同,如果直接混在一起,GitHub Actions 的日志分组功能会失去意义。
我的处理方案是把组件所有的日志输出统一成::group::和::endgroup::的 Actions 命令包裹格式,然后在平台适配层把鸿蒙构建日志通过管道重新清洗一遍,抽出关键行灌入统一的日志流。这么做还有一个附加好处:后续做失败告警分析时,可以直接从 Actions 的日志里按组提取命令结果,不用再解析杂乱的原始输出。
4. 实操过程:GitHub Actions 深度集成的完整流水线搭建
4.1 Workflow 的整体编排
我的主流水线文件.github/workflows/build.yml里定义了三个 job:analyze、build-flutter、build-harmony。analyze是门禁岗,跑完代码分析和静态检查,产物是一个 JSON 状态文件;后续两个构建 job 都依赖它。
name: CrossPlatform Build on: push: branches: [ main ] pull_request: branches: [ main ] workflow_dispatch: jobs: analyze: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 - name: Setup Flutter uses: subosito/flutter-action@v2 with: channel: stable - name: Run analyzer run: | dart analyze echo "version=$(dart run tool/version_bump.dart --dry-run)" >> $GITHUB_OUTPUT关键细节是outputs的透传:analyze算出的version要同时给 Flutter 构建和鸿蒙构建用,这保证了两个平台产物的版本号一致。这一步看着简单,实际在治理层面是核心逻辑,跨端一致性必须从源头上锁死。
4.2 鸿蒙构建 Job 的集成方式
鸿蒙构建 job 我用了自托管的 runner,因为鸿蒙的 SDK 和签名证书不方便放在公共 runner 上。自托管 runner 需要预先装好 DevEco Studio 的命令行工具链,并在 runner 的环境变量里配置DEVECO_SDK_HOME。
build-harmony: runs-on: [ self-hosted, harmonyos ] needs: analyze steps: - uses: actions/checkout@v4 - name: Setup Harmony SDK shell: bash run: | echo "DEVECO_SDK_HOME=$DEVECO_SDK_HOME" >> $GITHUB_ENV - name: Build HAP run: | dart run tool/build_harmony.dart \ --version ${{ needs.analyze.outputs.version }} - name: Upload HAP artifact uses: actions/upload-artifact@v4 with: name: app-hap path: entry/build/default/outputs/default/*这里有个很值得说的点:build_harmony.dart脚本内部调用的就是actions_toolkit_dart的组件能力,但它本身不会去执行hvigorw,而是委托给一个平台适配层的HarmonyBuildRunner,这个 runner 负责定位hvigorw路径、解析构建参数、捕获退出码并格式化日志。这样脚本层只做业务编排,平台命令差异全部隔离。
4.3 跨端版本号统一与产物收集
版本号统一这块,我写了一个version_bump.dart,从pubspec.yaml读取主版本和次版本,再结合当天日期和 commit 短哈希生成本次构建号。三个 job 拿到同一个版本号,生成的文件名格式统一为app_{version}_{platform}.{extension}。
产物收集环节还要处理一个现实问题:GitHub Actions 的 artifacts 上传是异步的,跨端流水线里经常出现 Flutter 产物上传完了、鸿蒙产物还在构建中的情况。我的处理是给每个 job 最后加一步"产物完整性校验",用组件计算 HAP 和 APK 的 SHA256,再把校验值写入同一个 manifest 文件。后续发布环节只用读这个 manifest 做最终校验,不用关心每个平台构建的先后顺序。
5. 跨端流水线治理:从脚本集到架构的跃迁
5.1 治理架构的核心:统一的状态流转
流水线治理不是简单地把多个 job 拼在一个 Workflow 里,而是要让每个环节的状态可追踪、可回查、可恢复。我借鉴了事件溯源的做法,把每次构建的关键事件以结构化的 JSON 形式追加到build_state/目录下,包括版本号、平台、触发源、关键步骤耗时、产物路径、校验值。
这些 JSON 文件本身也作为 artifact 上传,等于每次构建都留下了一本完整的"流水账"。排查线上问题时,我可以直接从日志里拉出某个构建当时的环境变量快照,而不是靠猜。
5.2 灰度与回滚策略在流水线里的落地
跨端发布最怕的是"一端成功、另一端失败了还得手动回滚"。我在组件里实现了一个release_gate模块,它读取 todos 状态文件,规则很简单:所有平台构建成功且校验通过,才允许打 release tag。只要有一个平台失败,整个发布流程自动中断。
这个模块的好处是把"治理规则"写进了代码,而不是停留在文档里。团队以后扩展新的平台,只要在新平台 job 的末尾调用统一的release_gate.markResult(),老规则自动延伸到新平台,不需要改发布脚本。
5.3 多仓库联动与依赖同步
跨端流水线治理的下一个层次是多仓库联动。我的 Flutter 主仓库依赖两个内部包仓库,当包仓库发版时,需要自动触发主仓库跑一轮跨端构建。这件事我用 GitHub Actions 的repository_dispatch事件打通:包仓库的发版流水线在成功时调一次主仓库的 dispatch API,把新版本号塞进 payload。主仓库收到事件后按workflow_dispatch的逻辑跑完整跨端流水线。
组件在这块提供的价值是"版本校验":version_guard模块会检查pubspec.yaml里的依赖版本与触发事件里带的新版本号是否一致,避免出现"包已经发版、主仓库还没更新依赖"的时间窗口。
6. 常见问题与排查技巧实录
6.1 平台判定失灵
现象:鸿蒙自托管 runner 上跑脚本,平台类型被识别成了unknown,导致路径策略走默认分支,找不到hvigorw。
原因:runner 上同时配置了 Flutter 和鸿蒙两套环境变量,我的detectPlatform里先判断鸿蒙环境变量再判断 Flutter,理论上没问题,但排查发现DEVECO_SDK_HOME在部分 job 里被actions/checkout重置了,因为自托管 runner 上一个 job 残留的环境变量在 job 间不共享。
解法:在启动脚本里显式把 SDK 路径写入$GITHUB_ENV,并在组件初始化时额外检查hvigorw可执行文件是否存在,双保险。这种"环境变量 + 文件系统特征"的双重判断,比单纯看环境变量可靠得多。
6.2 鸿蒙构建产物路径不稳定
现象:entry/build/default/outputs/default/下的 HAP 文件名在不同版本 DevEco 下会变化,导致上传 artifact 时通配符失效。
原因:鸿蒙构建工具链版本升级后会调整默认输出目录结构,公共 runner 和自托管 runner 上的工具链版本不一致时,产物路径差异很大。
解法:不在脚本里硬编码产物路径,而是让组件在构建完成后扫描entry/build/default/outputs目录,按最近修改时间取最新的后缀为.hap的文件。另外构建脚本里可以用hvigorw assembleHap --mode project -p product=default来固定产物模式,减少路径漂移。
6.3 Actions 日志分组混乱
现象:鸿蒙构建的原始输出几乎不换行,大量日志挤在同一个分组里,GitHub Actions 页面直接卡成"展开超长文本"。
原因:hvigorw的进度输出用的是\r覆盖同一行,收集日志时如果按\n切分,就会把所有内容糅进一行。
解法:写了一个日志清洗函数,把\r也作为行分隔符处理,并过滤掉重复的进度行。组件层把这部分逻辑封装成sanitizeHarmonyLog(),所有鸿蒙相关脚本共用。
| 问题 | 根因 | 排查思路 |
|---|---|---|
| 平台识别为 unknown | 环境变量未跨 job 传递 | 检查$GITHUB_ENV写入是否生效 |
| HAP 路径漂移 | 工具链版本差异 | 扫描目录取最新产物而非硬编码 |
| 日志单行过长 | \r未参与分词 | 清洗函数按\n与\r双分隔 |
| 版本号不一致 | 多 job 独立计算版本 | 用 analyze job 的输出统一透传 |
6.4 自托管 Runner 的资源竞争
鸿蒙构建比 Flutter 构建更吃内存,自托管 runner 上如果同时跑多个 job,很容易 OOM。我最初没做任何限制,结果一周内两次构建失败都出在内存不足。
解决思路分两步:一是在 Workflow 里用concurrency限制鸿蒙构建 job 全局唯一:
concurrency: group: harmony-build cancel-in-progress: false二是给 runner 配置增加 swap 空间,并让组件在启动时检查可用内存,低于阈值直接报错而不是硬跑。这个细节看着小,实际对自托管 runner 的稳定性提升非常明显。
7. 写在最后的一点体会
折腾完这套适配,我个人最大的体会是:组件适配鸿蒙这件事,代码层面的工作量只占三成,剩下的七成都在处理"平台差异如何被自动化体系平滑吸收"。actions_toolkit_dart本身的 API 设计偏向 Node.js 风格,跟鸿蒙的构建模型天然存在不匹配,但通过平台适配层把它隔离掉之后,上层脚本的重用率几乎达到百分之百。
另外一个值得分享的经验是:在工具链层面,一定要把"平台判断"做成可测试的纯函数,而不是散落在各个脚本里的环境变量判断。我后来把这套PlatformContext抽象抽成了单独的包,任何 Dart 脚本跑在任何平台环境里,初始化一步就能拿到正确的路径策略和命令策略,团队新写的自动化脚本再也没出现"换个平台就跑不动"的问题。
如果你正在做类似的工作,我的建议是先把组件层的平台抽象设计好,再去追实现细节。平台差异的坑是填不完的,但架构上把它收口住,填一个少一个,而且后面新增平台时,整个体系只需要加一个适配实现,不需要动任何业务脚本。这套路子在 Flutter、鸿蒙、未来可能出现的其他跨端平台上,都适用。