☰
Flutter鸿蒙适配的版本治理:从约定式提交到CI/CD落地
2026/10/21 22:52:33 网站建设 项目流程

鸿蒙适配做了一半就被版本号卡住,是时候聊聊 Flutter 组件的版本治理了

如果你所在的组件团队最近把 Flutter 组件往鸿蒙 HarmonyOS 上迁移,有一件事早晚会遇到——版本号不够用了。

我说的不是“从 1.0.0 升到 1.1.0”这种表面现象,而是整个组件仓库的发布流程突然变得不可信任:有人手动改版本号、提交信息随意写、CI 打出来的 tag 和 pubspec 里的 version 对不上。当组件需要同时服务 Android、iOS,又加上 HarmonyOS 这一端时,任何一次版本错位都会让下游 App 在集成时陷入“不知道拉的对不对、能不能回滚”的困境。

这篇文章不是我讲怎么把 Flutter 代码改写成鸿蒙的原生实现,而是聚焦在更隐蔽但更致命的一层:版本治理与 CI/CD 质量治理。整套方案以 Conventional Commits(约定式提交)为数据基础,把提交信息转化为版本号、CHANGELOG 和发布状态,最后落到一套三阶段的 CI/CD 流水线上。适合正在负责 Flutter 组件库或鸿蒙适配工程的开发者阅读,不管你是 5 人小团队还是几十人的组件组,这套思路都能直接落地。

1. 鸿蒙适配后的组件版本困境:为什么“能用”不等于“可交付”

1.1 三端并行时,版本号不再是随便填的元数据

Flutter 组件做鸿蒙适配,和普通的多平台支持不太一样。普通的多平台,比如 iOS 和 Android,底层虽然实现不同,但 Flutter 官方提供的 engine 已经统一了平台通道,你写了一遍 Dart 层,剩下的事情大部分由框架兜底。鸿蒙这一端不是这样,至少很长一段时间内,组件需要同时维护 Dart API 层、Android 实现、iOS 实现,以及鸿蒙的 ArkTS 实现路径。

这时候组件内部会出现大量平台能力分支:比如某个能力在 Android 上走 PlatformChannel,在鸿蒙上走系统侧封装的另一种通道;同一个 API 在三个平台上的实现完成度、性能表现和已知限制都可能不一样。于是版本号承载的信息量就变了。

以前版本号只需要回答一个问题:这包是新的还是旧的?适配鸿蒙之后,版本号还得回答更多问题:

  • 这个版本适配过鸿蒙吗?还是只在 Android 上验证过?
  • 我拉到的 2.1.0 是纯 Flutter 版本,还是包含鸿蒙实现包的版本?
  • 鸿蒙端的实现落后了,那 Android 修了一个 bug,我应不应该跟着升级?

这些问题不解决,下游集成方只能靠猜。而猜,在移动端发版这种场景里,代价极高。

1.2 手写版本号的三宗罪:拍脑袋升级、tag 滞后、CHANGELOG 靠回忆

我见过不少团队,适配鸿蒙之前版本管理就很松散,适配鸿蒙之后彻底爆了。典型的症状有三个。

第一宗罪,拍脑袋升级。发布前想一下“这次改动大不大”,大就升 minor,小就升 patch。问题是“大不大”这种判断完全没有客观标准,不同人答案不一样。组件库用户一旦发现同一个版本号在不同时间拉到的内容不一样,信任就没了。

第二宗罪,tag 滞后或错位。pubspec.yaml 里写着 2.0.1,但 git tag 还停在 2.0.0。或者反过来,tag 打了 2.0.1,但 pubspec 忘改了。下游用户通过 pub 仓库拉到的版本和代码仓库里的 tag 对不上,问题定位时根本不知道线上跑的是哪一版代码。

第三宗罪,CHANGELOG 靠回忆。发版前临时翻 git log,看到哪条写哪条,甚至干脆不写。等用户来问“2.1.0 和 2.0.0 到底改了什么”的时候,没人能给出准确答案。

这些问题的根源都一样:版本号的产生过程没有数据依据,完全依赖人的记忆和临场判断。

1.3 约定式提交:让 git 日志变成唯一可信的版本数据源

