最近团队在忙一件让所有 Flutter 基建同学都头大的事:把组件库体系从“一套 Android/iOS 走天下”改造成“Android、iOS、鸿蒙三端统一供给”。组件本身改起来倒还好,真正卡住进度的,是依赖分发和版本治理。同一个组件在 Android 上能编过,到了鸿蒙工程里却拉不到正确的包;开发同学急着联调,只能到处手工拷贝,出错以后还没法追溯。于是就有了 smartpub 这个项目。
smartpub 定位很直接:一套面向 Flutter 组件和鸿蒙 HarmonyOS 制品的私有化包管理仓库,负责组件发布、依赖解析、性能缓存和全链路审计。它解决的不只是“下载快一点”的问题,而是把整个组件生态的依赖来源、版本口径和交付质量统一收口。适合正在做鸿蒙适配的 Flutter 组件库团队、移动基础架构团队,以及被依赖冲突和版本漂移折腾到头疼的研发组织参考。这篇实战总结,我会从为什么需要它、架构怎么搭、客户端怎么接、依赖怎么治理,以及上线后踩过的坑,完整过一遍。
1. 立项前先想明白:鸿蒙适配为什么把包管理问题推到了台前
1.1 鸿蒙适配带来的依赖链变化
Flutter 在鸿蒙上的落地,通常走的是 OpenHarmony SDK 适配层这条路。也就是说,Flutter engine 和第三方插件都要产出能在鸿蒙工程里链接的形态,最常见的产物是 HAR,也就是鸿蒙静态共享包,以及 HSP,动态共享包。这和 Android 的 AAR、iOS 的 Framework 并不是一套体系,原来的 Flutter 依赖分发模型天然覆盖不到。
结果就是依赖链从“一条线”变成了“一个网”:组件不再只是 Dart 代码,还带上了原生 so、ArkTS 接口、资源文件,以及对应的构建产物。任何一个环节只要处于“半适配”状态,集成时就会报找不到符号、架构不匹配、资源冲突这一类问题。依赖源作为所有这些产物的统一入口,必须被重新设计。
公网源的局限在项目进入规模化之后会变得很致命。它只能覆盖标准 Dart 包,承载不了带厂商私有 SDK 接口的内部组件;版本一旦发布,后面的更新记录只有 ChangeLog,没有审计信息;团队大了以后,谁在什么时间用了哪个版本,完全没有闭环。我们出现过很典型的现场:某基础包连续发布了两个小版本,A 组在周一拉到 2.4.1,B 组在周五拉到 2.4.2,两边各自联调都没问题,到了集成环境却因为行为差异吵了一周。这个锅不能全怪组件质量,根子在依赖分发没有统一入口。
1.2 组件库的“双轨”发布模型
我们定下来的第一个原则是“内外双轨”。外部第三方包可以照常通过上游源代理缓存,但内部组件必须走私有发布链路,并且所有进入项目的依赖都要经过统一解析。双轨模型的长处是职责清晰:上游源负责“有没有这个包”,私有仓库负责“这个包能不能用”。内部组件未经审批不能推送到任何环境,第三方包的获取也不能绕过缓存和校验。
这样设计还有一个隐藏收益——依赖来源可审计。每次pub get都能在服务端留下记录,当出现安全通告时,只需要查“哪些工程引用了这个版本”就能精准定位,而不是靠群聊喊话。后面做依赖治理时,这个模型帮了大忙。
1.3 为什么自研 smartpub 而不是直接套用通用制品仓库
可能有人会问:直接用现成的通用制品仓库不就行了?我们评估过市面上的通用方案。它们对 Maven、Docker、npm 这类生态确实很成熟,但到了 pub 生态,问题就出来了:pub 的版本约束解析不是简单的“按路径取文件”,它依赖 metadata 中完整的依赖关系图。通用制品仓库很难感知 pubspec 里的 constraints、sdk 约束、dependency_overrides这些语义,强行用会有不少边缘情况要自己兜。
自建 smartpub 的另一个理由是性能可控。它只需要专注 Flutter 和鸿蒙相关制品,路由、缓存、权限模型都可以按我们的使用场景定制。它不是一个大而全的平台,而是一个“贴近组件库真实工作流”的轻量服务。后面所有优化都建立在这个取舍上。
2. 私有化仓库架构设计:smartpub 的存储、缓存与权限模型
2.1 请求链路一次说清
一次pub get在 smartpub 侧的处理路径是这样的:客户端首先请求包版本列表接口,smartpub API 网关从本地元数据库返回版本信息;如果本地没有或者缓存过期,网关会去上游源按协议拉取,并把结果写入本地。真正下载包文件时,网关会先检查对象存储,命中则直接走内部带宽,未命中再去上游拿文件,同时做哈希校验。
这个链路里有三个同步设计需要注意。第一,所有需要回源的请求都带短期锁,避免同一个冷门包被十个并发请求同时回源打爆上游。第二,包文件一旦写入永久缓存,除非手动失效,否则不再轮询上游,因为文件是不可变对象,哈希对上就永远能用。第三,版本列表接口设置短周期失效,兼顾新鲜度和性能。这是架构里最容易想当然的地方,很多人会把“缓存文件”和“缓存元数据”混在一起,最后要么版本更新不及时,要么把上游请求打得激增。
2.2 元数据模型:不只是存一份包
数据库设计时,最核心的一张表是版本表。字段需要包含:包名、版本号、publish 时间、内容哈希、依赖关系 JSON、SDK 约束、发布人。依赖关系单独抽成一张表,而不是字段里塞 JSON,是因为要支持“谁依赖了我”这种反向查询。没有它,安全通告之后你要人工在所有 pubspec 里 grep;有了它,一条 SQL 就能列出所有受影响工程。
发布包的文件内容不落数据库,放对象存储。下载时返回的地址必须带上签名,有效期不能太长。文件写入前,服务端会校验 sha256,并和 metadata 里登记的一致才确认发布成功,这能识别出上传过程中损坏的包。我们还额外存了一份“发布原始包”的冷备份,防止误删或恶意覆盖后彻底丢失。
2.3 缓存与回源策略:缺什么补什么
内部包永远优先。无论上游是否发布了同名包,smartpub 只认本地仓库里经过审批的版本。第三方包则走代理缓存,策略上分两层:版本列表短缓存,包文件长缓存。版本列表是动态的,上游可能随时更新,给它一个 60 秒的 TTL 比较合适,代价可以忽略。包文件是不可变的,只要哈希对得上,缓存多久都不怕,所以直接永久保存。
还有一个很实用的细节:回源采用“缺什么补什么”而不是整库同步。定时任务每天拉取上游版本列表增量,但不会把每个版本的包文件都搬回本地。只有等到有人实际请求某个文件时才回源拉取。这样冷门包不会浪费磁盘,热门包又总能命中。实测下来,一套两百多个依赖的工程,首次拉取后第二次pub get基本不需要落到上游。
2.4 权限与安全模型:最小权限是底线
仓库按环境划分:proxy 源代理第三方包,internal-stable 和 internal-beta 分别对应稳定版和灰度版。发布的权限按包名前缀授权,比如核心网络库只能由某个特定小组发布。CI 账号用短期 token,不允许用开发者个人密码。拉取侧权限相对宽松,但所有请求都有审计记录。
这里有个经验:token 的作用域不要图省事全开。我们早期为了配置简单,给所有 CI 同一个全权限 token,后来发现只要某个流水线脚本泄露,整个仓库面临风险。改成最小权限后,单个包仓库的泄露影响面被压缩到可控范围内。另外,所有管理操作都建议走二次确认,比如某个版本要执行删除,必须由负责人和仓库管理员分别确认,避免手滑。
3. 动手落地:Flutter 组件接入 smartpub 的完整流程
3.1 客户端的源配置:三分钟切到私有仓库
在 Flutter 工程侧,连接 smartpub 其实就是在pub get前指定源地址。最直接的方式是设置环境变量:
export PUB_HOSTED_URL=https://smartpub.internal.example.com export PUB_CACHE=/workspace/shared-cache建议把这种配置固化在工程根目录的.pubrc里,不同环境的仓库地址写在不同 CI 变量中,而不是硬编码到 pubspec。这样本地调试、测试环境、生产环境切换才不会踩坑。如果团队用统一的开发容器,也可以把环境变量写进容器初始化脚本,让每个成员拿到一致的环境。
要注意,共享PUB_CACHE时很容易出现“缓存中毒”。两个不同权限的工程共用同一个缓存目录,A 工程拉到的私有包可能被 B 工程直接复用,但 B 工程其实没有访问权限。我们的做法是 CI 上每个工程用自己的缓存目录,或者至少用 token 区分缓存 key。本地开发则建议不共享,这个目录坏了重建的成本比排查一次缓存问题低得多。
3.2 pubspec 配置与鸿蒙产物联合解析
在 pubspec 中引用内部组件和普通第三方包没有区别,只需要保证名称和版本约束正确:
name: demo_app environment: sdk: ^3.0.0 dependencies: flutter: sdk: flutter core_network: ^2.1.0 core_ui: ^1.8.2真正需要额外处理的是鸿蒙原生产物的联合解析。内部组件发布时,smartpub 需要额外登记一个x-ohos-modules字段,用来描述该版本对应 HAR 的包名、版本和产物哈希。这样鸿蒙工程在集成时,不仅可以拿到 Flutter 侧的 Dart 包,还能直接从私有仓库拉取对应的 HAR 模块,不会出现“Dart 代码已经更新了,但原生 so 还是旧版”的错位。
实现上,pub 协议本身允许在 metadata 里扩展自定义字段。兼容性经过验证,老版本客户端会忽略这些字段,新版本客户端会用它做二次校验。这里要特别提醒:HAR 产物要和 Dart 包放在同一个版本包里,不要分两条发布链路。分开发很容易出现版本号对不上,排查成本成倍增加。
3.3 发布与校验流水线:让坏包死在门外
内部组件的发布流程不能手工执行,必须在流水线里经过构建、扫描、人工审批三关。我们用的简化流程如下:
smartpub publish core_network --version 2.1.0 \ --archive dist/core_network.zip \ --har dist/core_network.har \ --release-notes "fix: 修复弱网重试失效问题"发布前流水线会依次执行:单元测试、鸿蒙真机编译冒烟、许可证扫描、漏洞扫描。只有全部通过,包才会进入 internal-beta;稳定版的发布额外要求相关负责人在平台上确认。这些步骤看起来麻烦,但能避免“发上去的包在鸿蒙工程里编不过”这种低级事故。
发布之后还有一步容易被忽略:通知。smartpub 会在新版本进入稳定仓库时,向已引用该组件的工程 owner 推送一条变更摘要。这个机制看起来简单,却让团队里每个人都对版本变更保持敏感,没人再等到集成报错才去查谁发了什么。
3.4 性能调优实测:从 40 秒到 4 秒
调整前,一个中等规模 Flutter 工程更新依赖需要 40 秒左右,其中大量时间花在上游源请求和重复回源上。smartpub 上线后,我们把pub get压缩到 4 秒内。主要做了四件事:第一,pub 客户端保持连接复用,减少 TLS 握手开销;第二,版本列表接口加解析缓存,相同约束的请求直接返回结果;第三,组件依赖元数据的响应体做了压缩,减少传输量;第四,把原先的逐包下载改成策略化预取。
这个性能提升对开发者体验影响很大,因为pub get是高频操作,一天几十次,每次省 30 秒,累积下来非常可观。而且当团队规模上到几十人以后,对上游源的并发压榨也会减少,整个外网链路更稳定。
4. 依赖治理:把版本冲突和安全问题挡在门外
4.1 版本策略:从源头定规矩
内部组件禁止使用any约束,禁止只写不带上限的裸版本号。合理的约束格式是^2.1.0,表示允许 2.x 系列内更新,但不跨大版本。团队内部要形成共识:大版本号变化代表不兼容变更,需要发布公告;小版本只做向后兼容的 bugfix 和功能增强。违反这个约定的发布应该被流水线拦截。
我们会额外关注 SDK 约束字段。鸿蒙适配期间,很多组件会面临 Flutter SDK 和鸿蒙 SDK 双约束,如果不显式声明,解析器很可能选择一个在高版本鸿蒙 SDK 上编不过的旧版包。这个字段的校验比版本号校验更隐蔽,却往往是最容易踩的坑。
4.2 依赖体检:把冲突消灭在 CI 阶段
定期执行dart pub deps查看依赖树,重点检查重复的大版本、传递依赖是否扩散。smartpub 提供反向依赖查询以后,体检可以做得更精准。比如当某个底层包有漏洞时,不是靠人工盘点,而是列出所有受影响组件和工程。依赖冲突的解决优先级应该遵循就近解析、最小变更的原则,能用约束收紧解决就不引入 override。
这里建议用表格记录一份依赖全景图:
| 组件名 | 当前版本 | 依赖方数量 | 是否跨大版本重复 | 处理建议 |
|---|---|---|---|---|
| core_network | 2.1.0 | 7 | 否 | 保持 |
| legacy_utils | 0.9.2 / 1.0.3 同时存在 | 5 | 是 | 推动迁移 1.0.3 |
| model_api | 3.2.1 | 3 | 否 | 关注发布节奏 |
这张表不需要做得太复杂,每周跑一次,有变化就自动差分发到相关群。坚持三个月,依赖问题基本不会拖到集成阶段。
4.3 安全与合规扫描:发布和拉取双关卡
在发布和拉取两端都植入扫描。发布时对源码和产物做静态扫描,拉取时对上游代理的第三方包做漏洞库比对。许可证方面,我们会强制拒绝 GPL 类组件进入内部仓库,避免后续交付出现授权风险。所有扫描结论都记入包的审计日志,方便交付前后检查。
这个环节最容易被忽视的是二进制产物扫描。Dart 代码层面的漏洞扫描大家都会做,但 HAR 里的 so 文件、资源文件往往不进扫描范围。我们后来补上了对 so 的符号表检查和架构白名单校验,才算是真正把住了关。
4.4 治理效果:从人工喊话到自动拦截
没有治理之前,新增一个依赖就是 PR 里一句“这个库挺好的”,完全靠人的自觉性。治理之后,CI 会自动检查依赖树、禁止引入重复大版本,发布未过扫描的包不能被任何工程拉取。整个流程变成制度化的,效果对比非常明显:
| 维度 | 治理前 | 治理后 |
|---|---|---|
| 版本来源 | 各团队各自拉取,来源混乱 | 统一经过 smartpub,来源可追溯 |
| 冲突处理 | 集成时爆发,靠人协调 | CI 阶段自动检测,提前拦截 |
| 安全通告 | 全公司群里滚动排查 | 反向依赖查询,分钟级列清单 |
| 新增依赖 | PR 里口头说明 | 必须过扫描和评审,记录归档 |
这套治理机制不仅解决技术问题,还让团队协作从“出了问题再补救”变成“出问题前就拦下”。
5. 上线后我遇到的坑:现象、排查与修复
5.1 同样的 pubspec 在不同机器解析出不同版本
现象:开发机刚 pull 完代码,pub get发现依赖版本和 CI 环境不同,怀疑是缓存问题。排查后发现,根因是部分组件在 pubspec 中用了区间约束,而两边拉包的先后顺序不同,上游刚发布了新版本,导致一个拉到旧版一个拉到新版。
解决:所有工程强制提交 pubspec.lock,CI 和本地都用 lockfile 锁定解析结果;发布新版本后由 smartpub 触发缓存失效,而不是等 TTL 自然过期。事后复盘时我们还发现,早期没有锁文件的一个重要原因是每次合并代码都会产生大量 lockfile 冲突。后来把 lockfile 的更新策略改成“由发布流水线统一更新”,冲突就消失了。
5.2 HAR 包在鸿蒙工程里“装上但符号找不到”
现象:Flutter 侧编译通过,但鸿蒙侧编译报 undefined symbol。排查:包虽然发布了,但 HAR 内的 ArkTS 导出文件没有包含新接口,架构匹配也没有问题,纯粹是产物不完整。这表明发布流程缺少对鸿蒙产物的完整性校验。
解决:smartpub 发布流水线增加 hvigor 构建校验,发布前检查oh-package.json5中声明的 export 是否完整,同时对 HAR 内 so 架构做白名单检查。需要强调的是,这种问题在真机上不一定暴露,但一旦别人引用某个符号就会立刻编译失败,因此校验必须前置。
5.3 私有源和上游源混用时的优先级问题
现象:组件 core_network 同时存在于私有源和上游源,部分机器拉到了上游旧版本,行为不一致。原因是客户端配置了多个源,解析顺序没有统一约束,导致同一个包在不同环境落到不同仓库。
解决:不允许客户端自己配置多个源,所有源统一由 smartpub 代理,并且服务端逻辑固定为“私有命中优先,私有未命中再代理上游”。这样不管客户端环境变量怎么折腾,裁决逻辑都在一个地方。从运维角度看,这也让故障排查简单了不少,因为只查一个入口的状态就行。
5.4 对象存储膨胀:缓存清理要聪明一点
现象:磁盘告警,发现缓存了大量旧版本。简单粗暴的清理会引发新问题:某天有个工程回滚到旧版本,结果对应文件被清了,只能重新回源拉取,速度慢且不稳定。
解决:增加版本清理策略,保留每个包最近 N 个版本,超过阈值的包文件移到冷存储,同时定期清理过期签名。清理前要确认没有任何工程在依赖这些版本,smartpub 会先跑一次反向依赖查询,确认无引用后再清理。这个“先查询再清理”的操作顺序,建议所有仓库管理员都刻在脑子里。
最后再分享一个小实践
做包管理这件事,表面上是搭一个源服务器,实际大部分精力都是在治“人情世故”。我最深的体会是,依赖治理的收益不是上线那一刻体现的,而是在三个月后某个安全通告里、在一个陌生组件突然无人维护时体现出来的。最后分享一个小实践:每次发布组件时在 metadata 里强制带上 release notes,不需要多长,一句话就好。这个字段会让后续的版本追溯变得非常省力,排查问题的时候能省下大量沟通成本,强烈建议加上。