接口与实现分离 --- MoneyTrack Types.ets 与 Models.ets 的双层设计模式

概述
在鸿蒙 ArkTS 开发中,如何平衡类型安全与响应式状态管理是一项关键设计决策。TypeScript/ArkTS 的接口(interface)提供了纯粹的编译时类型约束,而 @ObservedV2 + @Trace 装饰器实现了运行时响应式能力。然而,这两者之间存在天然的张力:接口不能被装饰器修饰,装饰器类又无法作为跨模块的轻量类型契约。
为什么需要接口与实现分离? 在跨模块协作中,类型安全是一个痛点。例如,bill_base 模块定义的 BillCardItem 接口被 bill_data_processing 模块消费,同时 UI 层又需要响应式追踪其字段变化。如果直接在 UI 层使用接口类型(纯数据结构),变更无法触发渲染;如果所有模块都依赖 @ObservedV2 类,则非 UI 模块(如数据层、工具库)会被迫引入不必要的 ArkUI 运行时依赖。MoneyTrack 采用接口(Types.ets)与实现类(Models.ets)分离的双层设计模式:接口层专注于类型定义,保持纯粹的数据结构描述;实现层使用 @ObservedV2 和 @Trace 装饰器实现响应式能力。这种模式既保证了跨模块的类型安全引用,又提供了高效的变更检测能力,同时避免了非 UI 模块对 ArkUI 框架的耦合。
核心知识点
1. 接口层(Types.ets)------ 纯类型定义
在 bill_base 模块的 Types.ets 中,定义了多个接口,如 BillCardItem、DailyBillGroup、BalanceResourceItem。每个接口仅声明属性和类型,不使用任何装饰器:
typescript
// bill_base/src/main/ets/commons/Types.ets
export interface BillCardItem {
accountId: number;
date: string;
transactionId: number;
resource: number;
icon: ResourceStr;
title: string;
note?: string;
amount: number;
type: BalanceChangeType;
assetId?: number;
assetName?: string;
memberId?: number;
memberName?: string;
memberAvatar?: ResourceStr;
}
export interface DailyBillGroup {
dateStr: string;
list: BillCardItem[];
}
export interface BalanceResourceItem {
key: number;
name: string;
icon: ResourceStr;
uncheckedIcon: ResourceStr;
type: BalanceChangeType;
sortNum: number;
isCustom?: number;
}
接口的最大优势在于可被任意模块引用(包括非 UI 模块,如数据层 bill_data_processing 或工具库 commonlib),不引入响应式开销,适合作为跨模块数据传输的契约。
2. 实现层(Models.ets)------ 响应式封装
对应的 Models.ets 文件使用 @ObservedV2 和 @Trace 装饰器实现接口:
typescript
// bill_base/src/main/ets/commons/Models.ets
@ObservedV2
export class DailyBillGroupModel implements DailyBillGroup {
@Trace dateStr: string = '';
@Trace list: BillCardItem[] = [];
}
@ObservedV2
export class BillCardItemModel implements BillCardItem {
@Trace accountId: number = 0;
@Trace date: string = '';
@Trace transactionId: number = -1;
@Trace resource: number = 0;
@Trace icon: ResourceStr = '';
@Trace title: string = '';
@Trace note?: string;
@Trace amount: number = 0;
@Trace type: BalanceChangeType = BalanceChangeType.EXPENSE;
@Trace memberId?: number;
@Trace memberName?: string;
@Trace memberAvatar?: ResourceStr;
}
@ObservedV2 启用属性级变更追踪,@Trace 标记需要观察的字段。这样 UI 组件仅在其关心的字段变化时重新渲染。注意 BillCardItemModel 实现了 BillCardItem 接口,所有字段都添加了 @Trace 装饰器及默认值。
此外,并非所有 Model 都需要实现接口。BalanceResourceItemModel 和 BalanceResourceModel 仅在 UI 层内部使用,不跨模块,因此直接定义为 @ObservedV2 类而不实现接口:
typescript
@ObservedV2
export class BalanceResourceItemModel {
@Trace key: number = 0;
@Trace name: string = '';
@Trace type: BalanceChangeType = BalanceChangeType.EXPENSE;
@Trace sortNum: number = 0;
@Trace isCustom?: number = 0;
}
@ObservedV2
export class BalanceResourceModel {
@Type(BalanceResourceItemModel)
@Trace income: BalanceResourceItemModel[] = [];
@Type(BalanceResourceItemModel)
@Trace expense: BalanceResourceItemModel[] = [];
}
3. 对照设计举例:asset_base 模块
asset_base 模块遵循同样的双层设计模式。其 Types.ets 定义了完整的资产相关接口体系:
typescript
// asset_base/src/main/ets/commons/Types.ets
export interface AssetRecordItem {
assetId: number;
name: string;
icon: ResourceStr;
type: AssetType;
subType: number;
category: AssetCategory;
amount: number;
note?: string;
isCustom?: boolean;
ownerId?: number;
}
export interface AssetGroup {
fund: AssetRecordItem[];
credit: AssetRecordItem[];
}
export interface AssetFilterState {
selectedIds: Set<number>;
isAllSelected: boolean;
}
对应的 Models.ets 实现了响应式版本:
typescript
// asset_base/src/main/ets/commons/Models.ets
@ObservedV2
export class AssetGroupModel implements AssetGroup {
@Trace fund: AssetRecordItem[] = [];
@Trace credit: AssetRecordItem[] = [];
}
@ObservedV2
export class AssetRecordItemModel implements AssetRecordItem {
@Trace assetId: number = 0;
@Trace name: string = '';
@Trace icon: ResourceStr = '';
@Trace type: AssetType = AssetType.FUND;
@Trace subType: number = -1;
@Trace category: AssetCategory = AssetCategory.ASSET;
@Trace note: string = '';
@Trace amount: number = 0;
@Trace isCustom: boolean = false;
@Trace ownerId?: number;
}
注意 AssetFilterState 仅在 Types.ets 中有定义,没有对应的 Model 实现------因为它是一个纯状态快照,不需要响应式追踪,UI 层直接消费接口即可。
4. 深入设计:FamilyModels.ets 中的同文件双定义
bill_base 模块还提供了一个极佳的对照案例------FamilyModels.ets 文件将接口与实现定义在同一个文件中:
typescript
// bill_base/src/main/ets/commons/FamilyModels.ets
export interface FamilyInfo {
familyId: number;
familyName: string;
inviteCode: string;
createdAt: string;
}
@ObservedV2
export class FamilyInfoModel implements FamilyInfo {
@Trace familyId: number = 0;
@Trace familyName: string = '';
@Trace inviteCode: string = '';
@Trace createdAt: string = '';
}
这种方式的优点是同一模块内使用时更紧凑,缺点是无法像 Types.ets 那样被外部模块仅依赖类型而不引入类体。MoneyTrack 对此的取舍是:跨多个模块复用的核心数据类型走双层文件分离,模块内使用的数据类型走同文件双定义。
5. Types.ets 与 Models.ets 的设计差异对比
| 维度 | Types.ets(接口层) | Models.ets(实现层) |
|---|---|---|
| 核心装饰器 | 无 | @ObservedV2 + @Trace |
| 默认值 | 不设默认值,仅声明类型 | 每个字段必须赋初始值 |
| 运行时开销 | 零运行时开销,编译期消失 | 有响应式追踪的运行时开销 |
| 可被引用方 | 任意模块(UI、数据层、工具库) | 主要被 UI 层和 ViewModel 引用 |
| 变更检测 | 无变更检测能力 | @Trace 字段变化自动通知 UI |
| 跨模块耦合 | 低,纯类型导入 | 高,引入类加载和装饰器初始化 |
| 嵌套对象支持 | 天然支持 | 需要 @Type() 装饰器辅助 |
| 典型场景 | API 返回类型、函数参数、跨模块 DTO | UI 组件数据源、ViewModel 持有状态 |
6. 接口与实现的映射关系表
| 模块 | 接口(Types.ets / FamilyModels.ets) | 实现类(Models.ets) | 实现方式 |
|---|---|---|---|
| bill_base | BillCardItem |
BillCardItemModel |
implements |
| bill_base | DailyBillGroup |
DailyBillGroupModel |
implements |
| bill_base | FamilyInfo |
FamilyInfoModel |
implements(同文件) |
| bill_base | FamilyMember |
FamilyMemberModel |
implements(同文件) |
| bill_base | AccountBook |
AccountBookModel |
implements(同文件) |
| bill_base | --- | BalanceResourceItemModel |
无接口,纯 UI 类 |
| bill_base | --- | BalanceResourceModel |
无接口,纯 UI 类 |
| asset_base | AssetRecordItem |
AssetRecordItemModel |
implements |
| asset_base | AssetGroup |
AssetGroupModel |
implements |
| asset_base | AssetTypeItem |
AssetTypeItemModel |
implements |
| asset_base | --- | AssetTypeListModel |
无接口,纯 UI 类 |
| asset_base | AssetFilterState |
--- | 无实现,直接消费接口 |
7. 架构层级关系
以下是 MoneyTrack 中接口与实现层的架构关系图:
渲染错误: Mermaid 渲染失败: Parse error on line 15: ... Models.ets M1[BillCardItemModel... ----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'
数据流向清晰:非 UI 层依赖接口(纯类型),UI 层依赖 Model(响应式),ViewModel 作为桥梁完成类型转换和状态管理。
8. 何时使用接口 vs 直接使用 @ObservedV2 类
以下决策指南帮助你在实际开发中选择合适的方式:
| 使用场景 | 推荐方式 | 理由 |
|---|---|---|
| API / 数据库返回的数据结构 | 定义接口 | 数据层不应依赖 UI 框架装饰器 |
| 跨模块函数参数和返回值 | 定义接口 | 降低模块间耦合,仅传递类型 |
| 组件 Props 类型声明 | 定义接口 | 保持父组件与子组件的类型契约 |
| UI 组件内部持有的数据源 | @ObservedV2 类 |
需要响应式触发渲染 |
| ViewModel 中的状态字段 | @ObservedV2 类 |
需要 @Trace 实现自动更新 |
| 工具函数 / 纯计算场景 | 接口或普通类 | 无需响应式,避免运行时开销 |
| 同一模块内的临时数据结构 | 直接定义类 | 无需额外文件拆分 |
| 被 3 个以上模块引用的核心实体 | 接口 + 实现分离 | 兼顾跨模块引用和 UI 响应式 |
最佳实践判断法 :如果一个数据类型需要从数据层传递到 UI 层并支持响应式更新,应当同时定义接口和实现;如果仅在 UI 层内部流转,直接使用 @ObservedV2 类即可;如果仅在非 UI 层使用,仅定义接口。
项目案例
d:\HarmonyOS\WorkSpace\MoneyTrack1.0.3\components\bill_base\src\main\ets\commons\Types.ets 文件定义了 11 个接口,d:\HarmonyOS\WorkSpace\MoneyTrack1.0.3\components\bill_base\src\main\ets\commons\Models.ets 实现了 5 个 @ObservedV2 类,d:\HarmonyOS\WorkSpace\MoneyTrack1.0.3\components\bill_base\src\main\ets\commons\FamilyModels.ets 实现了 3 个同文件接口-实现对。d:\HarmonyOS\WorkSpace\MoneyTrack1.0.3\components\asset_base\src\main\ets\commons\Types.ets 定义了 8 个接口,对应 d:\HarmonyOS\WorkSpace\MoneyTrack1.0.3\components\asset_base\src\main\ets\commons\Models.ets 中的 4 个实现类,二者形成清晰的对应关系。
最佳实践与注意事项
-
接口字段要保持最小完备性 :接口中只声明调用方真正需要的字段,避免"大而全"的接口。这样 Model 实现时可以根据 UI 需求选择性地添加额外字段(如
BillCardItemModel比BillCardItem多了memberAvatar字段),不影响已有调用方。 -
Model 必须提供合理的默认值 :
@ObservedV2类在构造函数中需要给每个@Trace字段赋初值。推荐使用"零值"作为默认值(如number为 0、string为''、对象为null),避免使用undefined导致的渲染异常。 -
嵌套对象数组需使用
@Type()装饰器 :当 Model 中包含其他@ObservedV2对象的数组时(如BalanceResourceModel.income的类型为BalanceResourceItemModel[]),必须使用@Type(BalanceResourceItemModel)装饰器标记,否则框架无法正确追踪嵌套对象的属性变更。 -
非 UI 模块绝不引用 Models.ets :数据层(如
AccountingDB)和工具库只应依赖Types.ets中的接口。如果数据层导入了@ObservedV2类,会导致非 UI 场景(如单元测试、后台任务)加载不必要的 ArkUI 框架代码。 -
接口与模型的映射建议保持一一对应 :虽然并非所有接口都需要 Model 实现(如
AssetFilterState),但当一个接口需要 Model 时,建议保持命名一致(如Foo→FooModel),并放在固定的模块路径下,方便团队成员快速定位。
总结
MoneyTrack 的双层设计模式(Types.ets 接口层 + Models.ets 实现层)成功解决了跨模块类型安全与 UI 响应式需求之间的矛盾。接口层提供了轻量、零开销的类型契约,可以被任何模块引用;实现层通过 @ObservedV2 和 @Trace 装饰器提供了高效的响应式能力,服务于 UI 渲染层。这种分离设计使得数据层可以保持纯净,UI 层获得响应式能力,而二者之间的桥梁------ViewModel------通过类型转换完成数据的流向编排。对于中大型鸿蒙应用,这是一种值得推广的架构模式。
参考文档
- ArkTS 接口(interface)定义规范
- @ObservedV2 和 @Trace 装饰器指南
- @Type 装饰器与嵌套对象响应式
- 类型安全与响应式分离设计模式