Conventional Commits 本质上是一个提交信息格式规范,它规定提交信息必须包含type(scope): subject这样的结构。它的核心价值不在于格式好看,而在于让 git 历史变成机器可读的数据源。

有了规范化的提交信息,工具就能做三件事:根据提交类型判断版本号应该升 major、minor 还是 patch;自动生成 CHANGELOG;在出现问题时从某个版本号精确回溯到对应的提交集合。

这和语义化版本 2.0.0 是天然匹配的:feat提交对应 minor 版本,fix提交对应 patch 版本,带BREAKING CHANGE的提交对应 major 版本。这套映射关系虽然不是强制标准,但已经被整个开源生态验证过无数次,直接拿来用是最稳妥的。

我当时决定引入这套规范,不是因为它“正规”,而是因为在鸿蒙适配这种多端并行的复杂场景下,必须有一个不依赖人记忆的版本决策链路。

2. 约定式提交在组件仓库的落地:从规范文档变成硬性拦截

2.1 最小可用提交类型集:先让团队跑起来,再逐步严控

很多团队搞提交规范,一上来就列二十个类型,还强制要求每个提交必须写 scope、写 body,结果推行两周就崩了。我对这种“一步到位”的做法非常反对。

落地约定式提交,第一原则是做最小可用集。我建议只开放这些类型:

类型用途是否触发版本升级
feat新增功能或 API是,升 minor
fix修复缺陷是,升 patch
refactor重构但不改变行为否
perf性能优化否(可配置)
test补充或修改测试否
build/ci构建或流水线相关否
chore杂项维护否
docs文档更新否
revert回滚提交否

feat和fix是唯二影响版本号的类型,也是提交规范的核心。其他类型可以逐步加。scope也可以不强制,等团队适应了再要求。想一上来就做到尽善尽美的规范,往往活不过第一个月。

2.2 commitlint 与 husky 的组合配置:提交那一刻就开始检查

规范文档写得再漂亮,如果只在 reviewer 脑子里,基本等于没有。我强烈建议在提交入口就做硬性拦截,用的工具是 commitlint + husky。

安装依赖:

npm install -D @commitlint/cli @commitlint/config-conventional husky

然后是 commitlint 配置文件。这里有个细节:@commitlint/config-conventional默认允许的类型集比较大,我建议显式覆写,控制成自己团队的最小可用集。

// commitlint.config.js module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'feat', 'fix', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'docs', 'revert' ]], 'subject-case': [0], 'header-max-length': [1, 'always', 72] } };

配置文件的含义是:提交类型必须落在白名单里(报错级别);主题内容不限制大小写风格;标题最长 72 个字符,超过只会警告,不阻断提交。最后这条是特意放宽的,因为 Flutter 组件的提交信息里经常需要写平台相关信息,比如fix(ohos): 修复文件编码问题导致的乱码。

然后是 husky 的配置。用命令行快速添加提交信息检查钩子:

npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'

这样每次git commit时,commitlint 会读取提交信息并校验。校验不通过,提交直接失败。这个拦截的意义是:坏提交信息在进入 git 历史之前就被挡住,而不是等到发版时才去纠正。

2.3 squash merge 策略与老仓库的过渡:两个最容易踩的坑

落地过程中有两个坑特别容易踩,我在这里提前说清楚。

第一个坑是squash merge 策略。很多团队的 PR 合入方式是把整个 PR 压缩成一个提交(squash)。这个策略本身没问题,但如果你只要求 PR 标题符合规范,而不管 commits,那么 squash merge 之后,git 历史里那个最终提交的信息默认取自 PR 标题。如果开发者的个人提交信息全是wip、fix、111,那么 squash 后的信息也好不到哪去。

解决办法是约定:squash merge 时手动编辑提交信息,或者要求 PR 标题本身就是一个规范的 commit message。在组件仓库这种需要精确溯源的环境里,我建议两者都做。

第二个坑是老仓库的过渡。不要试图把历史提交全部重写,那会污染所有开发者的本地历史。我用的办法很简单:从某个时间点开始,创建一个“基线提交”,提交信息写chore: 建立约定式提交基线,然后要求所有后续新提交必须符合规范。老的历史不追究,只保证未来是干净的。

3. 自动化版本治理:release 脚本如何生成版本号和 CHANGELOG

3.1 从 commit 解析到版本决策:一条完整的数据流

