做鸿蒙应用拆得越细,越会撞上一个问题:好几个HAP长得不一样,但内部都在重复同一套网络请求、登录态、埋点上报。一开始图省事,每个HAP各写一份,后来公共逻辑一处改了、另一处忘了同步,版本乱得自己都记不住。这个系列做到第五篇,正好把多HAP集成同一个HSP的事从头到尾理一遍——架构上怎么拆分,版本上怎么收敛,以及哪些坑实测下来最值得提前躲开。
这篇内容主要面向两种人:一种是工程里已经有多个HAP、正准备用HSP收敛公共代码的开发者;另一种是刚接触鸿蒙模块化开发,想搞清楚HAP、HSP、HAR之间到底什么关系的人。我会把概念、决策思路、配置细节和真实踩坑记录放在一起讲,尽量让不同基础的读者都能直接落地上手。
1. HAP、HSP、HAR三者的边界:为什么共享这件事会变成一个架构问题
1.1 三种包各自怎么理解
先说结论:HAP是能被系统安装和运行的最小单元,HSP是运行期共享包,HAR是编译期静态共享包。三者共享代码的时机完全不同,理解这一点是后面所有架构决策的前提。
HAP(HarmonyOS Ability Package)承载着一个应用里可被安装的代码、资源和Ability清单。用户从应用市场下载一个应用,实际落地的就是若干个HAP的组合。每个HAP是独立上版本、独立签名的,系统把它当成一个可运行的整体来对待。
HSP(HarmonyOS Shared Package)不参与安装入口,它是一份运行期共享的代码和资源。听起来像库,但它比普通库多了一层工程属性:HSP有自己的module配置、自己的资源目录、自己的导出入口(通常是Index.ets),可以单独构建、单独发版本,但只有被某个HAP依赖时才会真正运行。
HAR(HarmonyOS Archive)是最容易和HSP混淆的。HAR在编译期就被完整地拷贝进依赖它的HAP里。也就是说,多个HAP各自依赖同一个HAR,实际上每个HAP内部都有一份拷贝。HSP则不同,多个HAP运行时读的是同一份代码和资源。
有一个类比很贴切:HAR像你把一张图复印了三份,三个文件夹各放一份,谁改谁的互不影响;HSP像三个人看同一块白板,任何改动所有人立刻可见。也正是这个"立刻可见"的特性,让版本管理变得格外重要。
下面这张表可以帮你快速对比:
| 维度 | HAP | HSP | HAR |
|---|---|---|---|
| 是否可独立安装 | 是 | 否 | 否 |
| 代码共享时机 | 不共享 | 运行时共享 | 编译期拷贝 |
| 是否有独立模块配置 | 有 | 有 | 有 |
| 是否参与App Pack组成 | 是 | 是(随依赖它的HAP) | 否(编译期已内嵌) |
| 对包体积的影响 | 每个HAP独立 | 全局只保留一份 | 每个依赖的HAP各带一份 |
| 版本升级的影响范围 | 仅自身 | 所有依赖它的HAP | 仅当前HAP |
1.2 什么场景下真的需要多HAP + 公共HSP
我见过有人为了"技术先进"硬拆HSP,结果公共模块没有几个,倒是多了一整套依赖管理和构建配置的负担。一个单入口、单团队的小应用,完全没有必要引入HSP。
真正需要多HAP集成同一个HSP的场景,我归类为四种:
第一种是主应用拆成多个HAP。比如主入口是一个HAP,账号、支付、扫码等功能各自成HAP,它们都需要调用同一套登录态、网络层、配置中心和通用组件。没有HSP的话,每个HAP只能各写一份,或者用HAR重复打三份进包体,体积和维护成本都很难受。
第二种是超级应用式的工程结构。壳工程做宿主,功能模块以独立HAP的方式挂在壳下面,由不同的小组独立开发、独立发布。这种结构下,公共基础能力必须有一个统一的运行期出口,HSP是比HAR更合理的选择。
第三种是按需下载和动态加载场景。有些体积大、更新频率低的能力(比如扫码引擎、客服SDK),很适合放进HSP,配合按需分发机制,不让用户一次性下载全量包。这个场景下HSP的运行时共享特性几乎是唯一解。
第四种是团队协作边界的划分。公共模块由专门的基础设施团队维护时,HSP的独立版本号、独立发布链路,能让公共团队和业务团队各管各的版本节奏,比所有人改同一个HAR要干净得多。
1.3 引入HSP后工程结构的变化
没有HSP时,一个App工程的结构通常是:entryHAP + 若干个HAR,所有HAR编译进对应HAP,App Pack里基本就是几个互不相干的HAP。
引入HSP之后,工程结构会发生两个明显变化:
第一,App Pack的组成从"多个HAP各自完整"变成"多个HAP + 一个或多个HSP"的组合。HSP会跟着引用它的HAP一起进入App Pack,上架前做整体签名。运行期系统会根据HAP的依赖声明加载对应的HSP,相当于在HAP之间搭了一座共享的桥。
第二,模块的组织方式从"谁需要谁带一份"变成"公共底座统一维护"。你需要额外管理HSP模块的构建顺序、版本号、导出接口和混淆规则。这些东西在HAR时代几乎不用操心,现在都变成了架构的一部分。
2. 架构设计:把共享能力沉到HSP,把业务边界划到HAP
2.1 拆分的第一原则:按复用频率,而不是按团队感觉
架构设计的第一步,不是画图,而是决定哪些代码进HSP、哪些留在HAP、哪些用HAR。我用的标准非常朴素:多个HAP真正会复用的,才进HSP;只在一个HAP里用的,就留在原地;被HSP依赖但不跨越HAP共享的底层实现,保持HAR。
最容易犯的错,是把"看起来挺通用"的全部下沉到HSP。HSP真的不是越厚越好。公共HSP过厚会带来两个实际后果:一是版本升级时牵动所有HAP一起回归,改动风险面急剧放大;二是构建产物和初始化阶段,单包体积和加载时间都会明显上升,尤其对按需分发场景很不友好。
拿我手上的工程举例,最终沉淀到HSP的只有四类:网络请求封装、统一登录会话、埋点上报、通用UI组件。订单、支付、个人中心这类有明确业务边界的模块,全部留在各自的HAP里。边界划清楚之后,"这个接口该放哪"基本不用讨论。
2.2 依赖方向:HAP可以依赖HSP,HSP不要反向依赖HAP
多HAP工程里,依赖方向是架构的底牌。这里有一条铁律:HAP依赖HSP没问题,HSP绝对不要反向依赖某个HAP。
理由不复杂。HSP一旦反向依赖HAP,它就不再是公共共享层,而是携带了特定业务上下文。别的HAP引用这个HSP时,轻则编译报错,重则运行期行为分裂。
具体落到代码层面,我会强制约束三点:
HSP的导出入口只放稳定能力,不放跟具体业务HAP强相关的逻辑。判断标准很简单:这个函数如果离开某个业务HAP就讲不清楚,它就不该出现在HSP里。
HSP之间允许互相依赖,但层级要控制。打破这个约束会出现循环依赖,尤其在HSP和HAR混合的时候,构建系统和IDE的告警不够明显,直到运行期才暴雷。
HSP内部的代码可以依赖HAR和ohpm生态依赖包,但绝不能出现
import某个HAP模块的写法。工程规范里直接禁止这种引用关系。
2.3 一个典型的多HAP + 公共HSP分层结构
下面是我们现有工程抽出来的分层结构,看起来简单,但它是经过三轮调整后才稳定的:
App Pack (xxx.app) ├── entry.hap // 主入口:桌面图标、导航框架、页面路由壳 ├── pay.hap // 支付业务:支付页、收银台、支付结果处理 ├── scan.hap // 扫码业务:相机扫码、码解析、结果跳转 ├── past.hsp // 行为共享层:网络、登录会话、埋点、错误码 └── uikit.hsp // UI共享层:通用组件、主题、样式资源拆成common和uikit两个HSP而不是塞成一个,是按变更频率和职责不同来考虑的。行为共享层里的网络、登录、埋点,变更频率高,几乎每个迭代都会动;UI组件层的样式和组件相对稳定,变更频率低很多。拆开之后,需要升级UI组件时不必连累行为共享层的版本,反之亦然。如果你把所有能力都塞进一个HSP,每次小改动都要连带整包回归,那才叫苦不堪言。
2.4 设计阶段的决策清单
在我这里,每次新增一个模块或者要往HSP里加能力之前,必须过一遍这组问题。你也可以直接把这个清单当成评审项:
这个能力会有几个HAP用到?如果当前只有1个,先留在HAP里,等出现第二个使用者时再考虑下沉到HSP。过早下沉和过晚下沉的代价不同,宁可晚一点。
它会不会引用某个HAP的私有路由、私有数据模型或私有持久化文件?会,那它就不能进公共HSP。这一条违反之后,短时间内看不出问题,等第二个HAP引入时就是灾难。
它的变更频率高吗?高,给它单独一个HSP模块,别跟低频模块挤在一起;低,可以和类似稳定度的能力合并,减少模块数量。
它的改动会带来破坏性API变更吗?会,就走下一节说的版本升级流程,这一步不是技术问题,是发布流程问题。
3. 版本管理:HSP不是HAR,升级条件比你想的更严格
3.1 语义化版本:HSP版本号的分量和HAR完全不同
HSP的module配置里有versionName和versionCode,发布到仓库后,依赖声明里还会涉及版本范围。看起来和普通依赖差不多,但影响面完全不同。
HAR的版本升级影响是局部的。每个HAP编译时把自己那份HAR打进去,其他HAP升级HAR版本,已上线的旧HAP完全不受影响。你可以说HAR的版本是"各管各的"。
HSP是运行期共享,升级HSP就像改了一台公共服务器上的配置文件,所有连上来的HAP行为一起变。这也是为什么HSP的版本策略必须比HAR严格得多。
还有一点容易忽略:HSP的版本号不是想升就升的。升了次版本号(1.1.0变1.2.0),所有依赖它的HAP只要语义兼容,理论上可以不动;但这种"可以不动"恰恰是风险,因为运行期代码已经变了,只是API签名没变,行为差异仍然可能影响某些HAP。HSP版本升级后,哪怕是小版本,我也建议做一次全量回归。
3.2 dependencies怎么写版本约束:多个HAP必须收敛到同一个版本
在HAP的oh-package.json5里声明对HSP的依赖时,有一个容易忽略的细节。
如果多个HAP都用"@ohos/common": "file:../common"这种本地模块方式引入,那它们引用的其实就是同一个源码目录,构建时会一起打包。这种写法在开发阶段很方便,但本地HSP一旦直接改动,影响面是全局的,版本约束基本形同虚设。
正式发布到ohpm仓库之后,依赖声明就要用版本范围了:
{ "name": "entry", "version": "1.0.0", "dependencies": { "@ohos/common": "^1.2.0", "@ohos/uikit": "~1.0.1" } }这里的^和~语义和npm一致:^1.2.0允许1.x范围内所有版本升级,~1.0.1只允许补丁版本升级。这个机制在单HAP里没什么问题,但在多HAP场景下有一个暗坑:不同HAP声明的版本范围,最终解析到运行时必须是同一个HSP版本。
举个例子,entry把@ohos/common声明为^1.2.0,pay把@ohos/common声明为^1.0.0。假设仓库里最新HSP是1.2.0,entry解析到1.2.0,pay解析到1.0.0或1.1.x,而这两个HAP装在一起后,系统只能在一份HSP里做选择。一旦它们需要的接口版本不一致,就会出现"一个HAP正常、另一个HAP调用失败"的诡异现象。
所以多HAP工程的版本约束,必须在设计阶段就约定统一:所有HAP对同一HSP的依赖范围写一样,或者干脆统一升级到最新兼容版本,消除解析分歧。
3.3 破坏性变更的发布顺序:先兼容、再升级、后下线
公共HSP做破坏性变更时,绝对不能像内部HAR一样直接改、直接发。正确顺序是:
在现有HSP上保留旧接口、新增新接口,发一个次版本(比如1.2.0升到1.3.0)。这个版本叫兼容版本,旧HAP不用改代码也能跑。
所有HAP适配新接口,并把依赖声明统一指向这个兼容版本,做一轮完整回归。
等确认所有使用方都迁移完毕,再发主版本升级(比如1.3.0升到2.0.0),并在2.0里清理掉废弃接口。
如果违反顺序直接发2.0并把旧接口删掉,最典型的事故是:已在用户手机上的旧HAP还没更新,但HSP已经通过某种渠道升级,于是旧HAP调用HSP时运行期找不到方法。这种问题本地很难复现,因为本地开发时所有HAP和HSP往往已经同步升上去了。
3.4 版本冲突与锁文件
多HAP工程里,oh-package-lock.json5这个锁文件的分量比单模块工程里大得多。它锁定了每个HAP解析出来的具体依赖版本。遇到诡异问题,第一步永远先查锁文件,确认是不是某个HAP被锁在了不期望的旧版本。
我恢复现场时,十次里有七八次都是"本地能跑、打包出错",最后定位到的都是lock文件里的HSP版本和最新依赖不一致。改完依赖版本约束之后,要记得重新生成lock文件,不要手动删一行了事。删锁文件再重新解析通常没问题,但如果有多个团队并行开发,锁文件的变化会造成大量无谓的diff和冲突,建议纳入代码评审范围。
4. 工程配置与构建产物:从模块创建到App Pack打包的关键细节
4.1 创建HSP模块时module.json5的配置项
在DevEco Studio里新建Shared Module之后,生成的module.json5核心内容大概是这样:
{ "module": { "name": "common", "type": "shared", "srcEntry": "./Index.ets", "description": "$string:module_desc", "mainElement": "", "deviceTypes": ["phone", "tablet", "2in1"], "deliveryWithInstall": true, "installationFree": false, "pages": "$profile:main_pages", "versionName": "1.2.0", "versionCode": 102000 } }这里有几个点值得特意说明。
type字段必须写成shared,写成har或feature都会让模块类型错乱,界面和构建行为完全不一样。这是我见过最多的低级错误。
deliveryWithInstall表示HSP是否随应用安装分发。true是随包安装,false是按需下载。如果做按需分发场景,这个字段要和分发配置联动,不能只在这里改一个值。
versionName和versionCode是HSP自己的版本号。注意它和应用级别的app.json5版本号是独立的,两者不必一致,但要有映射关系。我习惯把versionCode设为应用版本号的10倍加上HSP内部序号,这样调试时一眼能看出当前HSP对应哪个应用版本阶段。
4.2 依赖声明与构建产物核对
HAP依赖HSP之后,构建产物会发生明显变化。一个正常构建的输出大致是这样:
build/default/outputs/default/ ├── AppPack/xxx-signed.app ├── default/entry-default.hap ├── default/pay-default.hap ├── default/scan-default.hap ├── default/common-default.hsp └── default/uikit-default.hsp打包阶段会把HSP和HAP一起装进App Pack。如果构建产出的App Pack里没有HSP,十有八九是HAP的依赖声明没配好,或者HSP没有被任何HAP引用,被构建系统当成孤立模块跳过了。
第一次引入HSP,我强烈建议手工解包App Pack看一眼里面的文件结构,确认HSP确实在包内、版本号正确。工具类的问题,用工具排查永远是最高效的,不要凭猜想改配置。
4.3 调试模式下多HAP与HSP的联调方法
DevEco Studio里Run一个多HAP工程时,默认运行配置里指定的entry模块。HSP代码修改后,Run某个入口HAP会把HSP跟着带上去,这个流程本身是顺畅的。
真正需要注意的是非入口HAP的调试。如果某次改动只涉及一个非入口HAP,单独Run那个HAP时,HSP可能没有重新打包,用的是旧缓存。尤其你刚改过公共HSP的代码,再去Run一个非入口HAP,很容易踩中"代码改了但行为没变"的假象。
我的做法是:改公共HSP之后,不要只Run某个HAP,先Build整个App Pack,或者干脆Clean Project之后再Run。虽然多花一点时间,但能省掉大量排查"是不是缓存"的重复工作。
5. 实测踩坑:多HAP依赖HSP时最容易翻车的几个场景
5.1 接口新增了,但某个HAP运行时找不到符号
这是多HAP + HSP最经典的现场:两个HAP依赖同一个HSP,A运行一切正常,B一调用新接口就报错"Cannot find function"或者"Property not exist"。
我的排查链路是这样的,你可以直接复用:
先确认HSP本身是否正常构建,拿构建产物目录里的
.hsp文件核对版本和导出接口。查报错模块的
oh-package.json5,看依赖声明里写的版本范围是否包含新增接口的版本。这里经常出问题:开发时改了代码,但依赖范围写的是旧版本起点。打开
oh-package-lock.json5,看这个模块实际解析到了哪个版本。很多时候,oh-package.json5写的是^1.3.0,但lock文件里还锁在1.2.0,构建就跑在旧版本上。重新生成lock文件并提交,问题通常就消失了。
这一步的关键是:不要先怀疑运行逻辑,先确认打包进去的HSP到底是哪个版本。版本不对,代码写得再对也没用。
5.2 改了HSP,构建却还是旧逻辑
HSP的增量编译缓存是个老顽固。共享模块被多个HAP引用时,增量编译如果依赖关系没有被完整识别,可能命中旧的产物。具体表现就是:HSP代码明明改了,也触发重建了,但打出来的包还是老逻辑。
解法很简单粗暴:Clean Project之后重新Build。如果你不想频繁全量构建,可以检查工程里模块间依赖顺序的配置,把HSP放在依赖链的前端位置。实测下来,Clean远比手动删build目录可靠,手动删目录偶尔会因为IDE缓存没刷新导致更奇怪的问题。
5.3 Release包混淆后HSP导出类被"优化"
Release构建默认开启混淆和裁剪,这是常见坑。HSP暴露给外部使用的导出入口(Index.ets)里的类和方法,如果没加混淆keep规则,运行期调用就会出现"NoSuchMethodError"或"method not found"。
处理方式是在混淆配置里给HSP的公开导出加keep规则。另一个重要经验是:HSP的公开API必须全部在导出入口统一声明,不要分散在深层类里。集中导出有两个好处:一是调用方依赖关系清晰;二是混淆规则可以集中配置,不用逐个类去加keep,降低漏配概率。
如果你发现release包能跑,但一调用某个HSP方法就崩,优先怀疑混淆。这个问题的迷惑性在于,debug包永远正常,release包才暴露。
5.4 签名不匹配导致安装失败
多HAP和HSP打包进同一个App Pack时,所有包共用同一个签名。开发阶段如果用了自动签名,偶尔会因本地证书或SDK版本不同导致某个HAP的签名不一致,安装时报错"INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES"。
排查方法:用SDK自带的工具检查每个包的签名信息,确认多个包确实使用的是同一个证书。这也顺便说明为什么按需分发的HSP要配置好依赖HAP的分发规则——签名验证是整体性的,任何一环不一致,整个App Pack都可能装不上。
如果现在让我重新设计一遍这个工程,我会把版本策略的讨论放在架构设计之前,而不是等模块拆完了才发现HSP版本联动比想象中复杂得多。这一篇讲到的架构拆分、版本约束、构建产物核对和几个踩坑场景,都是这个系列里最值得反复看的部分。多HAP集成HSP,方案本身不难,难的是把依赖关系、发布顺序和版本边界管住。你把这些规则提前定好,后面团队协作能少掉一半的沟通成本。