鸿蒙 EventBus 设计模式 — MoneyTrack 跨模块通信解密

在这里插入图片描述

概述

在多模块鸿蒙应用中,模块间通信是一个核心挑战。随着 MoneyTrack 功能不断扩展——从首页账单管理、统计图表、资产管理到家庭成员记账——模块间的数据依赖关系越来越复杂。如果采用模块直接引用的方式,会导致循环依赖和高耦合,使得任何一个模块的修改都可能引发连锁反应。

发布订阅模式(Publish-Subscribe Pattern)提供了一种优雅的解耦方案:发布者无需知道订阅者的存在,订阅者也不需要依赖发布者,双方仅通过事件通道进行间接通信。MoneyTrack 项目同时使用了三种跨模块通信机制:基于 EventHub 的自定义 EventBus 实现全局事件传递、基于 emitter 的系统级事件用于组件间通信,以及 @Prop/@Link/@Param 装饰器用于父子组件间的数据绑定。本文深入分析这三种机制的设计原理、适用场景与最佳实践。

核心知识点

1. 三种跨模块通信方式对比

MoneyTrack 在不同场景下选择不同的通信机制,三者各有所长:

通信方式 作用域 耦合度 适用场景 是否支持跨页面
EventBus 全局(整个应用进程) 松耦合(事件名耦合) 全局状态变更通知、跨模块刷新
emitter 进程内(同一 Ability) 松耦合(事件 ID 耦合) 轻量级事件通知、Widget 刷新
@Prop/@Link/@Param 父子组件树 紧耦合(直接数据绑定) 父子组件数据传递、UI 自动更新
  • EventBus 适合需要携带业务语义的全局事件,如"账单数据已变更,请刷新所有相关页面"。
  • emitter 适合轻量级的系统级通知,如"资产数据已变更,请刷新资产卡片"。
  • @Prop/@Link/@Param 适合组件树中上下游之间的数据绑定,由框架自动管理变更检测和 UI 重绘。

2. 基于 EventHub 的 EventBus 实现

MoneyTrack 在 commonlib 模块中封装了 EventBus 单例类,底层基于 UIAbility 的 EventHub

import { common } from '@kit.AbilityKit';
import { contextUtil } from '../ContextUtil';

export enum EventKey {
  GLOBAL_REFRESH_EVENT = 'globalRefreshEvent',
}

export class EventBus {
  private _eventHub: common.EventHub = (
    contextUtil.getContext() as common.UIAbilityContext
  ).eventHub;
  private static _instance: EventBus;

  public static get instance(): EventBus {
    if (!EventBus._instance) {
      EventBus._instance = new EventBus();
    }
    return EventBus._instance;
  }

  public emit(eventKey: EventKey | string, ...args: Object[]) {
    this._eventHub.emit(eventKey, ...args);
  }

  public on(eventKey: string, callback: Function) {
    this._eventHub.on(eventKey, callback);
  }

  public off(eventKey: string, callback?: Function) {
    this._eventHub.off(eventKey, callback);
  }
}

事件键集中定义在 EventKey 枚举中,避免了魔法字符串散落在各处。目前定义了 GLOBAL_REFRESH_EVENT = 'globalRefreshEvent',随着功能扩展可以按模块命名空间追加新的事件键。

3. emitter 系统级事件

除了自定义 EventBus,MoneyTrack 还使用 @kit.BasicServicesKitemitter API。emitter 是鸿蒙系统提供的进程内事件机制,与 EventBus 相比,它不依赖于 UIAbility 的 EventHub,生命周期更独立:

import { emitter } from '@kit.BasicServicesKit';

// 在 Constants.ets 中定义事件 ID
export const ASSET_CHANGE_EVENT_ID = 123;

// 在 HomeVM 中发送事件
emitter.emit({ eventId: 1 });

// 在 MainEntryVM 中监听 Widget 刷新事件
emitter.on(
  { eventId: this._widgetEventId },
  () => {
    WidgetUtil.updateWidgetsWhenChange();
  },
);