提交规范落地之后,紧接着就要做版本自动化。我设计的 release 数据流分四步:

  1. 收集git log <lastTag>..HEAD范围内所有提交信息;
  2. 逐条解析提交的 type,判定是否存在feat、fix、BREAKING CHANGE;
  3. 结合当前版本号,按语义化版本规则推导出下一个版本号;
  4. 更新版本文件、生成 CHANGELOG、打 tag、触发发布。

下面是一个简化的解析流程示例,用于说明概念:

当前版本: 1.4.2 区间内提交: feat(ohos): 新增跨端文件监听能力 fix(android): 修复偶现的内存泄漏 docs: 更新 README 解析结果: feat 存在 -> minor 升级 有 fix 但不影响 minor -> 不额外追加 无 BREAKING CHANGE -> 不升 major 下一版本: 1.5.0

这套逻辑的核心是:版本号的每一次变化,都能在 git 历史里找到对应的提交依据。任何人问“为什么这个版本是 1.5.0 而不是 1.4.3”,答案都是确定的。

3.2 Flutter 组件版本的双数据源困境:pubspec 与 tag 必须一致

做 Flutter 组件有个特别棘手的问题:版本号存在于多个地方。

pubspec.yaml 里有version:字段,组件发布平台读的是这个;git tag 是发布记录的标识;如果是鸿蒙侧的产物,还得在鸿蒙的包配置里维护一份对应的版本。三个地方一旦不一致,就会出现我在 1.2 节说的那种错位。

我的做法是:pubspec.yaml 是唯一的事实源,git tag 和鸿蒙侧版本都通过脚本从 pubspec.yaml 同步生成。任何手动改版本号的行为都必须禁止,改版只允许通过 release 脚本完成。

在实际操作中,我会在 CI 里加一步“版本一致性校验”:检查当前 HEAD 对应的 tag 是否等于 pubspec.yaml 里的 version;如果不一致,直接阻断发布。这一步成本极低,但能挡住最危险的发版事故。

3.3 轻量 release 脚本实现:不依赖重工具也能跑通

很多 JS 生态的团队会直接上 semantic-release 这种重量级工具。但 Flutter 组件库的场景有点不一样:版本号要落在 pubspec.yaml,还可能涉及鸿蒙侧的配置文件,用一个自维护的轻量脚本反而更可控。

下面是我用 Python 写的一个版本推导脚本核心逻辑,可以直接拿去改:

import re import subprocess from pathlib import Path def get_since_tag(): tags = subprocess.run( ["git", "tag", "--list", "v*"], capture_output=True, text=True ).stdout.strip().split("\n") tags = [t for t in tags if t] if not tags: return None # 取最大的语义化版本 tag,这里假设 tag 格式为 vX.Y.Z return sorted(tags, key=lambda t: [int(x) for x in t[1:].split(".")])[-1] def parse_commits_since(tag): if not tag: return [] log = subprocess.run( ["git", "log", f"{tag}..HEAD", "--format=%s%n%b---"], capture_output=True, text=True ).stdout return [c for c in log.split("---") if c.strip()] def next_version(current, commits): has_feat = any(re.search(r"^(feat|feat\([^)]+\)):", c) for c in commits) has_break = any("BREAKING CHANGE" in c for c in commits) major, minor, patch = map(int, current.split(".")) if has_break: return f"{major + 1}.0.0" if has_feat: return f"{major}.{minor + 1}.0" return f"{major}.{minor}.{patch + 1}" # 示意调用 commits = parse_commits_since(get_since_tag()) print(next_version("1.4.2", commits))

这个脚本省略了写 pubspec.yaml、生成 CHANGELOG、打 tag 的细节,但核心决策逻辑就这几行。你可以根据自己仓库的实际情况补充。我的建议是:不要一上来就追求工具的大而全,先把“版本号怎么变”这个核心问题用代码固定下来,其他环节逐步补。

3.4 预发布通道设计:鸿蒙适配进度不一致时的妥协方案

组件适配鸿蒙的过程中,最尴尬的阶段是:Android 和 iOS 已经很稳定,鸿蒙端才刚跑通基础功能。这时候如果所有改动都进了同一个版本流,下游集成方要么被迫升级到包含不成熟鸿蒙实现的版本,要么因为版本号没变而拉不到鸿蒙的修复。

