当前位置: 首页 > news >正文

MyFramework:EventSystem 事件系统的实现解析

结合上一篇,说一下我对CommandSystem和EventSystem的理解

CommandSystem:逻辑即是对象,明确知道一个事件发生时会触发什么逻辑,所以将这些逻辑集中到一个对象中.适合大部分情况下的事件逻辑封装.

EventSystem:完全事件分发,不知道一个事件发生时会触发什么逻辑.所以只管发,不管逻辑处理.需要的地方自己订阅事件去处理.适合逻辑非常分散而且无法收集的情况,比如任务条件的监听,成就进度的监听等.

好了,以下是正文.

在游戏项目里,系统之间经常需要互相通知。

比如背包数据变化后,可能需要刷新背包界面、刷新红点、推进任务、触发引导判断。

最直接的写法是让背包系统直接调用这些系统:

mBagUI.refresh(); mRedPointSystem.refresh(); mTaskSystem.checkTask(); mGuideSystem.checkGuide();

这种写法在小项目里没什么问题。

但项目越做越大以后,问题就会越来越明显。

背包系统本来只应该关心背包数据,但它开始知道 UI、红点、任务、引导这些系统的存在。

以后每增加一个需要响应背包变化的模块,背包系统就要继续改。

这会让模块之间的依赖越来越乱。

所以在 MyFramework 中,我把这类“某件事发生了,谁关心谁来处理”的逻辑,放到了统一的事件系统里:

EventSystem

项目地址:

GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub


一、事件系统解决的不是操作请求

在前一篇 CommandSystem 中,命令解决的是:

我要让某个对象做一件事

比如关闭窗口、移动对象、延迟执行某个命令。

而 EventSystem 解决的是:

某件事已经发生了,谁关心谁来处理

这两者不能混在一起。

命令更像主动操作:

打开背包窗口 移动角色 关闭弹窗 延迟执行某个动作

事件更像状态通知:

背包数据变化了 角色属性变化了 任务完成了 场景切换了 某个角色触发了状态变化

所以 EventSystem 的核心作用不是替代函数调用,也不是替代 CommandSystem。

它解决的是模块之间的通知关系。

发送事件的系统不需要知道谁会处理事件。

监听事件的系统只需要注册自己关心的事件。


二、GameEvent 是事件参数对象

MyFramework 中所有事件参数都继承自GameEvent

基础结构很简单:

public class GameEvent : ClassObject { public long mCharacterGUID; public override void resetProperty() { base.resetProperty(); mCharacterGUID = 0; } }

这里有两个点。

第一,GameEvent继承自ClassObject

这意味着事件参数对象也可以走对象池。

事件触发时,可以临时申请一个事件对象,用完以后自动回收到池中。

第二,基础事件参数里带了mCharacterGUID

因为游戏里很多事件不是纯全局事件,而是和某个角色相关。

例如某个角色属性变化、某个角色进入战斗、某个角色状态改变。

这种情况下,事件参数本身可以携带角色 ID。


三、事件注册信息 GameEventRegisteInfo

事件系统中真正保存监听关系的,不是一个简单的委托列表,而是GameEventRegisteInfo

它记录了这一次注册的完整信息:

public class GameEventRegisteInfo : ClassObject { public int mEventTypeID; public long mCharacterID; public IEventListener mListener; public Action mBaseCallback; }

其中:

mEventTypeID:事件类型 ID mCharacterID:指定角色 ID,0 表示全局事件 mListener:监听者 mBaseCallback:无参数回调

如果事件需要带参数,就使用泛型版本:

public class GameEventRegisteInfoT<T> : GameEventRegisteInfo where T : GameEvent { public Action<T> mCallback; public override void call(GameEvent param) { base.call(param); mCallback?.Invoke(param as T); } }

这样事件系统既支持无参数事件,也支持带事件参数的回调。

例如:

listenEvent<TestEvent>(() => { ... }, listener); listenEvent<TestEvent>(e => { ... }, listener);

这两种都可以使用。


四、为什么需要 IEventListener

事件监听者需要实现一个空接口:

public interface IEventListener { }

它本身不定义函数。

它的作用是给事件系统一个统一的监听者标识。

也就是说,事件系统可以知道:

某个监听者注册了哪些事件 某个监听者销毁时应该取消哪些事件 某个监听者是否还在监听列表中

这点很重要。

