HarmonyOS掌上记账APP开发实践第4篇:ViewModel 模式在鸿蒙应用中的实践 — 以 MoneyTrack 为例
ViewModel 模式在鸿蒙应用中的实践 — 以 MoneyTrack 为例

概述
ViewModel 模式源自 MVVM 架构,核心思想是将 UI 逻辑与业务逻辑分离,使视图层仅负责渲染和数据绑定,而状态管理和业务操作由 ViewModel 层承担。在传统的 Android 开发中,ViewModel 依托 ViewModel 基类和 Lifecycle 组件实现生命周期感知;在鸿蒙 ArkTS 应用中,这一模式通过 @ObservedV2、@Trace 和 @Computed 装饰器原生实现。ViewModel 作为一个普通的 @ObservedV2 类,无需继承任何基类,即可获得属性级变更追踪和派生状态计算能力。
为什么鸿蒙应用需要 ViewModel? 在 ArkUI 的 @ComponentV2 组件中,如果直接将业务逻辑(数据加载、过滤、增删改查)写在组件内部,会导致组件职责过重、代码难以测试和维护。另一方面,多个组件之间可能共享同一份数据状态,若没有 ViewModel 层统一管理,数据一致性难以保证。MoneyTrack 引入 ViewModel 层后,组件代码专注于 UI 布局和事件分发,ViewModel 专注于状态管理和业务编排,二者各司其职,大幅提升了代码的可维护性。
核心知识点
1. ViewModel 的核心职责
在 MoneyTrack 中,ViewModel 类承担以下职责:
- 状态管理:使用
@Trace标记响应式字段,数据变化时自动通知 UI 更新。 - 派生状态:使用
@Computed从多个源状态计算派生值,避免手动同步。 - 业务编排:封装数据和业务操作,如数据加载、过滤、增删改查。
- 事件处理:统一处理页面交互逻辑,将 UI 事件转化为业务操作。
2. HomeVM:@Computed 派生状态的典型应用
在 HomeVM 中,@Computed 被用于计算当前选中的收支分类标签:
// features/home/src/main/ets/viewmodels/HomeVM.ets
@ObservedV2
export class HomeVM {
@Trace checkedType: BalanceChangeType = BalanceChangeType.INCOME;
@Trace checkedResource: number = HomeConstants.RESOURCE_NO_FILTER;
@Trace resource: BalanceResourceModel = ResourceUtil.getResourceList();
@Trace showSheet: boolean = true;
@Trace sheet: HomeSheetModel = new HomeSheetModel();
@Trace dataProcessing: BillProcessingModel = new BillProcessingModel();
@Computed
public get currentResourceItem() {
if (this.checkedResource === HomeConstants.RESOURCE_NO_FILTER) {
return null;
}
let list =
this.checkedType === BalanceChangeType.INCOME
? this.resource.income
: this.resource.expense;
const item = Array.from(list).find(
(item) => item.key === this.checkedResource,
);
return item;
}
@Computed
public get resourceLabel() {
return this.currentResourceItem?.name ?? '全部类型';
}
public async initData() {
await this.dataProcessing.accountProcessing.init();
await this.refreshBill();
EventBus.instance.on(EventKey.GLOBAL_REFRESH_EVENT, () => {
this.refreshBill();
});
}
public openSheet(type: HomeSheetType) {
this.sheet.type = type;
if (type === HomeSheetType.DATE) {
this.sheet.sheetHeight = 360;
this.sheet.title = '选择月份';
} else if (type === HomeSheetType.TYPE) {
this.sheet.sheetHeight = 540;
this.sheet.title = '选择类型';
}
}
public async refreshBill() {
await this.dataProcessing.accountProcessing.loadAccounts();
this.dataProcessing.getBillReport(this.checkedResource);
try {
emitter.emit({ eventId: 1 });
} catch (error) {
WindowUtil.dealAllError(error);
}
this.checkBudgetOverrun();
}
public async checkBudgetOverrun() {
const accountId = this.dataProcessing.currentAccountId;
const month = dayjs(this.dataProcessing.date).format('YYYY-MM');
if (accountId === 0) return;
const budget = await AccountingDB.getBudget(accountId, month);
if (budget === null || budget <= 0) return;
const totalExpense = this.dataProcessing.bill?.totalExpense ?? 0;
if (totalExpense > budget) {
// 发送预算超支通知
notificationManager.publish({ ... });
}
}
public async deleteBill(id: number) {
RouterModule.push({
url: DialogMap.COMMON_CONFIRM,
param: { message: '删除后账单无法恢复,是否确认删除?' },
onPop: async (value) => {
const result = value.result as DialogInfo<ConfirmParam>;
if (result.param?.isConfirm) {
await AccountingDB.deleteTransactions(id);
ToastDialog.showToast({ message: '账单删除成功~' });
EventBus.instance.emit(EventKey.GLOBAL_REFRESH_EVENT);
}
},
});
}
public async changeMonth(dateStr: string) {
this.dataProcessing.changeMonth(dateStr);
await this.refreshBill();
}
public async changeResource(type: BalanceChangeType, key: number) {
this.checkedType = type;
this.checkedResource = key;
await this.refreshBill();
}
}
HomeVM 是 ViewModel 的典型代表:@Computed 属性自动响应 checkedResource 和 checkedType 的变化;initData() 完成初始化加载并注册全局刷新事件监听;业务方法如 deleteBill() 封装了确认弹窗到数据库删除的完整流程。
3. AssetsVM:资产模块的 ViewModel
AssetsVM 负责资产管理页面的状态和业务逻辑:
// features/assets/src/main/ets/viewmodels/AssetsVM.ets
@ObservedV2
export class AssetsVM {
@Trace anonymous: boolean = false;
@Trace typeList = AssetTypeUtil.displayTypeList;
@Trace assetProcessing: AssetProcessingModel = new AssetProcessingModel();
@Trace selectedMemberId: number = 0; // 0 表示全部成员
public initData() {
this.assetProcessing.getAssetReport(undefined, this.selectedMemberId);
}
public filterAssetsByMember() {
this.assetProcessing.getAssetReport(undefined, this.selectedMemberId);
}
public handleEyeClick() {
this.anonymous = !this.anonymous;
}
public async jumpToDetailPage(item: AssetRecordItem) {
RouterModule.push({
url: RouterMap.ASSET_BILL_DETAIL,
param: item,
onPop: () => {
this.assetProcessing.getAssetReport(undefined, this.selectedMemberId);
},
});
}
public async deleteAsset(id: number) {
contextUtil.getPromptAction()
?.showDialog({
message: '删除资产账户会取消所有该账户名下的记账数据关联,并且无法恢复关联。请确认是否删除?',
buttons: [
{ text: '取消', color: $r('app.color.font_color_level1') },
{ text: '删除', color: $r('app.color.icon_color_warning') },
],
alignment: DialogAlignment.Bottom,
})
.then(async (data) => {
if (data.index === 1) {
await AccountingDB.deleteAssetAccount(id);
ToastDialog.showToast({ message: '资产账户删除成功~' });
await this.assetProcessing.getAssetReport(undefined, this.selectedMemberId);
}
})
.catch((error: BusinessError) => {
WindowUtil.dealAllError(error);
});
}
}
AssetsVM 展示了 ViewModel 的另一个常见模式:UI 状态开关。anonymous 字段控制资产金额的显隐,handleEyeClick() 方法切换该状态,UI 层绑定此字段即可实现金额的显示/隐藏切换,无需在组件中维护额外状态。
4. MineVM:轻量级 ViewModel 的响应式设计
MineVM 是最简洁的 ViewModel,专注于个人中心页面的数据呈现:
// features/mine/src/main/ets/viewmodels/MineVM.ets
@ObservedV2
export class MineVM {
@Trace list: SettingItem[] = [
new SettingItem().setType(SettingType.ROUTER)
.setLabel('分享给朋友')
.setIcon($r('app.media.ic_mine_share'))
.setClickEvent(() => {
const ctx = contextUtil.getContext();
if (ctx) {
ShareUtil.shareText(ctx as UIAbilityContext, '我正在使用记账App管理收支,推荐给你!');
}
}),
new SettingItem().setType(SettingType.ROUTER)
.setLabel('意见反馈')
.setIcon($r('app.media.ic_mine_feedback'))
.setClickEvent(() => { RouterModule.push({ url: RouterMap.FEEDBACK }) }),
new SettingItem().setType(SettingType.ROUTER)
.setLabel('设置')
.setIcon($r('app.media.ic_mine_setting'))
.setClickEvent(() => { RouterModule.push({ url: RouterMap.SETTING }) }),
];
@Trace group: SettingGroup = new SettingGroup().setList(this.list);
@Trace totalDays: number = 0;
@Trace totalBills: number = 0;
gridClick(v: ServiceMenuItem) {
RouterModule.push({ url: v.routerName });
}
async loadBillInfo() {
try {
const billInfo: BillInfo = await AccountingDB.getBillStatistics();
this.totalDays = billInfo.totalDays;
this.totalBills = billInfo.totalBills;
} catch (err) {
console.error('loadBillInfo failed', err);
this.totalDays = 0;
this.totalBills = 0;
}
}
}
MineVM 的 @Trace 字段清单:
| 字段名 | 类型 | 作用 |
|---|---|---|
list |
SettingItem[] |
设置菜单列表,每一项自带图标、文字和点击事件 |
group |
SettingGroup |
设置分组容器,封装列表为 UI 可消费的 Group 结构 |
totalDays |
number |
记账总天数,从数据库统计获取 |
totalBills |
number |
记账总笔数,从数据库统计获取 |
这种设计体现了 ViewModel 的数据聚合职责:loadBillInfo() 方法从 AccountingDB.getBillStatistics() 获取统计数据后,分别更新 totalDays 和 totalBills 两个 @Trace 字段,UI 层通过双括号绑定即可自动更新。
5. ViewModel 与 View 之间的数据流向
以下是 ViewModel 与 View 之间的数据流向和交互关系:
ArkUI 的响应式机制确保了当 ViewModel 中的 @Trace 字段发生变化时,所有绑定该字段的组件自动重新渲染。View 不需要手动调用 setState() 或类似方法,这是 ViewModel 模式与 ArkUI 状态管理 V2 结合的最大优势。
6. ViewModel 生命周期管理
在鸿蒙应用中,ViewModel 的生命周期与其关联的页面(@Entry 组件)绑定。MoneyTrack 采用以下模式管理 ViewModel 生命周期:
@Entry
@ComponentV2
struct HomePage {
vm: HomeVM = new HomeVM(); // ViewModel 随组件创建
aboutToAppear() {
this.vm.initData(); // 页面显示前初始化数据
}
aboutToDisappear() {
// 可选:清理资源
EventBus.instance.off(EventKey.GLOBAL_REFRESH_EVENT);
}
build() {
// UI 直接绑定 vm 的字段和方法
}
}
生命周期要点:
- 创建时机:ViewModel 实例在
@ComponentV2的成员变量声明处创建,随组件创建而创建。 - 初始化时机:在
aboutToAppear()生命周期回调中调用initData()完成数据加载,避免构造函数中执行异步操作。 - 销毁时机:页面销毁时 ViewModel 随之销毁。如果 ViewModel 持有全局事件监听(如
EventBus),需要在aboutToDisappear()中反注册,防止内存泄漏。 - 跨页面共享:如果多个页面需要共享同一个 ViewModel 实例,可以通过状态提升或单例模式管理,但 MoneyTrack 目前采用页面级 ViewModel 实例,每个页面独立持有。
7. 组件与逻辑分离
MoneyTrack 的三个主要特性模块各自拥有独立的 ViewModel:
| 模块 | ViewModel | @Trace 字段数 | @Computed 数 | 公共方法数 | 核心职责 |
|---|---|---|---|---|---|
| home | HomeVM |
6 | 2 | 7 | 账单展示、月份切换、分类筛选、预算超支检查 |
| assets | AssetsVM |
4 | 0 | 5 | 资产列表展示、成员过滤、资产删除 |
| mine | MineVM |
4 | 0 | 2 | 个人中心设置列表、记账天数统计 |
视图组件通过 vm 属性持有 ViewModel 实例,模板中直接引用 this.vm.xxx 即可。这种设计使组件代码极其简洁——通常只需声明布局结构和事件绑定,没有任何业务逻辑。
ViewModel 设计最佳实践
-
保持 ViewModel 纯状态与行为,不持有 UI 引用:ViewModel 不应导入
@ComponentV2、@Builder等 UI 装饰器,也不应持有组件实例引用。所有 UI 相关的控制(如弹窗、路由跳转)通过返回状态或调用RouterModule等工具类完成。 -
使用
@Computed替代手动计算:当 UI 需要展示从多个源状态派生的值时,使用@Computed属性而非在组件中手动计算。这确保了派生值在源状态变化时自动更新,避免了遗忘手动更新的 Bug。如HomeVM.resourceLabel随checkedResource和checkedType自动变化。 -
异步方法合理处理错误:ViewModel 中的
async方法(如loadBillInfo、deleteAsset)应当在try/catch中捕获异常,并更新 UI 状态以反映错误。避免未捕获的异常导致页面白屏或状态不一致。 -
@Trace 字段遵循最小粒度原则:每个
@Trace字段应代表一个独立的状态维度。避免将多个不相关的状态塞入同一个对象字段,否则任何一个子字段变化都会触发该对象所有绑定的组件更新,造成不必要的渲染。 -
避免在 ViewModel 中直接修改
@Trace数组/对象内部:对于@Trace数组或@ObservedV2对象,修改其内部元素时需要使用赋值操作触发变更检测(如this.list = [...this.list, newItem]),或者确保元素本身也是@ObservedV2类。 -
ViewModel 的职责边界要清晰:ViewModel 不应直接操作数据库或 API——这些职责应委托给 Model 层(如
BillProcessingModel、AssetProcessingModel)。ViewModel 只负责编排和状态转换,实际的 IO 操作由下层 Model 完成。
总结
MoneyTrack 的 ViewModel 模式实践表明,在鸿蒙 ArkTS 生态中,借助 @ObservedV2、@Trace 和 @Computed 装饰器,可以高效地实现 MVVM 架构。HomeVM、AssetsVM、MineVM 三个 ViewModel 各司其职,分别管理首页账单、资产管理和个人中心的状态与业务逻辑,使得视图组件保持纯粹和简洁。ViewModel 与 View 之间的数据流是单向的——ViewModel 通过 @Trace 字段向 View 推送数据,View 通过调用 ViewModel 的方法触发状态变更。这种模式不仅提升了代码的可维护性和可测试性,也为团队成员提供了清晰的架构边界。
参考文档
- ArkTS 状态管理 V2 指南
- @Computed 装饰器 API 参考
- @ObservedV2 和 @Trace 装饰器指南
- MVVM 架构在鸿蒙中的实践
更多推荐

所有评论(0)