HarmonyOS掌上记账APP开发实践第5篇:鸿蒙 EventBus 设计模式 — MoneyTrack 跨模块通信解密

鸿蒙 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

typescript 复制代码
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,生命周期更独立:

typescript 复制代码
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)
typescript 复制代码
// HomeVM.ets - 初始化时注册监听
public async initData() {
  await this.dataProcessing.accountProcessing.init();
  await this.refreshBill();
  EventBus.instance.on(EventKey.GLOBAL_REFRESH_EVENT, () => {
    this.refreshBill();
  });
}
注册监听(StatisticsVM)
typescript 复制代码
// 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();
  });
}
触发事件(删除账单)
typescript 复制代码
// 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); // 触发刷新
      }
    },
  });
}
触发事件(设置默认账本)
typescript 复制代码
// AccountBookPage.ets - 设置默认账本后触发
await AccountingDB.editAccount(updateData);
ToastDialog.showToast({ message: '已设为默认账本' });
EventBus.instance.emit(EventKey.GLOBAL_REFRESH_EVENT);
触发事件(删除账本)
typescript 复制代码
// 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(首页) 用户 #mermaid-svg-RClWrTkymg5Us0py{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RClWrTkymg5Us0py .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RClWrTkymg5Us0py .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RClWrTkymg5Us0py .error-icon{fill:#552222;}#mermaid-svg-RClWrTkymg5Us0py .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RClWrTkymg5Us0py .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RClWrTkymg5Us0py .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RClWrTkymg5Us0py .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RClWrTkymg5Us0py .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RClWrTkymg5Us0py .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RClWrTkymg5Us0py .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RClWrTkymg5Us0py .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RClWrTkymg5Us0py .marker.cross{stroke:#333333;}#mermaid-svg-RClWrTkymg5Us0py svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RClWrTkymg5Us0py p{margin:0;}#mermaid-svg-RClWrTkymg5Us0py .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RClWrTkymg5Us0py text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-RClWrTkymg5Us0py .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RClWrTkymg5Us0py .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-RClWrTkymg5Us0py .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-RClWrTkymg5Us0py .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-RClWrTkymg5Us0py #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-RClWrTkymg5Us0py .sequenceNumber{fill:white;}#mermaid-svg-RClWrTkymg5Us0py #sequencenumber{fill:#333;}#mermaid-svg-RClWrTkymg5Us0py #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-RClWrTkymg5Us0py .messageText{fill:#333;stroke:none;}#mermaid-svg-RClWrTkymg5Us0py .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RClWrTkymg5Us0py .labelText,#mermaid-svg-RClWrTkymg5Us0py .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-RClWrTkymg5Us0py .loopText,#mermaid-svg-RClWrTkymg5Us0py .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-RClWrTkymg5Us0py .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RClWrTkymg5Us0py .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-RClWrTkymg5Us0py .noteText,#mermaid-svg-RClWrTkymg5Us0py .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-RClWrTkymg5Us0py .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RClWrTkymg5Us0py .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RClWrTkymg5Us0py .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RClWrTkymg5Us0py .actorPopupMenu{position:absolute;}#mermaid-svg-RClWrTkymg5Us0py .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-RClWrTkymg5Us0py .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RClWrTkymg5Us0py .actor-man circle,#mermaid-svg-RClWrTkymg5Us0py line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-RClWrTkymg5Us0py :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 所有监听该事件的模块同时刷新 点击删除按钮deleteBill(id)push 确认弹窗确认删除onPop({ isConfirm: true })deleteTransactions(id)删除成功showToast('删除成功')emit(GLOBAL_REFRESH_EVENT)on() 回调 → refreshBill()on() 回调 → refreshCalendarData()on() 回调 → refreshAssets()

7. 清理监听器

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

typescript 复制代码
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 装饰器文档
相关推荐
霸道流氓气质1 小时前
SpringBoot中设计模式组合使用示例-策略、模板、观察者、门面、工厂、单例。
spring boot·后端·设计模式
FrameNotWork1 小时前
HarmonyOS 6.0 输入法与键盘避让 — 聊天页面底部输入栏被键盘挡住的绝望
华为·计算机外设·harmonyos
不羁的木木2 小时前
HarmonyOS APP实战-基于Image Kit的图像处理APP - 第12篇:性能优化与内存管理
图像处理·性能优化·harmonyos
拥抱太阳06163 小时前
HarmonyOS 应用开发《掌上英语》第6篇-资源管理最佳实践多模块资源复用
pytorch·深度学习·harmonyos
ttod_qzstudio3 小时前
【软考设计模式】观察者模式:一对多依赖的自动通知与订阅解耦精讲
观察者模式·设计模式
2301_768103493 小时前
HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底
harmonyos·arkts·preferences·本地缓存·数据兼容
HONG````3 小时前
ArkTS 并发编程:TaskPool 多线程实战
harmonyos·arkts
不羁的木木4 小时前
HarmonyOS APP实战-基于Image Kit的图像处理APP - 第8篇:图片编码与保存
图像处理·华为·harmonyos
春卷同学4 小时前
HarmonyOS掌上记账APP开发实践第2篇:从零搭建记账应用 — MoneyTrack 工程全景解析![
华为·harmonyos
春卷同学4 小时前
HarmonyOS掌上记账APP开发实践第1篇:HarmonyOS 多模块 HAR 架构最佳实践 — MoneyTrack 多模块拆分之道
华为·架构·harmonyos