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 属性自动响应 checkedResourcecheckedType 的变化;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() 获取统计数据后,分别更新 totalDaystotalBills 两个 @Trace 字段,UI 层通过双括号绑定即可自动更新。

5. ViewModel 与 View 之间的数据流向

以下是 ViewModel 与 View 之间的数据流向和交互关系:

数据库 / API Model 层 ViewModel (@ObservedV2) ArkUI 组件 (@ComponentV2) 数据库 / API Model 层 ViewModel (@ObservedV2) ArkUI 组件 (@ComponentV2) 数据流:VM → V(单向数据绑定) 事件流:V → VM(方法调用) 用户交互(点击、滑动) 调用业务方法(如 deleteBill) 更新 @Trace 字段(如 showSheet) 调用数据层方法 执行数据库查询 返回数据 更新结果数据 @Computed 自动重新计算 @Trace / @Computed 变化触发渲染 重新渲染绑定字段

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 设计最佳实践

  1. 保持 ViewModel 纯状态与行为,不持有 UI 引用:ViewModel 不应导入 @ComponentV2@Builder 等 UI 装饰器,也不应持有组件实例引用。所有 UI 相关的控制(如弹窗、路由跳转)通过返回状态或调用 RouterModule 等工具类完成。

  2. 使用 @Computed 替代手动计算:当 UI 需要展示从多个源状态派生的值时,使用 @Computed 属性而非在组件中手动计算。这确保了派生值在源状态变化时自动更新,避免了遗忘手动更新的 Bug。如 HomeVM.resourceLabelcheckedResourcecheckedType 自动变化。

  3. 异步方法合理处理错误:ViewModel 中的 async 方法(如 loadBillInfodeleteAsset)应当在 try/catch 中捕获异常,并更新 UI 状态以反映错误。避免未捕获的异常导致页面白屏或状态不一致。

  4. @Trace 字段遵循最小粒度原则:每个 @Trace 字段应代表一个独立的状态维度。避免将多个不相关的状态塞入同一个对象字段,否则任何一个子字段变化都会触发该对象所有绑定的组件更新,造成不必要的渲染。

  5. 避免在 ViewModel 中直接修改 @Trace 数组/对象内部:对于 @Trace 数组或 @ObservedV2 对象,修改其内部元素时需要使用赋值操作触发变更检测(如 this.list = [...this.list, newItem]),或者确保元素本身也是 @ObservedV2 类。

  6. ViewModel 的职责边界要清晰:ViewModel 不应直接操作数据库或 API——这些职责应委托给 Model 层(如 BillProcessingModelAssetProcessingModel)。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 架构在鸿蒙中的实践
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