emitter 的事件 ID 是数值类型,适合用常量集中管理。在 MoneyTrack 中,ASSET_CHANGE_EVENT_ID = 123 用于资产数据变更通知,eventId: 1 用于 Widget 服务卡片刷新。

4. EventBus 与 emitter 的详细对比

对比维度 EventBus emitter
实现机制 基于 UIAbility 的 EventHub 基于 @kit.BasicServicesKit 的独立事件通道
事件标识 字符串(支持枚举) 数值(EventId)或字符串
生命周期 跟随 UIAbility 生命周期 独立生命周期,可在非 UI 线程使用
携带数据 支持 ...args 任意参数 支持结构化数据对象
适合场景 全局 UI 刷新、跨页面通信 后台任务通知、服务卡片更新、跨线程通信
性能特点 同步调用,轻量级 支持异步发送,适合高频事件
单例管理 自定义单例(EventBus.instance 系统级全局单例(直接 import 使用)

5. 全局刷新链

EventBus 最核心的应用场景是"全局刷新链"。当用户创建、删除或修改账单时,需要通知所有正在展示数据的页面刷新。

注册监听(HomeVM)
// HomeVM.ets - 初始化时注册监听
public async initData() {
  await this.dataProcessing.accountProcessing.init();
  await this.refreshBill();
  EventBus.instance.on(EventKey.GLOBAL_REFRESH_EVENT, () => {
    this.refreshBill();
  });
}
注册监听(StatisticsVM)
// StatisticsVM.ets - 统计页面同样监听
public async initData() {
  await this.dataProcessing.accountProcessing.init();
  await this.dataProcessing.getBillReport();
  this.getSelectedDateData(dayjs().format('YYYY-MM-DD'));
  EventBus.instance.on(EventKey.GLOBAL_REFRESH_EVENT, () => {
    this.refreshCalendarData();
  });
}
触发事件(删除账单)
// HomeVM.ets - 删除账单后触发全局刷新
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); // 触发刷新
      }
    },
  });
}
触发事件(设置默认账本)
// AccountBookPage.ets - 设置默认账本后触发
await AccountingDB.editAccount(updateData);
ToastDialog.showToast({ message: '已设为默认账本' });
EventBus.instance.emit(EventKey.GLOBAL_REFRESH_EVENT);
触发事件(删除账本)
// AccountBookPage.ets - 删除账本后触发
private async deleteAccount(accountId: number) {
  await AccountingDB.deleteAccount(accountId);
  EventBus.instance.emit(EventKey.GLOBAL_REFRESH_EVENT);
}

6. 完整事件流示例:“删除账单"到"首页刷新”

下面用 mermaid 时序图展示"删除账单"操作触发的完整事件链:

AssetsVM(资产页) StatisticsVM(统计页) EventBus(全局) AccountingDB 确认弹窗 HomeVM HomeView(首页) 用户 AssetsVM(资产页) StatisticsVM(统计页) EventBus(全局) AccountingDB 确认弹窗 HomeVM HomeView(首页) 用户 所有监听该事件的模块同时刷新 点击删除按钮 deleteBill(id) push 确认弹窗 确认删除 onPop({ isConfirm: true }) deleteTransactions(id) 删除成功 showToast('删除成功') emit(GLOBAL_REFRESH_EVENT) on() 回调 → refreshBill() on() 回调 → refreshCalendarData() on() 回调 → refreshAssets()

7. 清理监听器

为了避免内存泄漏,在页面销毁时需要取消事件监听。MainEntryVM 在清理方法中同时清理了 emitter 和 EventBus 的监听:

public cleanListener() {
  try {
    emitter.off(this._widgetEventId);
  } catch (error) {
    WindowUtil.dealAllError(error);
  }
  EventBus.instance.off(EventKey.GLOBAL_REFRESH_EVENT);
}

EventBus 事件命名规范建议

