资讯动态

用Dart统一Flutter与鸿蒙构建:GitHub Actions跨端流水线治理实践

发布时间:2026/10/18 7:05:22 来源:尧图企业网站定制
如果你的 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 MapString, 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 Futurevoid 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 Futurevoid 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, }); Futurevoid 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); } FutureListString 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-actionv1 - name: 安装 Dart SDK uses: your-dart-setup-actionv1 - 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-actionv1 - 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 是整个流水线的中枢。它做的事其实不复杂Futurevoid 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 依赖特定 JDKworkflow 里显式安装并设置 JAVA_HOMEDart 编译报错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 等机密全部走 SecretsDart 脚本里禁止出现密钥硬编码。这些边界一开始就要画清楚否则后面想收紧会非常痛苦。5.3 团队落地时的现实建议最后给几条我们团队踩出来的现实建议。先把日志规范和错误码定下来再写任何构建逻辑。错误码设计得越早后面排查问题的成本越低。从高频构建入手不要一上来就想把全仓库的流水线全部替换掉。先让 Flutter 主流程切换过来跑稳两周再纳入鸿蒙。不要把全部逻辑塞进一个 Action。外壳保持薄逻辑放 Dart 包这样测试和复用都容易。给 Dart 包写单元测试。哪怕只是给 Bridge 和产物匹配逻辑写测试也能拦住很多低级错误。版本锁定很重要。pubspec 里锁定 Dart SDK 版本workflow 里锁定 Flutter 和鸿蒙 SDK 版本否则今天能跑明天跑不了没人愿意每天修环境问题。这些建议都不是什么高深理论但每一条都对应真实的坑。跳过任何一条后面都会花几倍的时间补回来。如果重新做一次我最大的变化会是先设计“失败长什么样”再去想“成功怎么做”。这句话听着空实际意思是——构建成功只有一条路失败却有几百条路。我们花了大量时间在排查那些本该一开始就用统一错误码和结构化日志拦下来的问题。另一个小经验是周日晚上刚上线新版流水线之后不要立刻把旧脚本删掉至少留两个版本并行跑一周。新旧结果对照是最便宜的正确性验证。等双端产物全部对得上再把旧脚本归档也不迟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