事件系统最容易出问题的地方,不是事件发不出去,而是监听者销毁以后没有取消监听。

如果一个窗口关闭了,但还挂在事件列表里,后面事件再次触发,就可能访问已经失效的窗口。

所以 MyFramework 中不是只保存“事件类型 -> 回调”,还会保存“监听者 -> 注册信息列表”。


五、EventSystem 里维护了三组核心数据

EventSystem 中主要维护三组数据:

protected Dictionary<long, Dictionary<int, SafeList0<GameEventRegisteInfo>>> mCharacterEventList; protected Dictionary<IEventListener, List<GameEventRegisteInfo>> mListenerList; protected Dictionary<int, SafeList0<GameEventRegisteInfo>> mGlobalListenerEventList;

它们分别解决不同问题。

1. 全局事件列表

mGlobalListenerEventList

结构大致是:

事件类型 ID -> 监听列表

用于处理普通全局事件。

例如背包变化、配置变化、UI 状态变化这类事件,不需要绑定到某个角色。

2. 指定角色事件列表

mCharacterEventList

结构大致是:

角色 ID -> 事件类型 ID -> 监听列表

用于处理只关心某个角色的事件。

比如只监听某个角色的属性变化,而不是监听所有角色的属性变化。

这在多人、怪物、伙伴、宠物等对象都存在时比较有用。

3. 监听者反查列表

mListenerList

结构大致是:

监听者 -> 这个监听者注册过的所有事件

它的作用是取消监听。

当某个监听者销毁或不再需要事件时,可以通过监听者一次性找到它注册过的所有事件,并从全局事件列表、角色事件列表中移除。

这就是unlistenEvent(listener)的基础。


六、listenEvent 的注册流程

注册事件时,大致流程是:

创建 GameEventRegisteInfo ↓ 记录事件类型 ID ↓ 记录角色 ID ↓ 记录监听者 ↓ 记录回调函数 ↓ 加入 mListenerList ↓ 加入全局事件列表或角色事件列表

比如全局事件注册:

public void listenEvent<T>(Action<T> callback, IEventListener listener) where T : GameEvent { GameEventRegisteInfo info = createEventAddToListenList(0, callback, listener); mGlobalListenerEventList.getOrAddClass(info.mEventTypeID).add(info); }

指定角色事件注册:

public void listenEvent<T>(long characterID, Action<T> callback, IEventListener listener) where T : GameEvent { GameEventRegisteInfo info = createEventAddToListenList(characterID, callback, listener); var characterEventList = mCharacterEventList.getOrAddListPersist(characterID); characterEventList.getOrAddClass(info.mEventTypeID).add(info); }

这里有一个关键点:

同一份注册信息,会同时放进两个方向的结构中。

一边用于事件触发时快速找到回调。

一边用于监听者取消时快速找到自己注册过的事件。


七、pushEvent 的分发流程

发送全局事件时,流程比较直接:

根据事件类型 ID 找到监听列表 ↓ 遍历监听列表 ↓ 调用每个 GameEventRegisteInfo 的 call ↓ 异常单独捕获,避免影响其他监听者

代码里事件类型不是用字符串,而是使用:

TypeID<T>.ID

因为我觉得直接使用Type作为Key可能会比较慢,所以将Type转成int类型的ID来处理,这种做法也适用于其他任何地方.

这样每种事件类型都有自己的类型 ID,不需要到处写字符串事件名。

发送指定角色事件时,还有一个细节:

// 即使只是指定角色的事件,也会先广播全局监听 pushEvent(param);

也就是说,如果触发一个指定角色事件,会先发送全局事件,再发送指定角色事件。

这样全局监听者仍然可以收到这类事件。

比如某个系统想监听所有角色的某类事件,就监听全局事件。

另一个系统只关心某个角色,就监听指定角色事件。

这两个需求可以同时存在。


八、为什么遍历时要固定 count

事件分发时,代码里没有直接写:

for (int i = 0; i < infoList.count(); ++i)

而是先记录当前数量:

int count = infoList.count(); for (int i = 0; i < count; ++i) { infoList.get(i)?.call(param); }

原因是事件回调过程中,可能再次注册事件、取消事件,甚至再次触发事件。

如果遍历时直接使用动态变化的列表长度,就可能出现遍历结果不稳定。

所以这里固定当前 count。

本次分发只处理进入分发前已经存在的监听者。

