☰
Unity观察者模式实战:事件中心解耦与订阅退订纪律
2026/10/27 2:42:19 网站建设 项目流程

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 Dictionary<string, Action<object>> handlers = new Dictionary<string, Action<object>>(); public static void Subscribe(string key, Action<object> 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, Action<object> 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 { ((Action<object>)snapshot[i]).Invoke(payload); } catch (Exception e) { UnityEngine.Debug.LogError($"事件 {key} 的订阅者抛出异常:{e}"); } } } public static void Clear() => handlers.Clear(); }

这里有几个刻意的选择。第一,用Action<object>而不是无参委托,是为了让同一套骨架能带数据;用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 次伤害,那么阈值应该大于 (n+1)×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,两个方法紧挨着,各自三五

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询