我的解法是引入预发布通道。对于还在适配中的组件,把版本号定为2.2.0-ohos.1这种带预发布标识的格式。语义化版本规范里,-ohos.1是 prerelease 部分,它的排序语义是“低于正式版 2.2.0”,但又能区分出鸿蒙适配相关的迭代。

这样处理有三个好处:

  • 稳定版使用方不受影响,只有显式声明使用预发布版本的用户才会拉到鸿蒙适配中的版本;
  • 鸿蒙适配团队可以高频发版,不用等三端全部就绪;
  • 一旦鸿蒙端实现稳定,可以把2.2.0-ohos.1转正为2.2.0,版本历史清晰可追溯。

4. CI/CD 质量治理架构:三阶段门禁把错误拦在合并前

4.1 三阶段流水线总览:校验、构建、发布

版本治理如果只停留在“生成版本号”这一步,那还只是半自动。真正让这套体系跑起来的是 CI/CD 流水线。我把它设计成三个阶段,每个阶段有明确的触发条件和失败处理策略。

阶段触发条件核心任务失败处理
校验阶段PR 创建 / pushcommitlint 检查、代码格式、静态分析阻断合并
构建测试阶段main 分支合并单元测试、三端编译矩阵、版本一致性校验阻断后续流程
发布阶段手动触发或版本校验通过推导版本、更新 pubspec、生成 CHANGELOG、打 tag、发布保留产物并通知

第一个阶段是给开发者的即时反馈,让它越轻越快越好;第三个阶段是发布动作,必须带上完整的状态记录和产物归档。中间那个阶段最容易被人忽略,但它恰恰是质量治理的核心:只有三端都能构建出产物且版本一致性校验通过,才有资格进入发布流程。

4.2 版本一致性检查脚本:一个字符差都算失败

我在 CI 里加的版本一致性检查,脚本逻辑非常简单,但救过我好几次。

#!/usr/bin/env bash set -euo pipefail # 从 pubspec.yaml 提取 version 字段 PUBSPEC_VERSION=$(grep -E "^version:" pubspec.yaml | awk '{print $2}') # 获取当前 HEAD 最近的 tag,这里假设 tag 格式为 vX.Y.Z CURRENT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ "$CURRENT_TAG" != "v$PUBSPEC_VERSION" ]; then echo "版本不一致!" echo "pubspec.yaml version: $PUBSPEC_VERSION" echo "当前 git tag: $CURRENT_TAG" exit 1 fi echo "版本一致性校验通过: $PUBSPEC_VERSION"

这个脚本看起来简单到有点“不好意思”的地步,但它解决了实际痛点:很多人改完 pubspec 就忘了打 tag,或者打完 tag 忘了改 pubspec。只要发布流程强制跑这个脚本,这种低级错误就不可能流入用户手里。

如果你同时维护鸿蒙侧的版本配置,脚本里再加一步:读取鸿蒙配置文件里的版本号,和 pubspec.yaml 的 version 比对,不一致同样阻断。

4.3 跨端构建矩阵与产物可追溯:鸿蒙适配必须过这一关

组件适配到鸿蒙后,构建矩阵从两列变成了三列。我在 CI 里会强制跑三个构建任务:flutter 构建 Android 产物、flutter 构建 iOS 产物、鸿蒙侧构建产物。任何一个构建任务失败,整个流水线直接标红。

比构建本身更重要的是产物可追溯性。每个发布产物必须能回答四个问题:

  • 这个产物对应哪个 git commit?
  • 对应的版本号是多少?
  • 三端构建产物分别生成了没有?
  • 发布到平台后,Artifact 的哈希值是多少?

我为每个发布记录生成一个清单,内容包括版本号、commit hash、构建时间、三端产物的文件名和哈希值、以及 CHANGELOG 片段。这个清单会被归档,后续任何线上问题都能靠它快速定位。

4.4 质量治理的边界:别把流程做成负担

最后想聊一点体会:质量治理很容易做过头。我见过有些团队,流水线里塞了几十个检查项,每次提交要跑二十分钟,结果开发者开始想办法绕过流程。

