HarmonyOS Componentization Without Reflection: Contracts, Registries, and Unidirectional Dependencies
Based on the
hmsmoduledemosample project (login / registration / password change / forced game update / payment), explain the componentization implementation method of 'package type isolation + interface contract + unidirectional dependency'.
Screenshots
1. Why Componentization
In a monolithic project, all code is mixed in one module. As features grow, problems appear:
- Code coupling: changing payment might break login;
- Slow compilation: any single line change triggers a full rebuild;
- Impossible for multiple people to develop in parallel, constant conflicts.
The core idea of componentization: split packages by business boundaries, modules communicate only through 'contracts', no one knows anyone else's implementation.
Three iron rules of this project:
- Package Type Isolation — Different responsibilities use different package types (HAP / HAR / HSP);
- Interface Contract — Cross-module calls only depend on interfaces in
common_api, not on implementation classes; - Unidirectional Dependency — Upper layers can depend on lower layers, lower layers absolutely never depend on upper layers, business modules do not import each other.
2. Three Package Types: HAP / HAR / HSP
| Package Type | Full Name | Characteristics | Role in This Project |
|---|---|---|---|
| HAP | HarmonyOS Ability Package | App installation/runtime unit, can contain Abilities, finally packaged into the app | entry: Shell project |
| HAR | HarmonyOS Archive | Static shared package, each HAP that references it gets its own copy, code is copied with the package | common / common_api / user_module / pay_module / update_module |
| HSP | HarmonyOS Shared Package | Dynamic shared package, only one copy exists within the same device and app, shared at runtime | common_service: Global login state |
Key memory points for differentiation:
- Used by multiple modules but requires only one global state (like login state, config center) → Must be placed in HSP, otherwise the HAR copy in each HAP has its own static variables, causing state 'fragmentation'.
- Pure utilities, pure contracts (stateless) → Just use HAR.
- Package types are declared in each module's
src/main/module.json5:"type": "entry"/"har"/"shared".
3. Overall Architecture: Unidirectional Dependency Layering
entry (Entry HAP) ← Shell project: Navigation assembly + module initialization
├── user_module (HAR) Login / Registration / Change Password
├── pay_module (HAR) Payment Center (Product → Order → Pay)
├── update_module (HAR) Forced Update (Version check + mandatory update dialog)
├── common_service(HSP) SessionManager: Login state AppStorage wrapper (global singleton)
├── common_api (HAR) Contract layer: IUserService / IPayService / IUpdateService
│ ServiceRegistry / RouteCenter / Models / Event constants
└── common (HAR) Basic capabilities: Logger / Validators / MockHttp
entryis only responsible for two things: initializing each module (EntryAbility.onCreate) and assembling the Navigation route shell (pages/Index).- Business modules (user / pay / update) have no mutual imports; they only depend downwards on
common_api(contracts),common_service(state),common(utilities). - Want to call another module's function? Don't import the implementation class, ask
ServiceRegistryfor the interface.
Dependency relationships are reflected in each module's oh-package.json5 under file: local dependencies, for example entry/oh-package.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"
}
4. Four Inter-Module Communication Mechanisms (Key Points)
ArkTS does not support reflection (no Class.forName), so 'interface finding implementation' must be done explicitly. This project provides four complementary mechanisms:
4.1 Request-Response: ServiceRegistry (Interface Registry)
Location: common_api/src/main/ets/registry/ServiceRegistry.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 { ... }
}
Workflow (using payment as an example):
- Contract defined in common_api:
IPayServiceinterface declaresqueryProducts / createOrder / pay; - Implementer registers: Inside
PayModule.init(),ServiceRegistry.register<IPayService>(ServiceKeys.PAY, new PayServiceImpl()); - Consumer gets: Any page
ServiceRegistry.get<IPayService>(ServiceKeys.PAY)?.createOrder(...).
Key points:
- The type the consumer gets is the interface, completely unaware of
PayServiceImpl's existence at compile time — this is 'programming to an interface'; - Keys are uniformly collected in the
ServiceKeysconstant class, preventing scattered strings; - Suitable for 'ask once, answer once' calls (login, create order, check version).
4.2 Page Navigation: RouteCenter + Navigation Module Self-Registration
Location: common_api/src/main/ets/registry/RouteCenter.ets
HarmonyOS page routing uses the Navigation + NavDestination system, but the route table is centralized in entry, while business pages are in various HARs. entry should not directly import business pages (otherwise the shell and business are coupled again). The solution:
- Each business module declares a page builder function using
@Builder, and during module initialization, wraps it into aWrappedBuildervia the global functionwrapBuilderand registers it with RouteCenter:
// user_module/UserModule.ets
@Builder
function LoginPageBuilder(params: RouteParams): void {
LoginView({ params });
}
RouteCenter.register({ name: RouteNames.LOGIN, builder: wrapBuilder(LoginPageBuilder) });
entry'snavDestinationuniformly retrieves the builder from RouteCenter by name:
// 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(`Page not registered: ${name}`)
}
}
...
}
- Navigation becomes purely data-driven:
RouteCenter.push(pathStack, RouteNames.PAY_CENTER).
Key points:
- Route names are centralized in the
RouteNamesconstant class; - Route parameters are uniformly encapsulated as
RouteParams(containingpathStackandargs), passing parameters between pages does not depend on specific types; WrappedBuilder/wrapBuilder/NavPathStackare global symbols, do not need and cannot be imported from@kit.ArkUI(this project stepped on this pitfall).
4.3 Global State: AppStorage + HSP Singleton
Location: common_service/src/main/ets/session/SessionManager.ets
export class SessionManager {
static onLogin(user: UserInfo): void {
AppStorage.setOrCreate('isLogin', true);
AppStorage.setOrCreate('KEY_USER_INFO', user);
...
}
}
- Writer (after
UserServiceImpl.loginsucceeds) callsSessionManager.onLogin(); - Reader (Home page, Payment page) uses
@StorageLink('isLogin')reactive binding, UI automatically refreshes when login state changes, no need to manually send notifications:
@StorageLink('isLogin') isLogin: boolean = false;
- Why HSP: AppStorage is one per app, but if the
SessionManagerclass is placed in HAR, it gets packaged once per HAP/HSP. Static methods and constants don't conflict, but the class 'identity' will split (theUserInfotype stored by module A is not the same class as the one read by module B). Placing it in HSP ensures only one implementation exists across the entire app.
4.4 Notification Events: emitter Event Bus
Location: common_api/src/main/ets/constants/Events.ets
// Send (after UserServiceImpl login succeeds)
emitter.emit({ eventId: EVENT_USER_LOGIN }, { data: { userId } });
Usage principles (discipline written in project comments):
- Events are only used for 'notification-type' communication (I logged in successfully, whoever cares can listen), no return value expected;
- Request-response always goes through ServiceRegistry, do not use emitter to simulate calls (otherwise timing and error handling will spiral out of control);
- eventId is a number, define constants centrally:
EVENT_USER_LOGIN = 1001,EVENT_PAY_SUCCESS = 2001,EVENT_FORCE_UPDATE = 3001, etc.
How to Choose Among the Four Mechanisms
| Requirement | Use |
|---|---|
| Call someone else's function and get a result | ServiceRegistry |
| Navigate to a page | RouteCenter |
| Multiple UIs share a state and need automatic refresh | AppStorage (wrapped in HSP) |
| Broadcast a notification after completing an action | emitter |
5. Key Business Flow Walkthroughs
5.1 Startup Initialization
EntryAbility.onCreate (entry/src/main/ets/entryability/EntryAbility.ets):
UserModule.init();
UpdateModule.init();
PayModule.init();
Each init() does two things: registers the service implementation with ServiceRegistry, registers the page builder with RouteCenter. Explicit registration replaces reflection, traceable at compile time. If a module isn't initialized, you'll know immediately at runtime (service get returns undefined, page displays 'not registered').
5.2 Login Flow
LoginView → IUserService.login() → UserServiceImpl validates account via MockHttp → On success:
SessionManager.onLogin(user)writes to AppStorage;emitter.emit(EVENT_USER_LOGIN)broadcasts;- Home page
@StorageLink('isLogin')automatically refreshes to show 'Hello, Test Player'.
Test account: 13800000000 / abc123.
5.3 Forced Update Flow
Home page aboutToAppear → bundleManager.getBundleInfoForSelf reads local versionCode (AppScope configured as 100) → IUpdateService.checkUpdate() compares against Mock server minSupportCode=150 → 100 < 150 triggers mandatory dialog ForceUpdateDialog (autoCancel: false, only 'Update Now' or 'Exit Game').
Note the forced update dialog does not go through routing: it's a component, not a page, directly exported by update_module for entry to use — component-level reuse can be directly exported, page-level navigation requires RouteCenter.
5.4 Payment Flow
PayCenterView loads product list → selects channel (Huawei/WeChat/Alipay placeholder) → createOrder → pay:
- Page layer first checks
@StorageLink('isLogin'), redirects to login page if not logged in (first gate); - Service layer
createOrderchecksSessionManager.isLogin()again, returns 401 if not logged in (second gate, prevents bypassing UI to call API directly) — state validation must have a safety net at the service layer; - Payment success
emitter.emit(EVENT_PAY_SUCCESS).
6. Design Intent of the Mock Network Layer
common/src/main/ets/network/MockHttp.ets uses setTimeout(600ms) + in-memory data to simulate the network:
static request(api: string, params: ESObject, handler: (p: ESObject) => MockResponse): Promise<MockResponse>
All business code is written in the form of 'async request → get response → handle code'. In a real project, just replace MockHttp's implementation with @kit.NetworkKit's http.createHttp().request(), and the business modules do not need a single line changed — this is the replacement freedom brought by contracts/layering.
7. Pitfalls Encountered (Errors Actually Fixed in This Project)
These errors are very typical in componentized HarmonyOS projects and worth remembering:
- HAR module missing
src/main/module.json5→ Build errormodule.json5 file not found. Every module must have one,typefilled withhar/shared/entry. AppScope/app.json5missingicon/label→ Schema validation fails. Also, icon resources must be placed underAppScope/resources/, cannot reference entry's resources.- Key name in
main_pages.json: API 12 requires"src": [...], not"pages": [...]. - Do not write
@ohos/hvigordependency in rootoh-package.json5: hvigor plugin is managed byhvigor/hvigor-config.json5, writing it in oh-package causesohpm installto fetch from ohpm registry and report 404. 0xDEMOis not a valid hexadecimal (M is not a hex char) → Compilation throws a strange binary expression error.WrappedBuilder/NavPathStack/wrapBuilderare global symbols, importing from@kit.ArkUIinstead reports "has no exported member".- ArkTS does not allow object literals as types:
{ key: PayChannel; label: string }[]must be extracted into aninterface. - Cannot write regular statements inside UI build scopes: Declarations like
const route = ...are not allowed inside@Builder/build(), change toif/else+ component calls. - System color resource names must align with SDK: e.g.,
sys.color.ohos_id_color_sub_emphasizedoes not exist in the current SDK, check the SDK directorytoolchains/id_defined.jsonto confirm available names. - Fix order: First fix package structure/resources (PreBuild), then install dependencies (ohpm install), finally ArkTS compilation errors — preceding errors will mask the real errors behind them.
8. Hands-on Exercise Suggestions
- Add a 'Settings Module' (setting_module HAR): Provide
ISettingServicecontract (read/save volume switch), add an entry on the entry home page. Go through the full process of 'define contract → implement registration → page self-registration → shell assembly'. - Change the product list to refresh via emitter notification: Simulate 'product inventory changes after successful payment', experience the division of labor between event bus and ServiceRegistry.
- Replace MockHttp with real http requests: Verify if business modules truly require zero changes.
- Intentionally let user_module import pay_module's implementation class, observe what happens to compilation/architecture when dependency rules are broken, then change back to contract calls.
9. Reference File Quick Lookup
| Content | Path |
|---|---|
| Service Registry | common_api/src/main/ets/registry/ServiceRegistry.ets |
| Route Center | common_api/src/main/ets/registry/RouteCenter.ets |
| Login State Wrapper (HSP) | common_service/src/main/ets/session/SessionManager.ets |
| Event Constants | common_api/src/main/ets/constants/Events.ets |
| Route Name Constants | common_api/src/main/ets/constants/RouteNames.ets |
| Shared Data Models | common_api/src/main/ets/model/Models.ets |
| Mock Network Layer | common/src/main/ets/network/MockHttp.ets |
| Module Initialization Entry | entry/src/main/ets/entryability/EntryAbility.ets |
| Navigation Assembly | entry/src/main/ets/pages/Index.ets |
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
I've also studied HarmonyOS componentization before, following the official recommendations, which is relatively simple.
This lacks the ability to freely switch between application and library like on Android, so it still falls a bit short.