新加入的监听者不会立刻插入到本次遍历流程中。

这可以减少很多边界问题。


九、为什么需要 SafeList0

EventSystem 的监听列表使用的是SafeList0<GameEventRegisteInfo>,而不是普通List<GameEventRegisteInfo>

原因也和上面一样:

事件分发过程中可能修改监听列表。

比如某个回调里取消了自己的监听。

如果用普通 List,正在遍历时直接删除元素,很容易出问题。

SafeList 的作用,就是让遍历过程中的删除更安全。

当列表正在遍历时,删除不一定立刻物理移除,而是延后处理。

这也是为什么 EventSystem 中会有mNeedCheckEmptyEvent

当遍历过程中不能立即清干净空列表时,就先标记一下。

后续在 update 中统一检查并清理空事件列表,避免字典和列表一直膨胀。


十、unlistenEvent 如何取消监听

取消监听时,EventSystem 会先通过监听者找到它注册过的所有事件:

mListenerList[listener]

然后逐个从全局事件列表和角色事件列表中移除:

removeFromCharacterListenList removeFromGlobalListenList

最后再从mListenerList中移除这个监听者。

这就是为什么注册时要保存反查表。

如果没有mListenerList,取消监听时就只能遍历所有事件类型、所有角色事件、所有监听列表去查找这个监听者。

那样逻辑会更复杂,也更容易漏。

所以 MyFramework 的事件系统不是单向索引,而是双向维护:

事件类型 -> 监听者列表 监听者 -> 注册信息列表

这样注册和取消都比较明确。


十一、removeCharacterEvent 的作用

除了取消某个监听者的所有事件,EventSystem 还支持移除某个角色相关的所有事件:

removeCharacterEvent(long characterID)

这个函数适用于角色销毁、离开场景、数据释放等场景。

比如某个角色离开以后,和这个角色绑定的事件监听就不应该继续存在。

removeCharacterEvent会找到这个角色对应的事件列表,然后从监听者反查列表里同步移除相关注册信息。

这个过程很重要。

否则角色相关事件已经清掉了,但监听者反查表里还保留旧注册信息,就会造成两边数据不一致。


十二、防止事件递归过深

事件系统里还有一个保护:

protected int mDispatchDepth; protected const int MAX_DEPTH = 20;

每次发送事件时,都会增加分发深度。

如果递归超过上限,就会报错:

事件递归栈深度超过上限

这是为了防止事件之间互相触发,导致递归链条失控。

例如:

A 事件触发 B 事件 B 事件又触发 C 事件 C 事件又触发 A 事件

这种问题如果没有限制,可能会造成无限递归。

所以事件系统需要有一个最大深度保护。

这不是为了正常流程服务,而是为了在错误事件链出现时尽早暴露问题。


十三、事件对象和注册信息也会被池化

GameEventGameEventRegisteInfo都继承自ClassObject

这意味着事件参数和事件注册信息也可以使用对象池。

比如发送无参数事件时:

public void pushEvent<T>() where T : GameEvent, new() { using var a = new ClassScope<T>(out var param); pushEvent(param); }

这里使用了ClassScope<T>

也就是说,事件参数对象在作用域内申请,用完自动回收到对象池。

这和之前 ClassPool 文章中的设计是一致的。

事件系统并不是孤立存在的。

它也复用了框架中的对象池和 Scope 生命周期管理。


十四、EventSystem 和 CommandSystem 的区别

CommandSystem 和 EventSystem 都能让模块之间减少直接调用,但它们解决的问题不同。

CommandSystem 更适合表达:

我要执行一个操作

EventSystem 更适合表达:

我通知一个状态变化

比如:

打开背包窗口

更像命令。

背包数据变化

更像事件。

如果把所有东西都做成事件,代码会变得很绕。

如果把所有东西都做成命令,状态通知又会变得很重。

所以两者应该分工明确。

在 MyFramework 中,这两个系统可以形成互补:

CommandSystem 管理操作请求的执行生命周期 EventSystem 管理状态变化后的通知关系

十五、这套方案解决的具体问题

EventSystem 解决的不是“怎么调用一个回调函数”。

它主要解决的是模块之间的通知关系如何维护。