我的原则是:能让脚本自动判断的,才放进流水线;需要人主观判断的,留在 code review 环节。commitlint 检查提交格式,自动做;代码风格是否合理,交给评审人去判断。版本一致性是不是一致,脚本能查;API 设计是不是合理,脚本查不了。把这两类问题分开,流程才不会变成负担。

5. 实战翻车复盘:三次发布事故教我的事

只讲理论很容易,真正让人长经验的是事故。下面三个都是我实际遇到过的发布事故,虽然细节做了脱敏处理,但教训是实打实的。

5.1 事故一:tag 冲突导致发布失败,远程分支保护比想象中重要

第一次跑通自动化 release 时,我遇到了一个非常低级的错误:某同事在旧流程下手动打了v2.0.1的 tag,但没推送成功;新流程的 release 脚本也尝试推v2.0.1,结果推送时发现远程已经有了这个 tag,但指向的是另一个 commit,直接冲突。

问题出在“手动 tag”和“脚本 tag”两套操作并存。如果团队里还有人习惯顺手打 tag,自动化脚本再完善也会被绕过。后来我做了两件事:一是发布流程里先执行git ls-remote --tags检查目标 tag 是否已存在,存在就立即失败;二是把推送 tag 的权限收归到发布脚本,不允许手工操作远程 tag。

5.2 事故二:Android 修完上线,鸿蒙端还在旧 API,下游直接崩溃

有一段时间我们自己内部有个组件的 Android 实现出现崩溃问题,修复后正常发布了1.8.0,但这个修复只动了 Android 端的代码,鸿蒙端和 iOS 端没变。下游一个做鸿蒙应用集成的团队拉到了新版本,运行时发现某个平台通道方法在鸿蒙端根本不存在,启动直接异常。

这个问题从版本号上看完全没错:fix提交升 patch 到1.8.0,合情合理。但问题出在三端实现进度不一致。组件发布前,我并没有确认这个版本在三端都可用。

后来我在发布阶段加了一个硬性条件:release 版本的发布必须通过三端构建矩阵,并且每个端至少要跑一遍核心冒烟测试。如果某一端的实现还没跟上,版本只能走预发布通道,不能进 stable。

5.3 事故三:PR 标题全合规,提交信息全乱,squash 后一切归零

第三个事故和 squash merge 有关。某个大功能分支里,开发者的提交信息全是碎片化的fix、wip,但 PR 标题写得非常规范。squash merge 之后,git 历史里只留下了一个漂亮但毫无细节的提交信息。

问题是这个 PR 里实际上包含了一个破坏性 API 变更,而破坏性变更只在正文里,没有体现在最终提交信息中。release 脚本解析这个提交时没有发现BREAKING CHANGE,版本号从1.9.0升到了1.10.0,而不是2.0.0。

这个事故的直接后果是下游用户升级后发现 API 变了,完全没收到 breaking 警告。现在我对 PR 合入的要求是:squash 提交的信息必须有准确的 type 和正文说明,破坏性变更必须显式写在提交信息里,CI 会检查并拒绝没有明确 breaking 标注的破坏性变更提示。

5.4 复盘清单:发布前的七个自检问题

经历了这些事故后,我自己整理了一个发布前自检清单,每次 release 前都会过一遍:

  1. pubspec.yaml 的 version 和最新 tag 是否一致?
  2. 从刚才 tag 到 HEAD 的提交信息,是否全部符合约定式提交规范?
  3. 是否存在feat提交但版本没有升 minor?
  4. 是否存在BREAKING CHANGE但版本没有升 major?
  5. 三端构建产物是否全部生成并归档?
  6. CHANGELOG 是否已更新且包含本次所有用户可见变更?
  7. 如果鸿蒙端实现存在未完成项,是否已明确走预发布通道?

这七个问题全是脚本能验证的,没有一个是拍脑袋判断的。这也是我对“质量治理”这四个字的全部理解。

在我维护的组件仓库里,现在已经实现了“每次发版不需要任何人商量版本号定多少”的状态。版本号是从 git 历史的提交信息里长出来的,CHANGELOG 是自动生成的,发布流程是脚本可控的。我想给你留的最后一个小技巧是:不要一开始就铺开整套体系,先从 commitlint 和 husky 做起,等团队习惯了规范化的提交信息,再一步步接入版本自动化和 CI/CD 门禁。基础设施是一点点长出来的,不是一次性建成的。

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

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

立即咨询