ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ET8.1事件监听机制详解:从原理到游戏服务器实战避坑

ET8.1事件监听机制详解:从原理到游戏服务器实战避坑 ET8.1的事件监听不是拿来装点架构的组件而是解决游戏服务器模块之间通信纠缠的实用机制。我刚开始用ET框架时觉得它不就是个“广播”吗后来在项目里把登录、背包、任务、活动、公会这一堆系统接进去才发现事件监听的边界一旦划不清代码会从“解耦”变成“失控”。这篇文章我会从事件系统的底层链路讲起把注册、发布、派发、注销这些环节的细节全部撕开再结合我在服务端模块里的实战记录聊一聊哪些地方值得用事件、哪些地方千万别用以及那些不踩一遍根本记不住的坑。1. 为什么游戏服务器需要一套事件监听系统先别急着看代码。任何一个框架里的机制都是为了解决一类“一旦出现就会很痛苦”的问题。ET8.1把事件监听做成一个独立的子系统不是因为它想炫技而是游戏服务器里的跨模块通信真的不能靠“谁需要就调用谁”的方式硬写。1.1 模块之间的通信为什么不能靠直接调用我见过不少服务端项目玩家升级之后要做的事情是一长串直接调用public async ETTask HandleLevelUp(Player player, int newLevel) { await CompensateProperty(player, newLevel); await CheckTaskProgress(player, newLevel); await UnlockAchievement(player, newLevel); await NotifyGuild(player, newLevel); await PushMailIfNeeded(player, newLevel); }这段代码看起来挺清晰但它有一堆隐患。第一升级逻辑被绑死在所有下游业务上。今天加一个“活动系统”要在这里插一行明天加一个“排行榜系统”又在这里插一行。时间一长这个调用点就成了整个服务器最拥挤的路口谁都要从这里过一下。第二模块之间的依赖是网状而不是星状。任务系统要依赖Player对象成就系统要依赖Player对象公会系统也要依赖Player对象大家全部耦合在一起。后续想单独测试任务系统你至少得先构造一个完整的Player和一个升级入口非常别扭。第三下游系统升级逻辑的时机可能并不合适。比如玩家离线的时候Player对象可能已经被释放了这时候如果你强行调用基于Player实体写的方法容易访问到空引用。但事件监听不一样它只负责告诉你“有人升级了”至于监听方能不能拿到Player对象那是监听方自己需要考虑的事。用事件监听改造之后升级入口只剩一件事await EventComponent.Instance.PublishAsync( GameEventType.PlayerLevelUp, new PlayerLevelUpArgs { PlayerId player.Id, NewLevel newLevel });后续任何新的业务系统想响应升级只需要注册一个监听器不需要改动这一行发布代码。这就是开放封闭原则在游戏服务器里最朴素的体现。1.2 事件监听与plusready的类比以及适用边界如果你以前做过基于WebView的App开发肯定知道plusready这类页面生命周期事件。它的逻辑是页面开发者预先注册一个监听等到原生能力就绪后再触发回调页面业务代码和原生层之间不需要互相引用。ET8.1的事件监听在思想上和这个很像——都是一个时刻发生了一件事关心这件事的人自己去接收。但两者有一个本质区别plusready只需要管一个页面从加载到就绪的时序问题事件数量少、生命周期短、出错好排查。ET8.1的事件监听面对的是游戏服务器几十个业务模块同时在跑程序集还可能做热更新事件系统必须有更严格的注册、注销、异常处理机制。所以不能把事件监听单纯当成“广播工具”它其实是ET框架通信架构里承担模块解耦的一部分。那到底哪些场景适合用事件监听我自己的判断标准很简单跨模块通知玩家登录、玩家下线、等级变化、物品数量变更、任务提交完成。外部系统接入支付回调、GM指令、运营活动开关变更。异步业务流程中的通知组队匹配成功、副本创建完成、邮件发放完成。不适合用事件的场景也很清晰方法需要返回值。事件是单向通知发出去就没了拿不到处理结果。需要严格顺序保证的流程。多个监听者之间的执行顺序不是天然可控的。高频调用点。每帧都要执行的逻辑或者一次循环里跑几千次的通知用事件会白白增加查表开销。一句话事件监听解决的是“有事情发生了谁关心谁去响应”而不是“帮我做一件事然后把结果给我”。2. 事件监听的底层链路注册、发布、派发到底发生了什么要驾驭一个机制最好的方式是把它拆开看。ET8.1的事件系统核心链路其实就三条注册、发布、派发。我们把这三条链路逐一看明白后面用起来就不会心虚。2.1 事件参数与事件常量的组织方式事件参数本质上是一个数据载体不应该包含业务逻辑。我在项目里通常定义成结构体并且尽量让字段只读。public struct PlayerLevelUpArgs { public long PlayerId; public int OldLevel; public int NewLevel; public long Timestamp; } public static class GameEventType { public const string PlayerLevelUp PlayerLevelUp; public const string PlayerOffline PlayerOffline; public const string ItemCountChanged ItemCountChanged; }这里有一个容易忽略的点如果事件参数定义成class监听者A拿到参数后改了一个字段监听者B再拿到的就是被改过的数据。而struct在传递时是按值拷贝的虽然里面的引用类型字段比如string、List被修改时依然可能影响其他人但至少比普通class安全得多。ET8.1具体的事件常量实现方式不同发行版之间会有细微差别有的是字符串、有的是整数枚举。我习惯用静态常量类集中管理配合IDE的全局搜索能很轻松地看到某个事件一共有哪些发布点和监听点排查问题的时候优势非常明显。2.2 注册表EventSystem内部如何找到监听者ET框架启动的时候EventSystem会扫描程序集把带有事件标记的类收集起来构建一个“事件类型 - 监听器列表”的映射表。这里最核心的数据结构就是一个字典键是事件类型值是一个监听器列表。我用最小代码还原一下这个思路方便理解public class EventComponent : Entity { private readonly Dictionarystring, ListIEventListener _listeners new(); public void Register(string eventType, IEventListener listener) { if (!_listeners.TryGetValue(eventType, out var list)) { list new ListIEventListener(); _listeners[eventType] list; } list.Add(listener); } public void UnRegister(string eventType, IEventListener listener) { if (_listeners.TryGetValue(eventType, out var list)) { list.Remove(listener); } } }注意这里我用的是ListIEventListener而不是HashSet。原因是框架需要保留监听器的注册顺序同时List在遍历时对CPU缓存也更友好。如果你在代码里看到有人把监听器列表换成HashSet那就要想清楚是不是真的不在乎顺序了。监听器的实例形态在ET8.1里有两种常见情况。一种是全局单例形态的监听器框架启动时注册之后整个进程生命周期内一直存在另一种是跟随实体系统创建和销毁的监听器比如某个副本内的怪物模块、某个玩法内的临时UI逻辑必须等实体销毁时顺手注销否则就会产生悬挂引用。2.3 Publish时发生了什么事当业务代码调用事件发布接口时框架内部大致会做这几件事根据事件类型从注册表里取出监听器列表。遍历列表逐个调用监听器的处理入口。如果监听器返回的是ETTask就通过协程调度器继续执行异步流程。派发完成后回收临时状态。一个最关键的设计是单个监听器抛异常不应该影响其它监听器继续执行。如果A监听器挂了B监听器也跟着不跑那么一个异常就能瘫痪一整条业务链这在服务器上是不可接受的。我在自己的事件派发入口加了异常隔离逻辑类似这样private async ETTask PublishAsync(string eventType, object args) { if (!_listeners.TryGetValue(eventType, out var list)) return; foreach (var listener in list) { try { await listener.Handle(args); } catch (Exception e) { Log.Error($handle event {eventType} error: {e}); } } }Log.Error里必须带上事件类型和完整异常堆栈。后面我会专门说如果这里只打一句话而不带堆栈线上排查会非常痛苦。2.4 同步与异步的微妙差异事件处理可以是同步的也可以是异步的。同步事件比较直观发布事件后当前线程会一直执行到所有监听器处理完然后才回到发布点。异步事件则不一样监听器内部可以继续await其它ETTask整个事件链会被切分成多个协程片段。我自己的经验是能同步处理的事件尽量同步处理不要为了“统一风格”把每个事件都写成异步。每个异步事件都会引入协程状态机等于是用额外的开销换来了异步能力。如果一个监听器里只需要改几个内存字段、发一条日志完全没必要做成异步。反过来如果监听器里要await远程Actor消息、要等待数据库操作完成那就必须走异步接口否则会阻塞调用线程。这里不要自己开Task.Run去裸跑ET框架有自己的一套调度上下文事件处理里应该直接使用ET的异步接口让框架接管协程生命周期。3. 在ET8.1项目里落地事件监听的实操记录理论再多不如看一次落地过程。我拿一个最常见的业务场景——玩家下线来说一说从“面条式调用”改成事件监听之后代码到底发生了哪些变化。3.1 重构案例玩家下线逻辑从面条式调用变为事件发布早期项目里玩家下线逻辑大概是这样的public async ETTask PlayerOffline(Player player) { await player.SaveToDB(); await NoticeFriends(player.Id); await UpdateGuildOfflineTime(player.Id); await RefreshLeaderBoard(player.Id); }这段代码最大的问题是“下线”这个动作被硬编码成了对一堆具体系统的调用。后续每加一个新的系统就得来这里加一行。更麻烦的是有些系统只是想知道“有个人下线了”并不需要同步等待结果比如好友系统只需要推送一条离线通知它不关心排行榜刷新成不成功。改成事件发布之后主流程精简成这样public async ETTask PlayerOffline(Player player) { await EventComponent.Instance.PublishAsync( GameEventType.PlayerOffline, new PlayerOfflineArgs { PlayerId player.Id, OfflineTime TimeHelper.ServerNow() }); }好友系统、公会系统、排行榜系统各自写一个监听器注册到同一个事件上。想加一个“离线清理缓存”的系统就再写一个监听器主流程一行都不用改。重构前后对比非常直观对比项直接调用事件监听新增系统成本修改下线主流程新增监听类下线逻辑可读性依赖越多越难读始终保持精简测试隔离度难以单独测一个系统可以单独构造事件参数测监听器调用链排查直接看代码逐行调需要查注册表和相关事件但也要注意一个坑重构的时候不是把直接调用的那几行原封不动挪到监听器里就完事了。比如离线事件发布时Player对象可能已经被释放了一部分监听器里如果还反向依赖Player实例就会读到空引用。我建议事件参数里不要直接放对象引用而是放Id、数值、时间这些不可变字段。监听器拿到这些字段后再决定自己要不要继续构造完整上下文。3.2 监听器注册时机与生命周期管理很多踩坑案例都出现在生命周期管理上。ET8.1里我推荐的做法是在系统组件的Awake阶段注册监听器在Destroy阶段注销。public class FriendSystem : Entity { private FriendOfflineHandler _offlineHandler; protected override void Awake() { _offlineHandler new FriendOfflineHandler(this); GetParentEventComponent().Register(GameEventType.PlayerOffline, _offlineHandler); } protected override void Destroy() { GetParentEventComponent().UnRegister(GameEventType.PlayerOffline, _offlineHandler); _offlineHandler null; } }这里有两个细节需要注意。第一不要在构造函数里注册监听器。ET框架里实体对象的构造函数执行时机和初始化时机不完全一致构造函数里拿不到完整的父节点和依赖组件注册监听器很容易出现空引用。第二不要用匿名函数直接注册。匿名函数没法保存引用后续根本无从注销用一次泄漏一次。那什么时候用手动注册、什么时候用框架自动管理如果事件处理和某个实体的生命周期绑定就放到该实体组件里手动注册注销如果事件处理是全局服务比如“所有系统共用的审计逻辑”那就可以在框架启动阶段注册一次让注册表长期持有。3.3 调试验证如何确认事件真的派发了我第一次在ET里写事件时在业务代码里Publish了一个事件结果等了半天日志没输出。我以为是监听器写错了查了半天才发现是事件类型常量拼错了一个字母注册表里压根没有这个事件。从那以后我养成了三个调试习惯。第一在发布入口统一打事件日志。不必每条都打但至少联调阶段要能看到事件名和参数摘要。第二监听器入口打日志。发布日志和监听日志配对就能明确“发布了但没监听”还是“监听了但没执行”。第三写一个“事件审计”小工具统计每个事件被发布的次数、监听器数量、平均耗时。ET本身有日志和性能工具可以在Publish入口包一层。打印出类似这样的信息var sw System.Diagnostics.Stopwatch.StartNew(); await PublishAsync(eventType, args); sw.Stop(); if (sw.ElapsedMilliseconds 50) { Log.Warning($event {eventType} cost {sw.ElapsedMilliseconds}ms); }这个工具在线上排查时特别有用。哪条事件链路慢一眼就能看出来不需要靠猜。4. 事件监听里那些坑我一个个踩过来的这一节全部是实战里踩过的泥坑。每个问题我都能讲出一个真实的线上故事这里就挑最具代表性的五个展开。4.1 最容易犯的错监听器重复注册导致事件执行两次场景某系统模块在热重载后初始化逻辑被调用了两次同一个监听器被注册了两遍。事件触发后日志里出现两次完全相同的处理记录比如玩家下线后收到了两封离线邮件。排查方式在Register接口里打上调用堆栈看到底是谁在重复注册。也可以用断点配合“条件断点”检查监听器列表的Count。解决方案有两种。第一种注册方法做成幂等注册前先UnRegister再Register但这样会引入额外的查找开销只适合低频注册的全局监听器。第二种从生命周期管理上保证每个实例只注册一次我强烈推荐这种。用HashSet去重只能防同一个实例重复注册防不住两个不同实例做同样的事情。4.2 异步监听者之间的隐式顺序场景玩家下线事件里监听器A负责把玩家数据保存到数据库监听器B负责清理缓存。业务上必须等A保存完成之后B才能清理缓存否则缓存清理完又会被A回写一遍。但异步事件的多个监听者之间没有天然的顺序保证B可能比A先执行数据就乱了。解决方法我试过三种最后真正管用的是拆事件。下线事件先只做保存保存完成后再发布“下线保存完成”事件B去监听后者。或者不要用事件。如果A和B之间存在明确依赖就显式调用A完成后再调用B不需要强行套事件。事件监听适合“互相不依赖”的通知。一旦监听者之间出现依赖说明这个事件粒度已经太大了应该拆成多个阶段事件。4.3 事件参数被监听者修改场景有一个事件参数是class监听器A不小心把参数里的PlayerId改成了另一个值监听器B拿到后处理了别人的数据。这个问题极其隐蔽发布端看到的参数是正确的B却收到了脏数据查了很久才发现是A改的。解决这个问题的思路是设计事件参数时尽量使用struct字段定义为readonly如果必须传一个复杂对象传只读包装或者防御性拷贝。另外事件参数里不要放可变List、Dictionary这类集合字段实在要放监听器拿到后只能遍历不能增删改。4.4 热更新环境下事件类型悬挂ET8.x经常配合HybridCLR做热更新事件监听类本身可能在热更程序集里。热更程序集卸载再重新加载后旧注册表里如果还保留着指向旧程序集类型的监听器就可能触发TypeLoadException或者调用到已经无法访问的类型。处理方式在程序集加载完成后重建事件注册表在程序集卸载前清空注册表。不要让跨程序集的事件引用长期悬挂在注册表里。这一点跟plusready那种页面级事件完全不一样。plusready只管页面加载那一下ET服务端的事件表是长期存活的程序集生命周期必须纳入考虑。4.5 被吞掉的异常与排查链路异常隔离本身是好事但处理不当会让错误变得不可见。比如某个监听器内抛出异常被catch后只记录了一句话没有记录事件名、监听器类型、参数内容和完整堆栈。线上出现“事件没跑完但不知道断在哪”的情况时这种日志完全帮不上忙。我现在的做法是捕获异常时必须把上下文信息完整记录事件类型。监听器类型。事件参数摘要不能打印敏感数据。完整异常堆栈。排查链路一般是打开事件审计日志定位最后一条完整发布的事件。查看该事件有几个监听器。逐个监听器加日志或断点二分定位到具体某个监听器。重点检查异步方法内部是否还有嵌套的try-catch吞掉异常。5. 关于事件监听的设计取舍和我的使用习惯说完了坑最后聊一聊我在设计层面怎么把握事件监听的度。这一节更偏个人经验但都是我经历过大大小小事故之后沉淀下来的原则。5.1 不是所有跨模块通信都要用事件我见过有些团队一上来就把所有模块通信都改成事件结果代码里全是EventBus、EventCenter跳转关系复杂到睁眼瞎。我的建议是先看场景特征再决定方式。场景特征适合方式需要返回值直接调用方法调用频率极高单帧内多次直接调用或持有引用调用方关心执行结果直接调用多个系统都想响应顺序不敏感事件监听属于事后通知不需要反馈事件监听事件监听最大的隐性成本是追踪困难。直接方法调用的代码按Ctrl点击就跳过去了事件监听需要在注册表里找监听器再去逐个查看。所以事件不是越多越好而是越高价值越好。5.2 事件命名与代码组织规范我给自己定的规范是事件名称用过去时或“已发生”的语气比如PlayerLevelUp、ItemRemoved不要用PlayerLevelUpProcess这种带流程感的词。事件参数类统一放在Events目录下按业务域分文件夹。目录结构大致这样Events/ Player/ PlayerLevelUpArgs.cs PlayerOfflineArgs.cs Item/ ItemCountChangedArgs.cs Guild/ GuildMemberOnlineArgs.cs事件常量集中在同一个静态类里按领域分组方便全局搜索。这些规则看起来简单但能保证你三个月之后再接手这段代码时不用靠记忆和猜。5.3 把事件监听和监控结合起来事件驱动系统的最大痛点是不可见性。一朝事件多了线上某个模块行为异常你很难凭直觉判断是哪里触发的。我的做法是把事件发布入口统一包装天然叠加两层能力一个是性能统计一个是发布审计。性能统计可以用Stopwatch包裹var sw System.Diagnostics.Stopwatch.StartNew(); await EventComponent.Instance.PublishAsync(eventType, args); sw.Stop(); if (sw.ElapsedMilliseconds threshold) { Log.Warning($event {eventType} cost {sw.ElapsedMilliseconds}ms); }发布审计则可以维护一个环形缓冲记录最近N条事件发布的日志方便联调时随时翻查。这样线上排查的时候不再需要靠巧合复现直接看最近事件流水就能猜个大概。我自己项目里的线上问题至少六成是靠事件审计日志定位到具体事件再顺着事件找到监听器最后定位到代码问题的。这个习惯值得每一位做ET服务端的同学养成。最后再分享一点我自己的习惯。事件监听这东西用得好了是模块解耦的利器用不好就是一个隐形的全局跳转。我现在的做法是每个事件必须有明确的发布者和关心的场景不允许随手注册、随手发布每次发布都走统一入口方便加审计每个事件参数类尽量只读。刚开始可能觉得繁琐但等你维护两个月后就会明白这些“繁文缛节”都是在给未来的自己省时间。希望这篇关于ET8.1事件监听的实战拆解能帮你们少踩几个我踩过的坑。
返回列表