随着项目规模增长,事件命名规范直接影响代码的可维护性。以下是 MoneyTrack 实践中总结的命名建议:

  1. 使用枚举集中管理:所有事件键定义在 EventKey 枚举中,避免字符串散落在代码中。
  2. 采用领域前缀GLOBAL_REFRESH_EVENTGLOBAL 前缀标识全局事件;资产相关可用 ASSET_ 前缀,如 ASSET_CHANGED
  3. 动词过去式表示已发生:事件名表示"已发生的事情",如 GLOBAL_REFRESH_EVENT(已触发全局刷新)、BILL_CREATED(已创建账单)。
  4. 命名空间分层:按模块划分命名空间,例如 HOME_BILL_CREATEDSTATISTICS_DATA_READYMEMBER_STATUS_CHANGED
  5. 避免过于通用:不要使用 updaterefresh 这类过于宽泛的词,最好带上业务领域信息。

最佳实践与注意事项

1. 警惕"事件风暴"

事件风暴(Event Storming)是指系统中事件数量失控,大量事件相互触发形成网状依赖,最终导致难以追踪和调试。过度使用 EventBus 会使数据流变得不透明,排错困难。建议遵循以下原则:

  • 控制事件种类:全局事件不超过 5~8 种,超过此数量应审视是否有更好的通信方式。
  • 避免事件链式触发:不要在事件回调中再次 emit 另一个事件,这会形成隐式的链式调用,使数据流难以追踪。
  • 记录事件日志:在开发环境中记录所有 EventBus 的 emit 和 on 操作,便于调试。

2. EventBus 与 emitter 的选择策略

  • 全局 UI 刷新场景:优先使用 EventBus,因为其基于 EventHub,与 UIAbility 生命周期一致,页面活跃时才接收事件。
  • 后台服务通知场景:优先使用 emitter,不依赖 UIAbility,可在非 UI 线程中使用。
  • 高频触发场景:优先使用 emitter,它支持异步发送,对主线程影响更小。
  • 需要携带结构化数据:emitter 的 on 回调可以接收结构化的数据对象,适合需要传递复杂参数的场景。

3. 生命周期管理

  • 配对注册与注销on()off() 必须成对出现,在 aboutToAppear/onPageShow 中注册,在 aboutToDisappear/onPageHide 中注销。
  • 避免重复注册initData() 如果可能被多次调用,需要先 off()on(),防止回调被执行多次。
  • 单例中的监听:在 ViewModel 单例中注册的监听,必须在应用退出或模块卸载时清理。

4. 避免过度使用 EventBus

EventBus 虽然解耦了模块间的直接依赖,但过度使用会带来新的问题:

  • 隐式依赖:事件名成为隐式的接口契约,修改事件名需要追踪所有使用方。
  • 数据流不透明:全局事件的传播路径难以静态分析,只能在运行时追踪。
  • 调试困难:事件触发后难以确定哪些模块会响应,增加了调试难度。

推荐做法:优先使用 @Prop/@Link/@Param 进行父子组件通信,只在真正的跨模块场景使用 EventBus。

总结

EventBus 是 MoneyTrack 跨模块通信的核心基础设施,它基于 HarmonyOS 的 EventHub 封装,提供了全局事件发布订阅能力。与 emitter 系统事件、@Prop/@Link 组件绑定一起,构成了分层的通信体系。EventBus 负责全局业务状态同步,emitter 负责轻量级系统通知,@Prop/@Link 负责组件树内的数据传递。三者各司其职、互补共存。

在实际应用中,合理的做法是严格控制 EventBus 的事件种类和触发频率,结合生命周期管理防止内存泄漏,并优先使用更精确的通信机制(如 emitter 或 @Prop),只在必要场景使用全局 EventBus。只有这样,才能在享受解耦带来的灵活性的同时,避免陷入"事件风暴"的泥潭。

参考文档

  • @kit.BasicServicesKit emitter API
  • UIAbility EventHub 使用指南
  • 发布订阅模式在鸿蒙中的实现
  • HarmonyOS @ObservedV2 和 @Trace 装饰器文档
Logo

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

更多推荐