HarmonyOS掌上记账APP开发实践第3篇:接口与实现分离 — MoneyTrack Types.ets 与 Models.ets 的双层设计模式

接口与实现分离 --- 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 中,定义了多个接口,如 BillCardItemDailyBillGroupBalanceResourceItem。每个接口仅声明属性和类型,不使用任何装饰器:

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 都需要实现接口。BalanceResourceItemModelBalanceResourceModel 仅在 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 个实现类,二者形成清晰的对应关系。

最佳实践与注意事项

  1. 接口字段要保持最小完备性 :接口中只声明调用方真正需要的字段,避免"大而全"的接口。这样 Model 实现时可以根据 UI 需求选择性地添加额外字段(如 BillCardItemModelBillCardItem 多了 memberAvatar 字段),不影响已有调用方。

  2. Model 必须提供合理的默认值@ObservedV2 类在构造函数中需要给每个 @Trace 字段赋初值。推荐使用"零值"作为默认值(如 number 为 0、string''、对象为 null),避免使用 undefined 导致的渲染异常。

  3. 嵌套对象数组需使用 @Type() 装饰器 :当 Model 中包含其他 @ObservedV2 对象的数组时(如 BalanceResourceModel.income 的类型为 BalanceResourceItemModel[]),必须使用 @Type(BalanceResourceItemModel) 装饰器标记,否则框架无法正确追踪嵌套对象的属性变更。

  4. 非 UI 模块绝不引用 Models.ets :数据层(如 AccountingDB)和工具库只应依赖 Types.ets 中的接口。如果数据层导入了 @ObservedV2 类,会导致非 UI 场景(如单元测试、后台任务)加载不必要的 ArkUI 框架代码。

  5. 接口与模型的映射建议保持一一对应 :虽然并非所有接口都需要 Model 实现(如 AssetFilterState),但当一个接口需要 Model 时,建议保持命名一致(如 FooFooModel),并放在固定的模块路径下,方便团队成员快速定位。

总结

MoneyTrack 的双层设计模式(Types.ets 接口层 + Models.ets 实现层)成功解决了跨模块类型安全与 UI 响应式需求之间的矛盾。接口层提供了轻量、零开销的类型契约,可以被任何模块引用;实现层通过 @ObservedV2@Trace 装饰器提供了高效的响应式能力,服务于 UI 渲染层。这种分离设计使得数据层可以保持纯净,UI 层获得响应式能力,而二者之间的桥梁------ViewModel------通过类型转换完成数据的流向编排。对于中大型鸿蒙应用,这是一种值得推广的架构模式。

参考文档

  • ArkTS 接口(interface)定义规范
  • @ObservedV2 和 @Trace 装饰器指南
  • @Type 装饰器与嵌套对象响应式
  • 类型安全与响应式分离设计模式
相关推荐
NG47717 小时前
【软件设计与体系结构】-策略设计模式
java·设计模式·软件工程
CaffeinePro19 小时前
SOLID五大设计原则:重构优雅代码与架构的底层规范
设计模式·架构
饮茶三千19 小时前
电子保函模板编辑器实战二:HTML→FTL 编译转换的设计与实现
前端·vue.js·设计模式
Kel20 小时前
Node.js 本质:从全脉络视角理解运行时原理
javascript·设计模式·node.js
码哥字节21 小时前
11 万 Star 的 Spec Kit,90% 的工程师只用它解决了 10% 的问题
设计模式·vibe coding·github spec kit
我登哥MVP21 小时前
走进 Gang of Four 设计模式:访问者模式
java·设计模式·访问者模式·原型模式
ttod_qzstudio1 天前
【软考设计模式】状态模式:状态驱动行为自动转换与代码填空精讲
设计模式·状态模式
饮茶三千1 天前
基于 WangEditor 的富文本编辑器定制实践
前端·vue.js·设计模式
ttod_qzstudio1 天前
【软考设计模式】中介者模式:网状交互的解耦与集中调度精讲
设计模式·中介者模式