
上周发版同事把 pubspec.yaml 里的版本号从1.6.246手动改成1.7.047结果 Android 侧versionCode没同步整个流水线在 assembleRelease 阶段红灯打出来的包直接被商店侧链路拒掉。这种事在团队里发生过不止一次于是我把目光放到了 Flutter 三方库phntmxyz_bump_version_sidekick_plugin上利用它把版本号读取、自增、回写、打 tag 这一整套动作全部自动化。本以为事情到此结束可等应用准备出鸿蒙HarmonyOS NEXT包的时候新问题出现了这个插件根本不支持鸿蒙平台。没办法只能动手做鸿蒙化适配。这篇内容就是把我的适配过程、关键决策、以及踩过的坑完整写出来给同样走在Flutter 鸿蒙 CI/CD这条路上的团队一份可以直接参考的路线图。我会把问题拆成六个部分插件到底解决了什么、适配前要准备什么、核心通道怎么实现、CI 里怎么编排、踩坑链路怎么排查、最后是一份验收清单。1. 发版规范化版本号管理为什么值得单独做一个插件1.1 版本号不是改一行的活儿很多人觉得版本号管理无非就是改一行数字但实际工程里一个 Flutter 应用的版本号是分散在多处的pubspec.yamlFlutter 包的版本号形如1.7.047加号前面是语义化版本后面是构建号。Androidbuild.gradleversionCode和versionName前者必须是严格递增的整数商店审核和灰度都靠它。iOSInfo.plist/project.pbxprojCFBundleShortVersionString和CFBundleVersion稍有偏差就可能导致 TestFlight 上的构建版本冲突。鸿蒙app.json5versionName和versionCode同样要求递增且和 Android 一样有整型约束。只要这些值不完全一致发版就可能出事故。轻则流水线红灯重则线上崩溃日志无法和版本对应定位问题要翻半天。最典型的就是只改了pubspec.yaml没同步 Android 的versionCode商店后台上传包时直接提示“版本号必须高于现有版本”发版窗口直接错过。1.2 这个插件到底替我们做了什么phntmxyz_bump_version_sidekick_plugin 这个库的名字很长但功能其实很聚焦它把 Flutter 应用发版前的版本号操作收敛成一套可编程、可在 CI 里执行的工作流。实际使用中它提供了几类能力版本解析读取pubspec.yaml的version字段拆出major.minor.patch和build number并做合法性校验。递增策略支持主版本、次版本、修订号、构建号四种维度的自增。比如发个大版本1.6.246可以变成2.0.047常规迭代则可以变成1.6.347。Dry-run 模式只输出即将发生的变更 diff不实际落盘方便在流水线里提前审计。回写和格式化把新版本号写回pubspec.yaml同时保证 YAML 格式不乱。Git 发布流集成自动生成 tag、拼装 commit message有的版本还支持联动 changelog 生成。值得强调的是这些能力大部分是纯 Dart 实现的本质上是封装了对pubspec.yaml的解析和文件写入。真正依赖平台侧的只是获取应用原生版本信息、渠道信息这一类能力。所以鸿蒙化适配的难点不在算法的移植而在于如何让插件在鸿蒙工程里“注册得上、调用得到、编译得过”。1.3 适配策略不是把插件改到另一朵云上动手前我先做了一个判断是 fork 插件改主流程还是在 CI 里自己包一层鸿蒙适配逻辑我最终选了前者但有一个前提——版本号单一真源不能变。无论 Android、iOS 还是鸿蒙所有版本号都从pubspec.yaml生成平台工程里的值只在构建期被同步。这样适配层的职责就很清晰让插件在鸿蒙工程中能正常初始化、能把原生版本信息回传给 Dart 侧同时写一个构建期脚本去更新鸿蒙工程的版本元数据不碰插件的主逻辑。如果你们团队的插件改动较大也可以先本地 patch、再向插件仓库提 PR但千万不要因为鸿蒙化而把发版主流程的逻辑拆散。一旦各端版本号的计算方式分叉后面的维护成本会让你怀疑人生。2. 鸿蒙化适配前的三件准备运行模型、环境与工程骨架2.1 鸿蒙上 Flutter 插件的运行模型鸿蒙 NEXT 原生系统和 Android/iOS 不同Flutter 应用要跑起来依赖的是 OpenHarmony 社区维护的 Flutter 分支一般叫flutter_ohos。这个分支给 Flutter 提供了一套鸿蒙平台内核也让 Flutter 插件体系多了一种平台实现方式。在鸿蒙上插件机制和 Android 很像Dart 侧通过MethodChannel发消息鸿蒙侧用 ArkTS 写插件代码在引擎 attach 时注册通道在消息到达时处理调用、返回结果。只是这个“原生侧”不再是 Java、Kotlin、Swift而是 ArkTS。很多人在适配时犯的第一个错误就是以为把 Android 的 Java 插件代码“翻译”成 ArkTS 就行。实际上Flutter 鸿蒙分支的插件加载流程有自己的注册器插件需要以独立的ohos模块存在并且通过FlutterPlugin接口接入引擎生命周期代码必须放在 plugin 模块的src/main/ets下按ets目录规范组织。这个目录结构不摆对后面所有工作都是白费。2.2 环境清单版本匹配是第一道坎做鸿蒙化适配环境准备比逻辑编写要费心思因为你面对的不是一整套官方开箱即用的工具链而是一套需要版本对齐的组件。我的环境大致是组件说明注意事项DevEco Studio集成开发环境自带 HarmonyOS SDK建议 API 12 起步API 版本影响后续 API 可用性Flutter 鸿蒙 SDKflutter_ohos分支的 Flutter SDK需要用 fvm 管理避免影响原有 Android/iOS 分支ohpm鸿蒙包管理器类似 pub/npm安装插件依赖用hvigorw鸿蒙构建工具类似于 Gradle wrapper负责产物构建Node.js / JavaDevEco 工具链的运行依赖不同 DevEco 版本要求不同踩坑率最高的一块flutter_ohos分支的版本和 DevEco 的 SDK 版本、ohpm 依赖的ohos/flutter_ohos包版本必须严格对齐。我遇到过 flutter SDK 是 3.x 的某次 commit但 ohpm 上拉下来的 flutter 插件包还是旧的导致编译时FlutterPlugin接口都找不到。建议在工程里把pubspec.yaml、oh-package.json5、Flutter SDK commit hash 一起锁进版本管理不要相信“最新版总是兼容的”这句话。2.3 给插件工程补齐 ohos 平台目录以 phntmxyz_bump_version_sidekick_plugin 为例它的仓库结构原本只有lib/Dart 逻辑、android/、ios/适配鸿蒙要新增ohos/模块。这一步的本质是让鸿蒙侧能编译出一个 ArkTS 插件包并被 Flutter 引擎在运行时加载。具体操作可以按这个顺序来在插件工程根目录创建ohos/目录内部结构参考官方 WebView 插件的 ohos 实现包含oh-package.json5、src/main/ets/等。在oh-package.json5中声明依赖ohos/flutter_ohos的插件 SDK版本要和宿主工程一致。在插件根目录的pubspec.yaml里把ohos加进flutter.plugin.platforms声明具体字段名以你用的flutter_ohos版本为准有的版本是通过额外配置文件注册的。在 Dart 侧platform_interface.dart或对应入口文件里增加ohos分支的通道创建逻辑。这里有个容易踩的坑鸿蒙目录的编译产物不是打进 APK而是作为 ohos 模块参与 hvigor 构建。如果ohos/目录内部的模块名、包名和注册 ID 对不上构建可以过但运行时插件注册表里根本没有这个插件表现就是通道调用抛MissingPluginException。所以目录结构这一步宁可慢一点也一定要对照官方示例逐文件比对。3. 核心适配平台通道与版本元数据的鸿蒙侧实现3.1 ArkTS 插件入口与通道注册Flutter 鸿蒙分支的插件接口核心是FlutterPlugin生命周期上有两个方法onAttachedToEngine和onDetachedFromEngine。前者在插件被引擎加载时调用是注册MethodChannel的最佳时机后者在引擎销毁时调用负责释放资源。下面是一个参考骨架注意类名和导入路径以你引入的 SDK 版本为准// src/main/ets/plugin/BumpVersionSidekickPlugin.ets import { FlutterPlugin } from ohos/flutter_ohos; import { MethodChannel, MethodCall, MethodResult } from ohos/flutter_ohos/plugins; export class BumpVersionSidekickPlugin implements FlutterPlugin { private methodChannel: MethodChannel | null null; onAttachedToEngine(binding: any): void { this.methodChannel new MethodChannel(binding, phntmxyz_bump_version_sidekick/methods); this.methodChannel.setMethodCallHandler((call: MethodCall, result: MethodResult) { if (call.method getAppVersionInfo) { // 从鸿蒙应用配置里读取版本信息 const versionInfo this.getVersionInfo(); result.success(versionInfo); } else { result.notImplemented(); } }); } onDetachedFromEngine(binding: any): void { this.methodChannel?.setMethodCallHandler(null); this.methodChannel null; } private getVersionInfo(): Object { // 具体实现见 3.2 } }Dart 侧调用时原来可能写的是if (Platform.isAndroid) { // android branch } else if (Platform.isIOS) { // ios branch }鸿蒙化之后要补上一个判断比如if (Platform.isAndroid) { // android branch } else if (Platform.isIOS) { // ios branch } else if (Platform.isOhos) { // ohos branch: 走 MethodChannel(phntmxyz_bump_version_sidekick/methods) }注意Platform.isOhos这个字段在标准 Dart SDK 里未必存在要看flutter_ohos分支是否补了这个实现。如果没补可以用Platform.operatingSystem ohos兜底这是我在多个插件里验证过的稳妥写法。3.2 版本号读取逻辑的落点选择为什么主逻辑留在 Dart这里有一个设计决策值得展开讲phntmxyz_bump_version_sidekick_plugin 的核心价值在于维护“版本号单一真源”所以解析pubspec.yaml、自增、回写这些操作全部放在 Dart 侧完成绝对不能挪到 ArkTS 里。原因是多端的版本策略要一致Android 和 iOS 走的是同一套自增逻辑鸿蒙也必须走同一套否则会出现 Android 的 build 号比鸿蒙高、或者反过来的情况。版本号的“计算规则”只能有一份实现放在 Dart 里由插件的 CLI 或 Dart API 统一执行鸿蒙侧只需要在 Dart 需要的时候提供当前包的版本信息即可。所以我在适配时刻意把 ArkTS 插件保持“薄”它只做两件事——在通道上响应调用以及从鸿蒙应用配置里读取versionName和versionCode返回给 Dart。真实场景中这个信息可以这样拿import { bundleManager } from kit.AbilityKit; let bundleInfo bundleManager.getBundleInfoForSelf( bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION ); let versionName bundleInfo.versionName; let versionCode bundleInfo.versionCode;这段逻辑在验证应用实际安装到机器/模拟器上的版本时非常有用。CI 上如果只是构建产物分析可以退化为直接读构建输出的app.json5产物文件但真机验证场景推荐用 bundleManager 拿运行时真实值。3.3 构建期版本同步让鸿蒙包拿到同一个版本号适配插件只是第一步真正的发版自动化还差一个环节鸿蒙工程自身的版本元数据怎么和pubspec.yaml保持一致。鸿蒙应用的版本号写在AppScope/app.json5里典型结构如下{ app: { bundleName: com.example.app, versionCode: 47, versionName: 1.7.0 } }如果这个文件写死那 CI 每次构建鸿蒙包时都会用同一个版本号商店后台上传第二个包就会撞版本。我采用的方案是在 bump 阶段生成的版本号结果同步注入到鸿蒙工程配置里。方案 A简单粗暴但稳定在 CI 脚本里用 bump 阶段输出的版本号替换app.json5里的字段。注意 JSON5 支持注释且文件不严格是标准 JSON用sed直接替换容易出错我建议用一个小脚本做 key 级替换// update_harmony_version.json5 { app: { // ... } }方案 B更优雅在hvigorfile.ts里加一个构建前钩子读取pubspec.yaml的版本号再写回构建上下文的 app 配置。这样每次 hvigor 构建都会自动拿到最新版本不需要额外脚本。我实际用的是方案 B因为 CI 少一步就是少一个故障点。但无论哪种方案有一个原则必须坚持versionCode 必须严格递增。建议直接复用 Android 的 versionCode 生成逻辑让鸿蒙和 Android 共用同一个构建号不要让它们各自维护一份递增序列。否则等位数、区间错开策略设计不当迟早会在上架时发现鸿蒙的 code 已经撞了。4. CI/CD 流水线中的鸿蒙构建段从编译到自动发版4.1 在 GitLab CI 里加一段鸿蒙打包任务插件鸿蒙化适配完成之后剩下的工作就是把它编排进流水线。我这里以 GitLab CI 为例思路对 Jenkins、GitHub Actions 同样适用。整条发版流水线的基本流程是触发发版任务手动或定时。执行 bump 任务读取当前版本、按策略自增、回写pubspec.yaml、生成 changelog、打 git tag。执行 Android/iOS 构建任务各自产出安装包。执行鸿蒙构建任务产出 HAP 包。汇总产物上传到内部仓库或分发平台。鸿蒙构建任务可以写成这样harmony_build: stage: package image: your-registry/harmony-build-image:v1 variables: OHPM_REGISTRY: https://your-ohpm-registry DEVECO_SDK_HOME: /opt/sdk/harmonyos FLUTTER_HOME: /opt/flutter_ohos script: - ohpm install --all - hvigorw assembleHap --mode module -p productdefault artifacts: paths: - ohos/entry/build/default/outputs/default/*.hap这里有几个细节image必须预装鸿蒙工具链包括 ohpm、hvigorw、JDK、Node以及对应版本的 Flutter 鸿蒙 SDK。不要在 CI 里临时下载下载源不稳定翻车概率极高。bump 任务要先行这样鸿蒙构建任务拉到的代码里pubspec.yaml已经是最新版本号后续版本注入才有依据。建议在 bump 任务里把新的版本号写成流水线变量如BUILD_VERSION后续所有平台任务都消费这个变量而不是自己再去解析一遍。4.2 签名配置与 CI 证书注入最容易被卡住的环节鸿蒙的 release 构建必须有签名文件否则 HAP 装不上真机也没法上架。签名涉及三样东西.p12证书文件、.cer证书文件、.p7bProfile 文件。这些文件本质上和密钥一样敏感绝对不要直接提交进 Git 仓库。我在 CI 里的做法是把签名文件以 Base64 编码存在 CI 项目的受保护变量里。流水线任务启动时先解码到临时目录再通过build-profile.json5的signingConfigs引用。任务结束后清理临时目录。build-profile.json5里大致是这个结构具体字段以 DevEco 生成的结果为准{ signingConfigs: [ { name: release_sign, type: HarmonyOS, material: { certpath: /tmp/harmony-sign/release.cer, storePassword: $SIGN_STORE_PASSWORD, keyAlias: debugKey, keyPassword: $SIGN_KEY_PASSWORD, profile: /tmp/harmony-sign/release.p7b, signAlg: SHA256withECDSA, storeFile: /tmp/harmony-sign/release.p12 } } ], products: [ { name: default, signingConfig: release_sign } ] }这个环节真的有无数团队卡住有人把密码写死在文件里有人把.p12传到了公开仓库还有人因为 profile 文件绑定的包名和实际工程不一致而构建失败。我的经验是先在本地完整跑通一次签名 release 构建确认所有路径、密码、别名都对再带着这组参数去配置 CI。不要在 CI 里边试边调这样只会浪费时间看一堆抽象报错。4.3 发版参数的完整调参清单把流水线编排完之后我把常用参数整理成了一张表方便团队里其他同事查阅参数作用建议值VERSION_STRATEGY决定自增维度patch/minor/major/buildBUILD_VERSIONbump 后的完整版本号由 bump 任务输出统一注入OHPM_REGISTRYohpm 包源内网镜像优先避免公网抖动HVIGORW_PROFILE构建 profile 名称与 build-profile 一致SIGNING_CONFIG签名配置名release_signDEVECO_SDK_HOME鸿蒙 SDK 路径镜像内固定路径FLUTTER_HOMEFlutter 鸿蒙 SDK 路径fvm 管理的分支路径这些参数有一个共同点尽量在流水线模板里定义而不是散落在各个脚本中。否则换一套环境、换一个镜像所有脚本都得跟着改一遍维护成本直线上升。5. 适配中的真实踩坑与排查链路鸿蒙化适配很少能一次跑通我在这个插件上至少踩过四类坑。下面按“现象 → 排查 → 解决”的链路记录方便你对号入座。5.1 症状一通道调用抛 MissingPluginException现象是Dart 侧调用getAppVersionInfo后总是抛出MissingPluginException看起来像是通道没注册成功。但工程可以正常编译鸿蒙应用也能启动。排查链路先确认ohos/模块是否真的被 gradle/hvigor 纳入构建。我遇到过ohos目录建好了但宿主工程的oh-package.json5没声明依赖导致这个模块根本没参与编译。再查插件注册。Flutter 鸿蒙分支在插件加载时会生成一个注册表如果插件的pubspec.yaml里没有增加对应的 ohos 平台声明或者声明里的类和 ArkTS 实际导出的类名不一致注册表就会跳过它。最后在onAttachedToEngine里加调试日志确认通道注册代码是否执行到。解决思路是在EntryAbility.ets的onCreate里临时打印插件加载列表。如果列表里没有BumpVersionSidekickPlugin基本可以断定是注册路径的问题不要浪费时间查通道代码。5.2 症状二鸿蒙包上的版本号纹丝不动现象是CI 跑了几次pubspec.yaml里版本号确实递增了但打出来的 HAP 包安装到真机上系统设置里显示的版本号始终是旧的。排查链路先确认app.json5是否真的被更新。很多人的直觉是改app.json5就行但鸿蒙构建的最终元数据有可能是从build-profile.json5或 hvigor 缓存合并而来。如果只改了源文件、没清理缓存产物里的版本号还是老的。确认 hvigor 构建缓存是否失效。我遇到过一次因为缓存未清理构建系统把上一次的app.json5快照直接带进了产物。解决办法是构建前加rm -rf ohos/.hvigor ohos/.idea ohos/build或者调用hvigorw --daemon之外还加上clean子命令。检查注入脚本的 key 路径。app.json5里版本号字段在app对象下如果脚本里的路径写成了app.module.versionName自然永远替换不到。这个症状很隐蔽因为流水线本身是绿着过的。我的建议是在流水线产物归档时单独加一个步骤打印产物里的app.json5版本字段用输出日志昭告天下“这次包的版本到底是多少”。否则问题可能到上架前才发现。5.3 症状三流水线里 git tag 打不上去现象是bump 任务生成了 tag但 push 的时候直接失败流水线停在最后一步。排查链路最常见的原因是 CI 拉代码默认是浅克隆git fetch只拉了一个浅层 commit后续生成 tag、回推的时候就缺少历史上下文。一个是权限问题CI 使用的 Git 账号没有 push tag 的权限或者 tag 已经存在、推送失败。另一个容易忽略的点是git 用户信息没配git config user.nameuser.email为空导致 tag 对象无法生成。解决起来也不复杂git fetch --unshallow origin git config user.name ci-bot git config user.email ci-botexample.com git tag -a v$BUILD_VERSION -m release $BUILD_VERSION git push origin v$BUILD_VERSION注意 tag 的名称规范要统一。我用的是v 完整版本号含 build number的格式比如v1.7.047这样和商店上架的版本号能够一一对应排查问题时一眼就能关联上。5.4 症状四热更和灰度场景下构建号不够用现象不是故障而是策略问题。鸿蒙和 Android 一样构建号必须单调递增。如果你同一个版本要出多个灰度包比如1.7.047灰度给 1% 用户热更后又出一个1.7.048给 5% 用户那构建号很快就会顶到整型上限。这类问题不是插件 bug而是发版策略没有提前设计。我的方案是把构建号的生成规则从“连续整数”改为“时间戳 批次号”的组合但前提是你的商店后台和灰度高台支持这种格式。鸿蒙的versionCode虽然是整型但只要你保证每次发布的 code 比上一次大中间留多少空档都无所谓的。所以 CI 里建议把 versionCode 生成规则统一封装成一个函数Android 和鸿蒙共用避免两边各想一套规则。6. 适配完成后的验收清单与可复制的方法论6.1 本地回归清单插件适配完成后我列了一张验收清单建议分别在真机和模拟器各过一遍检查项操作预期结果插件初始化启动应用查看日志无 FlutterPlugin 重复注册警告通道调用Dart 侧调用getAppVersionInfo返回鸿蒙应用真实版本号bump dry-run执行bump --dry-run输出版本变更 diff不落盘bump 正式执行执行完整 bumppubspec.yaml版本更新tag 生成鸿蒙版本同步构建 HAP 后检查app.json5产物版本号和 bump 结果一致CI 全流程跑完整流水线三端产物版本号统一构建全绿这套清单看起来简单但每一项都能揪出实际工程里的隐藏问题。比如“通道调用返回真实版本号”这一步如果发现返回的是0.0.0说明 bundleManager 的调用时机不对如果发现根本没返回那就要回头查通道注册。6.2 一套可以复制到其他插件的鸿蒙化方法论做完这个项目我最大的体会是鸿蒙化适配难的不是技术而是工程方法。Flutter 生态里大量插件都是纯 Dart 逻辑 平台薄壳的结构这类插件鸿蒙化其实只需要三件事在工程里补齐ohos/模块并按 flutter_ohos 的插件注册规范接好生命周期。把依赖原生能力的部分收窄到最小的通道接口能用 Dart 解决的就不要动 ArkTS。在 CI 里为鸿蒙单独配置一套构建、签名、版本注入的流程同时保持版本号来源唯一。以后你遇到其他插件比如日志上报、崩溃采集、埋点 SDK 这类的 Flutter 插件如果它本身在 Android/iOS 上只是薄薄一层平台通道那照着这套思路走一遍就能完成鸿蒙化。真正的硬骨头是那些重度依赖原生 UI 组件的插件比如地图、相机、WebView这些就得等官方或大厂适配了靠个人项目去啃成本太高。我在实际维护中还有一个小技巧不要等插件官方去适配鸿蒙。如果插件社区活跃可以直接在 issue 里问维护者的鸿蒙计划如果长期没人理那就自己做本地 patch并尽可能把 patch 内容沉淀成 CI 里的一个独立脚本方便升级插件后重新应用。phntmxyz_bump_version_sidekick_plugin 这个插件虽然名字冷门但它的适配价值在于版本管理是每个团队上 CI/CD 都绕不开的底座能力底座不稳上面的自动化全都不稳。至少在我们团队这套流程上线后发版从人工半小时变成了流水线一键多端版本号再也不靠人肉同步了。就算以后鸿蒙 SDK 升级、插件版本升级需要重新适配也有了可复现的路线图。