具体包括:

  • 事件发送者不需要知道谁在监听

  • 监听者可以只关心自己需要的事件

  • 支持无参数事件和带参数事件

  • 支持全局事件和指定角色事件

  • 支持通过监听者统一取消注册

  • 支持角色销毁时移除角色相关事件

  • 支持事件分发过程中的安全删除

  • 支持事件递归深度保护

  • 事件参数对象可以通过 ClassScope 自动回收

  • 注册信息可以通过 ClassObject 生命周期统一管理

这些能力看起来都不复杂,但它们解决的是长期项目中的真实问题。

项目越大,模块之间的通知关系越多。

如果没有统一事件系统,很多模块最终都会互相引用、互相调用、互相影响。


结语

EventSystem 的价值,不是为了把简单回调包装得更复杂。

它真正解决的是游戏项目中模块之间的通知关系。

一个系统只负责把“发生了什么”发出去。

谁关心这个事件,谁自己注册监听。

监听者销毁时,通过统一接口取消监听。

角色销毁时,通过角色 ID 清理相关事件。

事件分发过程中,即使有人新增或删除监听,也由 SafeList 和固定 count 遍历来保证流程稳定。

所以 MyFramework 中的 EventSystem,本质上是一套事件生命周期管理系统。

它管理的不只是事件触发,还包括事件注册、事件取消、角色事件清理、监听者反查、事件对象回收和递归深度保护。

这就是 EventSystem 在 MyFramework 中的核心作用。

http://www.jsqmd.com/news/1037775/

相关文章:

  • Notebook到生产环境的MLOps交付实战指南
  • 2026推荐:40Cr钢板切割厂家/合金板定尺加工 - 资讯速览
  • 10秒视频转GIF|2026免费在线保姆级教学(画质可调) - 时时资讯
  • 2026 制造企业商标专利怎么选?五大核心优势,易柱推荐|商标专利律师推荐 - 起跑123
  • 终极指南:如何用BilibiliDown轻松实现B站视频下载与高效管理
  • 香奈儿包包回收门店避坑指南|认准资质齐全的商家,拒绝隐形扣费 - 奢品小当家
  • MPC857T ATM控制器地址映射与APC调度机制深度解析
  • MCP7386X锂电充电管理芯片选型、电路设计与故障排查全解析
  • 2026 国内头部咨询公司排名组织管控数字化管控服务商实力榜单 - 资讯速览
  • 2026视频转WEBM保姆级教程:HTML5必备,免费在线+小程序全攻略 - 时时资讯
  • 武汉实测靠谱宠物店推荐,本地买宠可以参考 - 园友3800037
  • ML模型可观测性实战:从Notebook到生产环境的健康运行机制
  • 杭州本地宠物店实测分享,选猫选狗别只看价格 - 园友3800037
  • Totolink路由器未授权访问漏洞:原理、复现与安全加固实战
  • 上海家庭防水补漏首选:5 家响应快售后好的正规品牌 - 起跑123
  • 佛山出手翡翠别乱选!本地高口碑回收商家排行榜来了 - 奢侈品交易观察员
  • 如何解决Buzz离线转录工具的模型下载难题:终极加速指南
  • 2026佛山黄金回收高性价比机构甄选|全品类回收+专业鉴定测评 - 奢侈品回收测评
  • MLP实战指南:从原理到工业部署的全流程拆解
  • 2026 海南自贸港旧账乱账清理全攻略|税务稽查应对流程、办理周期、本土头部财税合规机构 TOP5 榜单 - GrowthUME
  • 上海餐饮厨房排烟工程施工,连锁饭店、火锅店全套排烟管道定制 - 品牌优选官
  • 数据科学中的算法偏见与公平性实践指南
  • 2026年西安封边机厂家选购指南:哪个品牌真正值得信赖? - 资讯速览
  • AGI共存实战指南:从能力边界到人机契约的工程化落地
  • pandas多维聚合实战:从银行风控到运营分析的工程化落地
  • 多模态大模型工程落地:跨模态对齐与视觉编码器实战指南
  • 武汉黄金回收避坑完整版|官方拆解全套行业套路,一眼分清黑心商家与正规平台 - 奢侈品回收测评
  • 混淆矩阵实战指南:从医疗诊断看分类模型评估本质
  • 别再乱卖旧金!杭州2026黄金回收常见骗局盘点,鬼秤、熔金压价一招识别 - 奢侈品回收评测
  • 2026贵阳黄金回收哪家靠谱?实探全城后,这家平台让我彻底服了 - 资讯速览