资讯动态

Unity观察者模式实战:事件中心解耦与订阅退订纪律

发布时间:2026/10/27 2:42:27 来源:尧图企业网站定制
1. 为什么我在项目里反复用观察者模式我最早接触观察者模式是在一个中小型的战斗Demo里当时角色血量变化要同步给血条UI、音效系统、成就统计、屏幕震动四个地方。图省事直接在角色受击的函数里逐个调用写完那一刻觉得挺快等策划说“成就统计先去掉”“再加一个低血量警告”我就要回到那个函数里改四遍。更难受的是某天我把UI对象给销毁了忘了清引用受击直接报空指针。那次之后我才下定决心把所有这种“一件事要通知一堆人”的场景全部换成发布-订阅结构。观察者模式发布-订阅模式说白了就是两层解耦事件发生的一方只管“喊一嗓子”不需要知道谁在听关心这件事的一方自己去“登记”事来了自动收到消息不关心了就撤掉登记。放到Unity里这个模式的短期收益是代码不再缠成一团长期收益是模块边界变清楚了——战斗逻辑不依赖UI层UI层也不依赖战斗逻辑双方只认一个事件中心。这东西不复杂但工程上能不能用得住全看细节。适合谁看如果你写过那种“一个方法里连着改七八个系统状态”的代码或者接手过一个“改一处崩三处”的项目这篇会很对胃口。新手可以把它当作解耦的第一课有经验的朋友可以重点看事件键设计、注销时机、派发顺序这几块这几处是最容易埋雷的。2. 整体设计思路与方案取舍2.1 为什么不用直接引用而要多绕一层事件中心直接引用的写法最直观但它把“通知方”和“接收方”绑死在了一起。角色类持有UI类的字段意味着角色类在被编译时就得知道UI类的存在这两个模块再也拆不开。更现实的问题是生命周期UI对象在切场景时被销毁角色类手里的引用就变成了一座“断桥”访问就炸。多绕一层事件中心本质上是在两者之间插入一个双方都认识的中间人。通知方只认识中间人接收方也只认识中间人。中间人的生命周期跟着游戏走不跟场景走所以两边什么时候生、什么时候死都互不干扰。这就是为什么这个模式在场景切换频繁的项目里价值特别大。代价也是有的调用链变长了调试时你在受击函数上打断点走一步就跳进了事件中心看不到具体谁在响应。这个问题只能靠日志和调试工具补不能靠退回直接调用来解决。我的态度很明确跨模块的通信一律走事件模块内部的短调用保持直接调用否则整个工程会变成“处处是事件”的泥潭反而没人看得懂。2.2 用字符串键还是类型键我的实际选择事件键的设计是这个模式第一个分水岭。常见做法有三种方案写法示例优点坑点字符串常量OnPlayerDamaged上手最快跨程序集无成本手写易拼错重构时改名不跟着变只能在运行时报错静态类常量GameEvents.PlayerDamaged集中管理能被IDE跳转依然靠字符串拼错不报错枚举GameEvent.PlayerDamaged有类型检查性能好跨模块共享需要单独程序集加事件要改公共文件事件类型自定义结构体/类按事件类型分发类型安全最强泛型与装箱要注意代码量偏多我在中小项目里最常用的是“静态类里的字符串常量”配合一个集中声明文件理由是成本最低、可读性够。但有个硬性要求常量只能在一个文件里声明任何人加事件都必须去那个文件里加不允许在业务代码里写裸字符串。这是纪律问题不是技术问题一旦开了口子三个月后你会在工程里搜出十几种拼法。大一点的项目我更倾向用事件类型分发尤其是带数据的那些事件。原因是字符串键没法约束数据类型同一个键在不同地方传的结构不一样运行时才炸排查成本很高。2.3 同步派发与延迟派发的取舍派发时机也是个容易被忽略的设计点。同步派发就是发布瞬间立刻遍历所有订阅者依次调用逻辑直观但会带来几个麻烦在遍历过程中如果有人订阅或退订集合被修改轻则漏通知重则直接抛异常某个订阅者里又发布了同一个事件就会形成隐式递归层级一深就难查。延迟派发是把事件先塞进队列等当前逻辑走完再统一处理。它能规避遍历中改集合的问题也让事件流变得有序可追溯但代价是“滞后一帧”的观感问题——比如受击后血条慢半拍虽然只有极短时间做特效时还是会被察觉。我的做法是默认同步但对集合操作做保护遍历前把订阅者列表复制一份到临时列表里再遍历这样遍历期间的增删不影响当前这一轮。这个方案实现成本极低能吃掉大部分坑。真正需要延迟的只有少数场景比如“一帧内多次触发只处理一次”的合并需求那时候再单独走队列不要一刀切。3. 核心实现细节与关键环节拆解3.1 事件中心的最小骨架核心就三个方法订阅、退订、发布。另外加一个清空用于场景切换或退出时兜底。骨架不长但每一行都有讲究。using System; using System.Collections.Generic; public static class EventCenter { private static readonly Dictionarystring, Actionobject handlers new Dictionarystring, Actionobject(); public static void Subscribe(string key, Actionobject handler) { if (string.IsNullOrEmpty(key) || handler null) return; if (handlers.TryGetValue(key, out var existing)) handlers[key] existing handler; else handlers[key] handler; } public static void Unsubscribe(string key, Actionobject handler) { if (string.IsNullOrEmpty(key) || handler null) return; if (!handlers.TryGetValue(key, out var existing)) return; var next existing - handler; if (next null) handlers.Remove(key); else handlers[key] next; } public static void Publish(string key, object payload null) { if (string.IsNullOrEmpty(key)) return; if (!handlers.TryGetValue(key, out var existing)) return; // 关键复制一份再遍历避免遍历中增删导致异常 var snapshot existing.GetInvocationList(); for (int i 0; i snapshot.Length; i) { try { ((Actionobject)snapshot[i]).Invoke(payload); } catch (Exception e) { UnityEngine.Debug.LogError($事件 {key} 的订阅者抛出异常{e}); } } } public static void Clear() handlers.Clear(); }这里有几个刻意的选择。第一用Actionobject而不是无参委托是为了让同一套骨架能带数据用object而不是泛型是为了避免静态泛型类被拆成多份实例导致“订阅到了另一份里”的经典事故。第二Publish里逐个 try-catch是因为一个订阅者崩了不应该影响其他订阅者事件中心要做的是隔离故障而不是让整条链路一起死。第三Clear一定要有它是最后的安全网。注意GetInvocationList会分配数组高频事件比如每帧触发的移动同步不要走这套字符串事件那是另一个量级的需求用直接引用或者专门的高频通道更合适。3.2 订阅与退订的配对纪律这个模式最容易出事故的地方不是发布而是退订。我见过最多的线上问题就是“对象销毁了但没退订事件一来就访问空对象”。规矩只有一条在哪里订阅就在对称的生命周期里退订绝不跨层代劳。Unity里比较稳妥的落点是OnEnable订阅、OnDisable退订。这两个方法在对象启停时会成对调用天然对称。有人喜欢Start订阅、OnDestroy退订也能用但如果对象被反复启停比如对象池回收Start只跑一次而OnDestroy拖到很晚中间那段就会收到不该收的消息。public class HealthBarView : MonoBehaviour { private void OnEnable() { EventCenter.Subscribe(GameEvents.PlayerDamaged, OnPlayerDamaged); EventCenter.Subscribe(GameEvents.PlayerDied, OnPlayerDied); } private void OnDisable() { EventCenter.Unsubscribe(GameEvents.PlayerDamaged, OnPlayerDamaged); EventCenter.Unsubscribe(GameEvents.PlayerDied, OnPlayerDied); } private void OnPlayerDamaged(object payload) { if (payload is DamageInfo info) Refresh(info.Current, info.Max); } private void OnPlayerDied(object payload) PlayDeathAnim(); }写法上有个小技巧订阅和退订的代码块放在一起方法名一模一样肉眼看过去就能对照检查。不要用Unsubscribe(key)这种“把该键全部清掉”的重载去偷懒那会把别人的订阅一起干掉问题出现时极其难查——症状是“某个UI偶尔不更新”你可能查一整天。3.3 事件数据的设计与装箱控制传数据的方式直接影响可维护性。最省事的写法是传一个字典或者可变对象什么都能塞但用的人根本不知道这个事件到底该带哪些字段只能去翻发布的代码等于把耦合从类转移到了“心照不宣的约定”上。我推荐为有数据的事件定义只读结构体public readonly struct DamageInfo { public readonly int Current; public readonly int Max; public readonly int Delta; public readonly DamageSource Source; public DamageInfo(int current, int max, int delta, DamageSource source) { Current current; Max max; Delta delta; Source source; } }结构体传参走的是值拷贝不产生堆分配这点在高频事件里很重要。但要注意把结构体装进object参数时会发生装箱每次发布都会分配一个堆对象触发GC。如果这个事件一帧要发几十次就得改成泛型通道或者专用接口别硬扛。实测下来的经验是低频事件受击、死亡、关卡完成、货币变化用object装箱完全无所谓一秒几次的分配量在GC面前毫无存在感只有每帧级别的数据流才需要上泛型。别为了“性能洁癖”把简单的东西写复杂维护成本也是成本。3.4 派发顺序与依赖问题事件中心不保证顺序或者说它保证的是“订阅先后顺序”而这个顺序在运行时是不可控的——谁先OnEnable谁就先收到。一旦某个订阅者的处理逻辑依赖另一个订阅者的结果这套结构立刻变成定时炸弹。典型的例子是“伤害数字飘字”依赖“血量已经扣完”如果飘字先收到消息读到的就是旧血量。解决办法不是去调订阅顺序那是治标。正确的做法是把依赖关系从事件里拿出去需要“先算完再通知”的逻辑就别用两个独立订阅者而是在发布方那里按顺序做事或者让有依赖的一方主动去查询数据源而不是等别人推。提示我给自己定的规矩是——订阅者之间不允许有先后依赖。如果发现两个订阅者必须按顺序跑说明它们本该是同一个模块或者事件拆分得不对。4. 完整落地流程与典型场景实操4.1 从零搭一套可用的通知链路假设做一个最小闭环玩家受击血条更新低血量触发警告死亡播放表现。步骤我按实际动手顺序写。第一步建一个事件键声明文件所有键都在这里谁都不许另起炉灶。文件里只放常量不写逻辑保证任何人一眼能看全工程有哪些事件。第二步定义带数据的结构体比如上面那个DamageInfo放在独立文件里跟事件键文件分开避免公共文件越滚越大。第三步写发布方。发布方只做三件事算数值、发事件、不管后续。public class PlayerHealth : MonoBehaviour { [SerializeField] private int maxHp 100; private int currentHp; private void Awake() currentHp maxHp; public void TakeDamage(int delta, DamageSource source) { if (currentHp 0) return; currentHp Mathf.Max(0, currentHp - delta); EventCenter.Publish(GameEvents.PlayerDamaged, new DamageInfo(currentHp, maxHp, delta, source)); if (currentHp 0) EventCenter.Publish(GameEvents.PlayerDied, null); } }第四步写订阅方。血条只管刷新显示低血量警告只管判断阈值两者互不认识。public class LowHealthWarning : MonoBehaviour { [SerializeField] private float threshold 0.3f; [SerializeField] private GameObject warningRoot; private void OnEnable() EventCenter.Subscribe(GameEvents.PlayerDamaged, OnDamaged); private void OnDisable() EventCenter.Unsubscribe(GameEvents.PlayerDamaged, OnDamaged); private void OnDamaged(object payload) { if (payload is not DamageInfo info) return; float ratio info.Max 0 ? 0f : (float)info.Current / info.Max; warningRoot.SetActive(ratio 0f ratio threshold); } }第五步OnDisable之外再补一层保险在场景卸载或者重启流程里调用一次EventCenter.Clear()。这一步在编辑器里反复进出Play模式时能救你很多次因为静态字段不会跟着场景销毁上一次运行的订阅会残留到下一次。4.2 参数与阈值的确定过程看代码会觉得阈值是拍脑袋写的其实要算。低血量警告的阈值怎么定取决于玩家的反应时间。假设角色满血能扛住若干次伤害一次伤害占比是确定的那么阈值至少要留出“一次完整操作循环”的余量否则玩家看到警告的瞬间已经死了警告毫无意义。具体算法设单次伤害占最大生命的比例为 d玩家从看到警告到做出有效反应需要的时间为 t期间可能再吃到 n 次伤害那么阈值应该大于 (n1)×d。这个值算出来通常落在两成到三成之间跟游戏节奏相关快节奏可以压到两成慢节奏可以放宽到三成五。同样的思路适用于所有基于事件触发的表现。冷却提示什么时候亮、连击提示什么时候断都属于“阈值要提前于事件发生”的问题别凭手感先估算时间窗再定数值。4.3 用对象池时要特别注意的那一段对象池会复用对象行为跟普通实例完全不同。被回收时对象没有销毁OnDestroy不会触发如果退订写在OnDestroy里那么池子里的对象会带着订阅一直活着池子越大泄漏越多。这属于很典型的隐性泄漏不看内存根本发现不了。正确姿势是出池时订阅回池时退订落点用OnEnable/OnDisable就行只要池子在回收时正确调用了SetActive(false)这两个方法就会成对触发。如果你的池子不切激活状态那就必须手动在回收流程里调退订。这是我在池化UI上踩过的坑现象是“列表滚动后重复刷新”查了很久才定位到旧对象还在收消息。4.4 弱引用方案什么时候值得上有些团队会要求订阅方用弱引用登记好处是忘了退订也不会阻止对象被回收事件中心在派发时跳过已失效的引用。代价是判断成本、调试复杂度以及“以为退订了其实没有”的语义模糊。我的态度如果团队能把订阅退订纪律执行到位不需要弱引用如果项目规模大、人员流动频繁、新人多可以上弱引用当兜底但依然要保留显式退订弱引用只作为最后一道保险不能当设计基础。原因很简单弱引用解决的是“对象被回收后的安全”解决不了“对象还活着但逻辑上不该再收消息”的问题后者才是业务bug的大头。5. 常见问题与排查技巧实录5.1 症状对照表症状可能原因排查方向没有任何响应键拼写不一致静态字段残留被清空订阅代码未执行打印订阅与发布的键值对照检查OnEnable是否被调用收到多次通知重复订阅且未退订对象被启用多次在订阅处打日志统计次数检查是否有手动重复挂载偶发不更新派发时遍历中集合被修改确认Publish是否用了快照遍历空引用异常对象已销毁但订阅还在检查退订落点是否与订阅对称一个订阅者出错其他人全收不到未做异常隔离派发时逐个 try-catch进Play模式就带着旧状态静态字段跨运行残留初始化时Clear或用RuntimeInitializeOnLoadMethod清理5.2 我踩过的三个具体坑第一个坑是“静态类不重置”。事件中心是静态的编辑器里反复进出Play模式上一次的订阅会留到下一次表现为“第一次运行正常第二次开始重复响应”。修复方法是在初始化时清空用[RuntimeInitializeOnLoadMethod]挂在启动流程上确保每次运行都是干净状态。这个问题在没有关闭域重载的编辑器设置下几乎必然出现跟代码写得对不对没关系。第二个坑是“遍历中退订”。早期我在Publish里直接遍历原集合结果某个订阅者在处理时把自己退了订集合被改后面的人全漏掉。这个bug极难复现因为只在特定时序下发生。改为快照遍历后彻底解决代价是每次派发一个数组分配对低频事件完全可接受。第三个坑是“事件键重复语义”。曾经有两个模块各自定义了名字几乎一样的事件一个叫受击一个叫受伤语义重叠结果数值被扣了两遍。这类问题靠代码约束很难完全防住我的做法是把事件键文件当成接口文档来维护每个键后面写一行注释说明触发时机和数据定期的代码评审里专门看一眼有没有语义重复。注意不要在事件回调里做重活。订阅者的处理时间直接叠加进发布方的调用耗时里一个界面刷新慢卡的是战斗逻辑那一帧。重活扔给延迟执行或者专门的更新循环。5.3 一个实用的调试小工具想看清事件流可以在事件中心里加一个可开关的日志public static bool Verbose false; public static void Publish(string key, object payload null) { if (Verbose) UnityEngine.Debug.Log($[Event] {key} - {handlers.GetValueOrDefault(key)?.GetInvocationList().Length ?? 0} 个订阅者); // ... 后续派发 }用条件编译或者编辑器下默认开启、发布包关闭的方式控制。排查“为什么这个UI不动”的时候先看有没有订阅者再看有没有发布两行日志就能把问题范围砍掉一半比断点调试快得多。5.4 关于性能的实际感受很多人担心事件中心成瓶颈我的实测结论是在每秒几十次到几百次的量级下字典查找加委托调用的开销完全可以忽略真正带来开销的是装箱和异常。所以优化优先级很清楚——先避免高频事件走装箱通道先避免把异常当控制流用这两项做完再看有没有必要折腾。每帧都要发的事件比如血条跟随、体力条插值我建议根本不要走事件直接持有引用或者用专门的轻量更新列表逻辑上也更自然。还有一点容易被忽视订阅基数。同一个键上挂了几百个订阅者时一次派发的总耗时是线性叠加的这时候要审视是不是用错了——几百个对象监听同一个事件多半是设计问题比如每个列表项都监听全局的货币变化事件正确做法是列表容器监听一次再内部分发。这是架构层面的反思不属于模式本身的锅。5.5 最后分享一条纪律这套东西用了几年我给团队定的唯一铁律是订阅与退订必须成对出现在同一个文件里且能一眼对上。所有因为观察者模式出的事故追到根上几乎都是这一条被破坏——有人为了方便在别处补了个订阅却没人记得补退订。工具能帮的有限纪律才是长期稳的根本。我在项目里见过最清爽的写法是每个订阅方开头就放一对OnEnable/OnDisable两个方法紧挨着各自三五

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