基于
hmsmoduledemo示例工程(登录 / 注册 / 修改密码 / 游戏强更 / 支付), 讲清「包类型隔离 + 接口契约 + 单向依赖」的组件化落地方式。
效果图





一、为什么要组件化
单体工程里所有代码揉在一个模块,随着功能变多会出现:
- 代码耦合,改支付可能碰坏登录;
- 编译慢,任何一行改动全量重编;
- 无法多人并行开发,冲突不断。
组件化的核心思路:按业务边界拆包,模块之间只通过"契约"通信,谁也不认识谁的实现。
本工程的三条铁律:
- 包类型隔离 ------ 不同职责用不同包类型(HAP / HAR / HSP);
- 接口契约 ------ 跨模块调用只依赖
common_api里的 interface,不依赖实现类; - 单向依赖 ------ 上层可依赖下层,下层绝不依赖上层,业务模块之间互不 import。
二、三种包类型:HAP / HAR / HSP
| 包类型 | 全称 | 特点 | 本工程中的角色 |
|---|---|---|---|
| HAP | HarmonyOS Ability Package | 应用安装/运行单元,可含 Ability,最终打进 app 包 | entry:壳工程 |
| HAR | HarmonyOS Archive | 静态共享包,每个引用它的 HAP 各自拷贝一份,代码随包复制 | common / common_api / user_module / pay_module / update_module |
| HSP | HarmonyOS Shared Package | 动态共享包,同设备同应用内只存在一份,运行时共享 | common_service:全局登录态 |
关键区别记忆点:
- 多个模块都要用、但要求全局只有一份状态 (如登录态、配置中心)→ 必须放 HSP,否则每个 HAP 里的 HAR 副本各有自己的静态变量,状态会"分裂"。
- 纯工具、纯契约(无状态)→ 放 HAR 即可。
- 包类型在各自
src/main/module.json5里声明:"type": "entry"/"har"/"shared"。
三、整体架构:单向依赖分层
scss
entry (Entry HAP) ← 壳工程:Navigation 组装 + 模块初始化
├── user_module (HAR) 登录 / 注册 / 修改密码
├── pay_module (HAR) 支付中心(商品 → 下单 → 支付)
├── update_module (HAR) 强更(版本检查 + 强制更新弹窗)
├── common_service(HSP) SessionManager:登录态 AppStorage 封装(全局单例)
├── common_api (HAR) 契约层:IUserService / IPayService / IUpdateService
│ ServiceRegistry / RouteCenter / 模型 / 事件常量
└── common (HAR) 基础能力:Logger / Validators / MockHttp
entry只负责两件事:初始化各模块 (EntryAbility.onCreate)和 组装 Navigation 路由壳 (pages/Index)。- 业务模块(user / pay / update)之间没有任何相互 import ;它们只向下依赖
common_api(契约)、common_service(状态)、common(工具)。 - 想调用别人的功能?不 import 实现类,而是问
ServiceRegistry要接口。
依赖关系体现在各模块 oh-package.json5 的 file: 本地依赖里,例如 entry/oh-package.json5:
json5
"dependencies": {
"@demo/common": "file:../common",
"@demo/common_api": "file:../common_api",
"@demo/common_service": "file:../common_service",
"@demo/user_module": "file:../user_module",
"@demo/update_module": "file:../update_module",
"@demo/pay_module": "file:../pay_module"
}
四、四种模块间通信机制(重点)
ArkTS 不支持反射(没有 Class.forName),所以"接口找实现"必须显式完成。本工程给出四种互补机制:
4.1 请求-响应:ServiceRegistry(接口注册表)
位置:common_api/src/main/ets/registry/ServiceRegistry.ets
ets
export class ServiceRegistry {
private static services: Map<string, Object> = new Map<string, Object>();
static register<T extends Object>(key: string, impl: T): void { ... }
static get<T extends Object>(key: string): T | undefined { ... }
}
工作流程(以支付为例):
- 契约定义在 common_api :
IPayService接口声明queryProducts / createOrder / pay; - 实现方注册 :
PayModule.init()里ServiceRegistry.register<IPayService>(ServiceKeys.PAY, new PayServiceImpl()); - 使用方获取 :任意页面
ServiceRegistry.get<IPayService>(ServiceKeys.PAY)?.createOrder(...)。
要点:
- 使用方拿到的类型是接口 ,编译期完全不知道
PayServiceImpl的存在 ------ 这就是"面向接口编程"; - key 统一收敛在
ServiceKeys常量类,防止字符串散落; - 适合"问一句、答一句"的调用(登录、下单、查版本)。
4.2 页面跳转:RouteCenter + Navigation 模块自注册
位置:common_api/src/main/ets/registry/RouteCenter.ets
鸿蒙页面路由是 Navigation + NavDestination 体系,但路由表集中在 entry,业务页面却在各个 HAR 里,entry 不该直接 import 业务页面(否则壳和业务又耦合了)。解法:
- 各业务模块用
@Builder声明页面构建函数,模块初始化时通过全局函数wrapBuilder包成WrappedBuilder注册进 RouteCenter:
ets
// user_module/UserModule.ets
@Builder
function LoginPageBuilder(params: RouteParams): void {
LoginView({ params });
}
RouteCenter.register({ name: RouteNames.LOGIN, builder: wrapBuilder(LoginPageBuilder) });
entry的navDestination统一从 RouteCenter 按名字取 builder:
ets
// entry/pages/Index.ets
@Builder
pageMap(name: string, param: ESObject): void {
NavDestination() {
if (RouteCenter.has(name)) {
RouteCenter.get(name)?.builder.builder(param as RouteParams)
} else {
Text(`页面未注册:${name}`)
}
}
...
}
- 跳转变成纯数据驱动:
RouteCenter.push(pathStack, RouteNames.PAY_CENTER)。
要点:
- 路由名集中在
RouteNames常量类; - 路由参数统一封装为
RouteParams(含pathStack和args),页面间传参不依赖具体类型; WrappedBuilder/wrapBuilder/NavPathStack是全局符号 ,不需要也不能从@kit.ArkUIimport(本工程踩过这个坑)。
4.3 全局状态:AppStorage + HSP 单例
位置:common_service/src/main/ets/session/SessionManager.ets
ets
export class SessionManager {
static onLogin(user: UserInfo): void {
AppStorage.setOrCreate('isLogin', true);
AppStorage.setOrCreate('KEY_USER_INFO', user);
...
}
}
- 写入方(
UserServiceImpl.login成功后)调SessionManager.onLogin(); - 读取方(首页、支付页)用
@StorageLink('isLogin')响应式绑定,登录态一变 UI 自动刷新,不需要手动发通知:
ets
@StorageLink('isLogin') isLogin: boolean = false;
- 为什么放 HSP:AppStorage 是全应用一份,但
SessionManager这个类如果放 HAR,会被每个 HAP/HSP 各自打包一份,静态方法和常量虽然不冲突,但类的"身份"会分裂 (A 模块存的UserInfo类型和 B 模块读的不是同一个类)。放 HSP 保证全应用只有一份实现。
4.4 通知型事件:emitter 事件总线
位置:common_api/src/main/ets/constants/Events.ets
ets
// 发送(UserServiceImpl 登录成功后)
emitter.emit({ eventId: EVENT_USER_LOGIN }, { data: { userId } });
使用原则(工程注释里写明的纪律):
- 事件只用于"通知型"通信(我登录成功了,谁关心谁听),不期待返回值;
- 请求-响应一律走 ServiceRegistry,不要用 emitter 模拟调用(否则时序和错误处理会失控);
- eventId 是 number,集中定义常量:
EVENT_USER_LOGIN = 1001、EVENT_PAY_SUCCESS = 2001、EVENT_FORCE_UPDATE = 3001等。
四种机制怎么选
| 需求 | 用哪个 |
|---|---|
| 调别人的功能拿结果 | ServiceRegistry |
| 跳页面 | RouteCenter |
| 多个 UI 共享一个状态且要自动刷新 | AppStorage(HSP 封装) |
| 做完一件事广播一声 | emitter |
五、关键业务流程走读
5.1 启动初始化
EntryAbility.onCreate(entry/src/main/ets/entryability/EntryAbility.ets):
ets
UserModule.init();
UpdateModule.init();
PayModule.init();
每个 init() 做两件事:向 ServiceRegistry 注册服务实现、向 RouteCenter 注册页面 builder。显式注册替代反射,编译期可追踪,哪个模块没初始化一跑就知道(服务 get 到 undefined、页面显示"未注册")。
5.2 登录链路
LoginView → IUserService.login() → UserServiceImpl 走 MockHttp 校验账号 → 成功后:
SessionManager.onLogin(user)写 AppStorage;emitter.emit(EVENT_USER_LOGIN)广播;- 首页
@StorageLink('isLogin')自动刷新出"你好,测试玩家"。
测试账号:13800000000 / abc123。
5.3 强更链路
首页 aboutToAppear → bundleManager.getBundleInfoForSelf 读本机 versionCode(AppScope 配置 100)→ IUpdateService.checkUpdate() 对比 Mock 服务端 minSupportCode=150 → 100 < 150 触发强制弹窗 ForceUpdateDialog(autoCancel: false,只能"立即更新"或"退出游戏")。
注意强更弹窗不走路由 :它是组件不是页面,由 update_module 直接导出给 entry 用 ------ 组件级复用可以直接导出,页面级跳转才需要 RouteCenter。
5.4 支付链路
PayCenterView 加载商品列表 → 选渠道(华为/微信/支付宝占位)→ createOrder → pay:
- 页面层先查
@StorageLink('isLogin'),未登录跳登录页(第一道闸); - 服务层
createOrder再查SessionManager.isLogin(),未登录返回 401(第二道闸,防止绕过 UI 直接调接口)------ 状态校验要在服务层兜底; - 支付成功
emitter.emit(EVENT_PAY_SUCCESS)。
六、Mock 网络层的设计意图
common/src/main/ets/network/MockHttp.ets 用 setTimeout(600ms) + 内存数据模拟网络:
ets
static request(api: string, params: ESObject, handler: (p: ESObject) => MockResponse): Promise<MockResponse>
所有业务代码都写成"异步请求 → 拿响应 → 处理 code"的形式。真实项目只需把 MockHttp 的实现换成 @kit.NetworkKit 的 http.createHttp().request(),业务模块一行不用改 ------ 这就是契约/分层带来的替换自由度。
七、踩坑记录(本工程实际修过的错)
这些错误在组件化鸿蒙工程里非常典型,值得记住:
- HAR 模块缺
src/main/module.json5→ 构建报module.json5 file not found。每个模块都必须有,type填har/shared/entry。 AppScope/app.json5缺icon/label→ schema 校验失败。且 icon 资源必须放在AppScope/resources/下,不能引用 entry 的资源。main_pages.json的键名 :API 12 要求"src": [...],不是"pages": [...]。- 根
oh-package.json5不要写@ohos/hvigor依赖 :hvigor 插件由hvigor/hvigor-config.json5管理,写进 oh-package 会让ohpm install去 ohpm 仓库拉取报 404。 0xDEMO不是合法十六进制(M 不是 hex 字符)→ 编译报奇怪的二元表达式错误。WrappedBuilder/NavPathStack/wrapBuilder是全局符号 ,从@kit.ArkUIimport 反而报 "has no exported member"。- ArkTS 不允许对象字面量当类型 :
{ key: PayChannel; label: string }[]要抽成interface。 - UI 构建作用域里不能写普通语句 :
@Builder/build()里不允许const route = ...这类声明,要改成if/else+ 组件调用。 - 系统色资源名要和 SDK 对齐 :如
sys.color.ohos_id_color_sub_emphasize在当前 SDK 不存在,可查 SDK 目录toolchains/id_defined.json确认可用名。 - 修复顺序:先修包结构/资源(PreBuild),再装依赖(ohpm install),最后才是 ArkTS 编译错误 ------ 前置错误会掩盖后面的真实报错。
八、动手练习建议
- 加一个"设置模块"(setting_module HAR) :提供
ISettingService契约(读取/保存音量开关),在 entry 首页加入口。走完"定义契约 → 实现注册 → 页面自注册 → 壳组装"全流程。 - 把商品列表改成 emitter 通知刷新:模拟"支付成功后商品库存变化",体会事件总线与 ServiceRegistry 的分工。
- 把 MockHttp 换成真实 http 请求:验证业务模块是否真的零改动。
- 故意让 user_module import pay_module 的实现类,观察依赖规则被破坏后编译/架构上会发生什么,再改回契约调用。
九、参考文件速查
| 内容 | 路径 |
|---|---|
| 服务注册表 | common_api/src/main/ets/registry/ServiceRegistry.ets |
| 路由中心 | common_api/src/main/ets/registry/RouteCenter.ets |
| 登录态封装(HSP) | common_service/src/main/ets/session/SessionManager.ets |
| 事件常量 | common_api/src/main/ets/constants/Events.ets |
| 路由名常量 | common_api/src/main/ets/constants/RouteNames.ets |
| 共享数据模型 | common_api/src/main/ets/model/Models.ets |
| Mock 网络层 | common/src/main/ets/network/MockHttp.ets |
| 模块初始化入口 | entry/src/main/ets/entryability/EntryAbility.ets |
| Navigation 组装 | entry/src/main/ets/pages/Index.ets |