去年底接了个项目,团队要在一个宿主App里同时跑三条业务线:用户中心、数据统计、还有每月至少两次的版本活动。最开始大家图省事,所有页面全塞在entry模块里,结果工程膨胀得厉害,随便改个活动页都要整个包重新编译,CI动不动就十几分钟。后来我花了一周时间把工程重构成基于Feature模块的架构,从那之后,每个业务小组各自维护一个独立模块,发版互不干扰,新增功能再也不用动主工程。
这个案例里的Feature模块,其实就是HarmonyOS应用工程里的独立功能模块,按常规习惯会命名为feature_xxx,它是动态化开发最顺手的一个武器。这篇文章就围绕这个"瑞士军刀"式的小案例展开,讲清楚它解决什么问题、怎么落地、以及我踩过的坑。无论你是刚接触HarmonyOS开发,还是已经在用多模块工程,这篇内容都能拿来直接参考。
1. 先说结论:Feature模块是什么,为什么要用它
1.1 一个真实的业务痛点
先回顾一下当时的问题。主工程里包含了登录、账单、统计、设置、活动、推送等所有功能,代码量到了一定程度之后,开始出现几类让人头疼的情况。
第一是编译慢。虽然DevEco Studio有增量编译,但模块内部的循环依赖一旦变多,改一处业务代码,连带编译的模块就会变多,一次调试构建从三分钟慢慢爬到了七八分钟,非常影响开发节奏。第二是协作冲突。多人同时改entry模块时,git提交冲突频繁,解决冲突的代价远超写代码本身。第三是动态化需求难以满足。业务方希望版本活动页可以单独更新,不用等主包发布,而动态化能力依赖的恰恰是把功能拆成独立模块,独立编译、独立交付。
我把这些痛点和团队的几个核心成员整理了一遍,最后得出一个结论:必须改成多模块工程。而Feature模块的引入,不只是改目录结构这么简单,它涉及依赖方向、路由通信、资源隔离、构建产物等一系列变化。
1.2 Feature模块、HAR、HSP三者之间的关系
HarmonyOS的工程里,默认会有一个entry模块作为应用入口,承担启动和全局初始化工作。而Feature模块按官方设计思路,是“按业务功能划分的独立模块”,比如feature_login、feature_statistics、feature_bill,每个模块可以包含自己的页面、服务、资源和逻辑代码。
但Feature模块本质上不是一个具体的东西,它可以通过两种工程形态来实现:
- HAR(HarmonyOS Ability Package):静态共享包,编译时打入宿主应用,类似于Android的Library。
- HSP(HarmonyOS Shared Package):动态共享包,运行时按需加载,支持独立升级和动态交付,这才是真正意义上的“动态化模块”。
所以在工程里创建Module时,如果选的是“HarmonyOS Shared Package”,那这个feature模块天然就是动态化的;如果选的是“Static Library”,那就是一个静态的feature模块,代码逻辑可以分离,但无法做到真正的按需动态加载。
这里有个很多人容易混淆的点:function模块(feature模块)不等于HSP,但工程实践里,我强烈建议用HSP去承载业务Feature模块。因为HSP支持动态加载——也就是应用市场可以只下发这个模块,不需要重新下载整个应用。对于高频更新、内容运营类的业务,这个能力太关键了。
1.3 动态化开发解决了什么问题
拆分之后,解决的不仅仅是编译时间问题,还打开了一个更大的可能性:模块级动态发布。具体来说有三个层面的收益。
第一,按需加载。用户只下载核心模块,辅助功能等到使用时再动态拉取,这直接减小了安装包的首包体积。比如统计报表这类低频功能,完全可以在用户点击时才加载对应HSP。
第二,独立交付。业务模块的迭代不再跟随应用主版本。活动页面需要临时上线时,只需独立构建并发布对应的HSP包,应用市场审核和发布范围都灵活很多。
第三,团队自治。每个Feature模块有明确的边界和负责人,模块间通过约定好的接口通信,不用关心对方内部的实现细节。编译、测试、发布都能做到一定程度的自治。
不过,动态化也不是越多越好。如果模块颗粒度太细,会导致工程碎片化,依赖关系复杂,反而增加维护成本。这个平衡点我在后面会详细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 物料准备与核心参数
2.1 工程环境要求
这个案例用的是HarmonyOS NEXT 5.0.x版本,开发工具是DevEco Studio 5.0及以上。要注意,NEXT版本不再兼容Android APK,所以工程里的API、SDK都要基于HarmonyOS自己的体系,这点和早期用API 9做双框架开发是不同的。
建议在一个干净的工程里从头建HSP模块,这样不容易被历史工程配置带偏。创建时主要的路径是:
File -> New -> Module -> HarmonyOS Shared Package
创建完成后,工程结构会多出一个模块目录,默认名字类似sharedlibrary或者你自定义的feature_login。它的目录结构和entry大体一致,有src/main/ets、src/main/resources和src/main/module.json5。
如果是在已有工程里加模块,需要注意聚合根配置文件settings.gradle(或build-profile.json5)会自动更新,确保新模块被纳入编译。这一点DevEco Studio一般会自动处理,但也要检查一下,避免出现模块没有编进包里的情况。
2.2 路由与服务注册
模块拆分之后,跨模块跳转是绕不开的问题。HarmonyOS里页面跳转有两种主流方式:router和Navigation。
router是传统路由,写法简单直接,通过url字符串定位页面。在跨HSP模块跳转时,url的格式比较特殊:
typescript复制router.pushUrl({
url: '@bundle:com.example.app/feature_login/ets/pages/LoginPage'
})
这个url包含三部分:@bundle:后面跟应用包名,然后是模块名,最后是模块内的页面路径。页面路径以/ets/pages/LoginPage这种形式写在模块的main目录之后。
Navigation是更推荐的组件化路由方案,结合NavPathStack可以实现类型安全的路由传参,并且能配合系统路由表做动态加载。不过对于小案例来说,router完全够用,代码量也更少。
除了页面路由,模块间还需要服务调用。比如entry模块要获取用户信息,entry不能直接import feature_login的类,因为依赖方向反了。正确做法是抽取一个common模块,专门放接口定义和数据结构,entry和feature_login都依赖common。通过接口约定调用关系,而不是直接依赖实现类,这个设计原则在多模块工程里非常重要。
2.3 module.json5 关键配置说明
HSP模块的module.json5和entry模块有些差异,需要重点看这几个配置项:
json复制{
"module": {
"name": "feature_login",
"type": "shared",
"srcEntry": "./ets/Application/AbilityStage.ts",
"description": "$string:module_desc",
"mainElement": "LoginAbility",
"deviceTypes": ["phone", "tablet"],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "LoginAbility",
"srcEntry": "./ets/entryability/LoginAbility.ts",
"description": "$string:LoginAbility_desc",
"icon": "$media:icon",
"label": "$string:LoginAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"]
}
]
}
]
}
}
这里type字段是"shared",代表这是一个动态共享模块,在构建产物里是独立的.hsp文件。deliveryWithInstall表示是否随主包安装时一起下发,如果设为false,则这个HSP模块默认不会被安装,需要后续按需请求。installationFree表示是否支持免安装运行,普通业务模块一般不需要开。
entry模块的module.json5里会在dependencies字段声明对HSP的依赖,这样才能在编码时引用到它暴露的接口。具体的依赖写法是:
json复制"dependencies": [
{
"name": "feature_login",
"version": "1.0.0"
}
]
如果只是希望在宿主工程里运行,也可以不写在dependencies里,而是通过HSP的动态加载能力在运行时获取,不过这对工程配置和签名要求更高。
3. 从零到一:Feature模块落地全过程
3.1 创建feature模块和依赖规划
我用的是DevEco Studio 5.0,实际操作里踩过一个小坑:Module类型一定不要选错。在New Module弹窗里,有“Application”和“Shared Library”等选项,其中带“Shared Package”字样的才是HSP。我当时差点选成“Static Library”,创建出来之后发现是编译期打包的HAR,如果业务需要动态发布就达不到预期了。
创建完成后,建议做三件事:
- 修改模块名为更有业务意义的名称,例如
feature_bill。 - 调整feature模块的源码包名,和主工程的包名前缀保持一致,比如
com.example.app.feature_bill。 - 把公共的接口类、常量、工具类抽到
common模块,避免重复代码。
依赖规划是整个环节最关键的一步。我在这个案例里设定了这样的依赖规则:
- entry依赖:common、feature_login、feature_bill、feature_statistics
- feature_login依赖:common
- feature_bill依赖:common
- feature_statistics依赖:common、feature_bill(因为统计页需要读取账单数据)
依赖方向永远是“上层依赖下层”,不能出现循环依赖。如果发现两个feature模块互相调用,那就说明公共能力抽得不够干净,需要继续往下沉到common层。
3.2 模块内页面和服务实现
以feature_login为例,我们在这个模块里实现了登录页、用户信息页和自动登录服务。
页面代码和普通单模块开发没有太大区别,核心差别在于资源的引用方式。HSP模块内使用自己的资源,直接用$r('app.string.xxx')即可;但跨模块引用资源时,HSP支持通过$r配合模块名来引用:$r('app.string.module_desc', 'feature_login')这种形式,格式上要注意模块名参数。实际测试中,跨模块引用资源容易出问题,我把公共文案都放在common模块里,各业务模块各自维护自己的资源,尽量不跨模块引用。
服务实现上,我写了一个简单的用户会话管理类,负责token存储和登录状态判断。这个类不直接暴露给entry模块,而是通过common里定义的接口IUserSession来访问:
typescript复制export interface IUserSession {
isLogin(): boolean;
getUserId(): string;
login(account: string, password: string): Promise<boolean>;
logout(): void;
}
feature_login内部实现这个接口,并通过一个全局的ServiceLocator完成注册。entry模块在启动时只需要从ServiceLocator拿到实现类,不关心是哪个模块提供的,这样即使以后把登录模块替换成指纹登录模块,entry的代码也不用改。
3.3 宿主工程集成与跳转
宿主entry模块的集成分两步:配置依赖和页面跳转。
依赖配置在entry模块的module.json5里写清楚。跳转时,我用了一个简单的路由分发工具类,统一管理所有feature模块的路由表。这样页面跳转的调用方不需要硬编码url,避免url字符串散落在各个页面里。
typescript复制export class RouterManager {
private static readonly routes = new Map<string, string>([
['login', '@bundle:com.example.app/feature_login/ets/pages/LoginPage'],
['bill', '@bundle:com.example.app/feature_bill/ets/pages/BillListPage'],
['statistics', '@bundle:com.example.app/feature_statistics/ets/pages/StatisticsPage']
]);
static push(pageName: string, params?: Record<string, string>) {
const url = this.routes.get(pageName);
if (!url) {
console.error(`RouterManager: route not found: ${pageName}`);
return;
}
router.pushUrl({ url, params });
}
}
这样的好处是后续模块如果调整了内部路径,只需要改RouterManager这一处,调用方无感知。如果是大型团队,还可以用编译期注解来生成路由表,不过小案例里一个Map就足够了。
跳转的参数传递用router.pushUrl的params对象,注意参数只能传字符串或数值等简单类型,复杂对象需要通过序列化传参。我在统计页面跳转时传账单日期范围,就是一个典型的字符串参数场景。
3.4 构建、签名与动态交付
构建HSP模块和构建普通应用有区别。主包和HSP会分别生成不同的构建产物,在工程目录的build目录下可以看到:
entry-default-signed.hapfeature_login-default-signed.hspfeature_bill-default-signed.hsp
签名时要注意,HSP必须和主应用使用相同的签名证书,否则运行时校验会失败。DevEco Studio里可以配置自动签名,团队合作时则需要在build-profile.json5中配置好统一证书。
动态交付的配置在AppGallery Connect侧。把hsp文件作为“动态能力”上传,然后设置“随包交付”或“按需交付”。这个步骤如果只是本地调试,可以暂时不用配置AGC,但如果不配置,HSP会随主包一起安装,无法体验到真正意义上的“按需加载”。
我在实际测试动态交付时遇到过一个问题:第一个版本把deliveryWithInstall设为true,HSP随包安装,运行正常;第二个版本改成动态按需交付,结果熄屏后再打开应用时出现了短暂的空白页,排查后发现是因为HSP在后台被系统回收,再次进入时还没有来得及加载完成。这个问题的解决方式是,在宿主入口页提前预加载HSP,或者改用deliveryWithInstall为true保证该模块常驻。
4. 实战常见问题与排查实录
4.1 跳转找不到页面的问题
这是使用HSP后出现频率最高的问题。现象是点击跳转后控制台报“The page URI is not exist.”,页面白屏。
排查步骤和结论:
- 确认url格式是
@bundle:开头,不是entry或pages开头。很多人从单模块习惯带过来,写着pages/LoginPage,在HSP场景下必挂。 - 确认模块名大小写。url里的模块名必须和
module.json5中的name字段完全一致,大小写敏感。 - 确认页面文件是否被
main_pages.json包含。每个模块的main_pages.json是一个数组,页面路径必须列进去,否则即使url格式正确也找不到页面。 - 确认entry的依赖里已经声明了该HSP。如果依赖没声明,编译期能过,但运行时无法解析模块资源。
其中main_pages.json的问题比较隐蔽,因为它只列出模块内所有页面,新增页面后如果忘了加,编译不会报错,运行时才暴露。
4.2 依赖与资源冲突
HSP模块之间如果依赖了同一个底层库,可能出现依赖重复或版本不一致的问题。最常见的是common模块被两个HSP分别以不同版本依赖,运行时系统会选择高版本,但代码里用了低版本的API,就会在低版本API执行到的地方出现崩溃。
我处理这类问题的原则:
- 所有模块依赖的公共库版本统一在
build-profile.json5的dependencies里管理,不单独在子模块里指定版本号。 - 公共库尽量采用HSP形式共享,而不是在每个HSP里各打包一份。
- 如果依赖关系复杂,可以用
ohpm的overrides机制强制统一版本。
资源冲突方面,HarmonyOS的资源名在同一模块内必须唯一,跨模块则允许同名资源存在。但在HSP场景下,资源合并存在优先级,宿主会覆盖部分子模块的同名资源,这可能导致某些页面图片显示异常。最简单的做法是给每个模块的资源文件名加前缀,比如feature_bill_icon_empty.png,从根源上避免同名覆盖。
4.3 动态化场景下的启动优化
引入HSP之后,启动流程比单模块复杂,因为存在按需加载。如果启动页所在的入口模块依赖了某个延迟加载的HSP,那首帧时间可能被拉长。
优化技巧有几个:
- 关键路径上的模块设置
deliveryWithInstall: true,随包安装,避免用户等待动态分发。 - 非关键的HSP在应用启动后空闲时间预加载。HarmonyOS提供了
abilityManager和moduleManager相关的API来主动请求模块,具体可以在onWindowStageCreate之后延迟触发。 - 页面级代码不要过度拆分。比如登录页和用户信息页,如果业务关联紧密,放在同一个feature模块里,减少跨模块调用的次数。
实测在我这个案例里,把统计模块改为按需加载后,首包体积减少了约三成,启动耗时反而没有明显变化,因为统计模块本身的初始化逻辑很轻,不会阻塞主流程。
4.4 版本管理与升级策略
HSP支持独立升级,这对运营活动很有用,但也带来了版本管理的复杂度。
如果一个HSP模块升级到了2.0版本,但主应用还停留在1.0版本,接口兼容性就非常关键。我的策略是:
- 接口变更必须遵循“向后兼容”原则,只能增加接口,不能删除或修改旧接口签名。
- 如果要废弃旧接口,先在公共接口里标记
@deprecated,至少保留两个大版本后再移除。 - 每次发布前,用自动化测试跑一遍跨模块核心流程,特别是登录、账单展示这类高频率调用链路。
实践中遇到过极端情况:一个活动页HSP升级后引用了新的公共库方法,但没有同步升级common模块,导致运行时抛出方法找不到异常。后来我把所有版本的兼容性检查纳入CI,构建时自动比对依赖版本,这类问题就很少再出现了。
4.5 真机调试注意事项
HSP在模拟器上的表现和真机上会有细微差别,主要体现在动态分发和签名校验上。建议有条件就尽量用真机做最终验证。
真机调试时有一个小技巧:DevEco Studio的Run配置里可以选择“Deploy Haps”模式,指定部署哪些模块。开发时如果只改了feature_bill模块,可以只部署这个模块的hsp,不用重新装整个应用,能省不少时间。
另外,HSP模块的断点调试和单模块工程略有不同。DevEco Studio对跨HSP断点支持不算特别完善,有时候断点会跳到汇编代码或者直接失效。我遇到这种情况时,会在HSP模块里加日志,通过日志输出定位问题,反而比断点更高效。
注意:如果真机上出现了签名不匹配导致HSP加载失败,优先去AppGallery Connect重新下载证书,并确认本地的调试证书和生产证书没有混淆。这个问题在团队协作中非常常见,因为不同开发者的设备证书不同,构建产物互相覆盖后就容易出现“我这边能跑,你那边不能跑”的诡异问题。
5. 一点个人总结与后续扩展
这次重构做下来,我对Feature模块的定位有了更准确的认识。它确实是动态化开发的“瑞士军刀”,但核心不在于某个API或某个配置,而在于模块边界的划分思维。没有清晰的边界,再好的技术选型也会被复杂的依赖拖垮。
我个人的体会是,模块划分要以“业务变化频率”和“团队归属”为主要依据。高频率变化、多人协作的业务适合独立成feature模块;稳定且通用性强的底层能力适合放到common或公共库;入口模块则越薄越好,只做启动、路由分发和全局初始化。
这个案例做完之后,我又把扩展方向想了几个。一是引入编译期路由注解生成,替代手写的Map路由表,让新增页面更自动化;二是结合AppGallery Connect的动态交付接口,实现灰度发布和A/B测试;三是在HSP模块内部继续细分原子化服务,把更小的功能卡片做成免安装模式。这些方向各有难点,但前提都是把基础的多模块架构铺好。
最后分享一个小技巧:多模块工程里,模块数量多了之后,编译日志会变得特别长。我习惯在build-profile.json5里开启编译缓存并调整日志级别,只输出warning和error,找问题快很多。这些细节看起来不起眼,但在每天多次构建的节奏下,累积的时间节省非常可观。
