目录事件系统的七宗罪第一宗罪孤儿订阅第二宗罪跨会话的数据污染第三宗罪递归触发第四宗罪丢失的 Schedule Handle第五宗罪Lambda 陷阱再说一次第六宗罪核弹级的 RemoveAllListeners第七宗罪昂贵的谓词GES 的预防模式黄金法则OnEnable / OnDisableAuto Static ResetGES 内置的数据污染预防递归防护模式Handle 管理永远存储永远取消SetInspectorListenersActive批量静音精准移除永远不要用 RemoveAllListeners 做清理缓存你的委托保持谓词廉价架构模式Service Event Interface上线前检查清单你一直在每次测试 5 分钟。跑得好好的。然后 QA 提了个 Bug30 分钟游玩过程中内存持续增长。加载 6 个场景后帧率从 60 降到 40。你去 Profile。一个应该只有 12 个监听器的事件上注册了 847 个。每次场景加载都添加了新的订阅但从未移除旧的。对象被销毁了但它们的委托引用还活着把已经死掉的 MonoBehaviour 钉在内存里垃圾回收器碰都碰不到。或者这个第二次进入 Play Mode 后血量数值不对。第一次运行没问题。你按 Play测战斗停止。再按 Play玩家以 73 HP 开始而不是 100。上一个会话的 ScriptableObject 状态泄漏了因为没人重置它。再或者经典的游戏卡了 3 秒然后 Unity 崩溃。事件 A 的监听器触发了事件 B事件 B 的监听器触发了事件 A。栈溢出。但有时候它不崩溃 —— 只是卡住在一个不产生任何可见错误的死循环里吃 CPU。这些不是假设。这些是我见过的上线游戏里的真实 Bug。根本原因都一样事件系统的模式单独看没问题但到了规模化的时候就崩了。事件系统的七宗罪在聊解决方案之前先把失败模式列出来。每个事件系统 —— 不只是 GES不只是 Unity 的任何语言里的任何发布/订阅实现 —— 都有这些潜在陷阱。能不能上线的区别在于团队是在第一轮 QA 之前还是之后才知道这些。第一宗罪孤儿订阅这是存在时间最久的事件系统 Bug。在Awake()里订阅忘了取消订阅。对象被销毁了但委托还持有引用。垃圾回收器没法回收这个 MonoBehaviour因为事件的调用列表还指着它。publicclassBadExample:MonoBehaviour{[GameEventDropdown,SerializeField]privateInt32GameEventonDamage;privatevoidAwake(){onDamage.AddListener(HandleDamage);// No corresponding RemoveListener anywhere}privatevoidHandleDamage(intamount){// This method will be called even after the object is destroyed// Unity marks it as destroyed, but the C# object is still alive// because the delegate reference prevents GCtransform.positionVector3.up;// MissingReferenceException}}阴险的地方在于第一个场景完全没问题。第二个场景运气好也没问题。内存泄漏是看不见的直到有人玩了 20 分钟加载了足够多的场景累积了几百个孤儿委托。在 Profiler 里你会看到托管内存随每次场景加载稳步增长。泄漏的不只是 MonoBehaviour —— 还包括这些 MonoBehaviour 引用的一切纹理、网格、材质。一个泄漏的监听器就能钉住数兆字节的资产。第二宗罪跨会话的数据污染Unity 的 Play Mode 有一个微妙的陷阱。ScriptableObject 实例在 Play Mode 会话之间持久存在于内存中。如果你的事件作为 ScriptableObject存储了运行时状态 —— 监听器列表、缓存值、调度句柄 —— 那些状态在你停止游玩后还在。按 Play订阅 5 个监听器停止。再按 Play。那 5 个监听器还注册在 ScriptableObject 的内存里……但拥有它们的 MonoBehaviour 已经没了。现在列表里有 5 个死委托加上新会话的 5 个新委托。停了又 Play 10 次50 个死委托。表现为事件触发次数比预期多上次会话的幽灵监听器第一次触发事件就MissingReferenceException死委托尝试调用长时间开发过程中编辑器性能逐渐下降对于静态字段问题更严重。静态字段只在特定配置下才能存活 Domain Reload开启了 “Enter Play Mode Settings” 优化时。当它们存活时任何静态缓存、注册表或状态都会在会话间被污染。第三宗罪递归触发事件 A 的监听器触发事件 B。事件 B 的监听器触发事件 A。或者更简单的版本事件 A 的监听器触发事件 A。栈溢出。// Infinite recursionprivatevoidHandleHealthChanged(intnewHealth){// I need to notify everyone that health changedonHealthChanged.Raise(newHealth);// This calls HandleHealthChanged, which calls Raise, which calls...}直接版本很明显。间接版本更难发现OnDamageDealt - HandleDamage - raises OnHealthChanged OnHealthChanged - HandleHealthCheck - raises OnDamageDealt (reflected damage) OnDamageDealt - HandleDamage - raises OnHealthChanged ... forever两个事件两个监听器一个无限循环。而且它不一定崩溃。如果循环因为某个状态条件比如血量归零最终退出了它可能只是造成一次持续数秒的卡顿难以复现因为它取决于特定的游戏状态。第四宗罪丢失的 Schedule Handle你调用RaiseRepeating()传了count: -1无限然后没存 Handle。事件永远触发下去。你没法停止它。跑着它的协程没有外部引用。它就……一直跑。privatevoidStartAmbientEffect(){// Ill cancel this later// narrator: they did not cancel this lateronAmbientPulse.RaiseRepeating(interval:0.5f,count:-1);}Handle 被方法返回然后立刻丢弃了。如果这个方法每次场景加载都运行一次你就每个场景多一个无限重复事件。10 个场景后你有 10 个重叠的环境脉冲每个每秒触发 2 次。本来应该是每秒 2 次的变成了 20 次。第五宗罪Lambda 陷阱再说一次之前在监听器策略的文章里讲过了但它在这个列表里是因为它是事件系统被报告最多的Bug。匿名委托无法取消订阅。privatevoidOnEnable(){onDamage.AddListener((intamount)health-amount);}privatevoidOnDisable(){// This creates a NEW lambda. It doesnt match the one above.onDamage.RemoveListener((intamount)health-amount);// The original is still subscribed. Memory leak.}语言让危险的模式看起来自然。安全的模式看起来啰嗦。这是一个失败之坑。第六宗罪核弹级的 RemoveAllListeners系统 A 管理一个子系统的事件。清理时调用RemoveAllListeners()来清除自己的注册。但RemoveAllListeners()移除的是所有监听器 —— 包括系统 B、C、D 注册的。// CombatSystem.csprivatevoidOnDisable(){// Clean up my listenersonPlayerDamaged.RemoveAllListeners();// OOPS: killed AudioManagers listener too}AudioManager 不播受击音了AnalyticsTracker 不记录伤害事件了AchievementSystem 不追踪里程碑了。就因为一个系统在需要手术刀的地方用了大锤。这在快速原型变成生产代码时特别常见。RemoveAllListeners()写起来比追踪个别引用快。你的系统是唯一监听器时没问题。其他系统开始订阅同一个事件时就悄无声息地崩了。第七宗罪昂贵的谓词Conditional Listener 的谓词在事件每次触发时都会被评估。如果事件每秒触发 60 次而谓词做了一次 Physics.OverlapSphere那就是每个 Conditional Listener 每秒 60 次球体检测。// 60 sphere casts per second, just for the condition checkonPositionUpdate.AddConditionalListener(HandleNearbyEnemies,()Physics.OverlapSphere(transform.position,10f,enemyLayer).Length0,priority:50);Profiler 显示时间花在了条件评估上你纳闷为什么事件系统这么慢。事件系统没问题。是你的谓词在一个本应是廉价布尔检查的委托里干了整个物理系统的活。GES 的预防模式现在聊解决方案。有些内置在 GES 里有些是你通过约定来执行的。黄金法则OnEnable / OnDisable如果整个博客系列你只记住一件事就记这个privatevoidOnEnable(){myEvent.AddListener(HandleEvent);}privatevoidOnDisable(){myEvent.RemoveListener(HandleEvent);}不是Awake/OnDestroy。不是Start/OnApplicationQuit。是OnEnable/OnDisable。为什么偏偏是这对OnEnable/OnDisable 追踪的是活跃状态。禁用一个 GameObjectOnDisable触发监听器移除。重新激活OnEnable触发监听器重新添加。禁用的对象不接收事件 —— 这几乎永远是正确的。Awake/OnDestroy 只触发一次。禁用再重新激活一个在 Awake 中订阅的对象它在禁用期间还在订阅状态接收着不该处理的事件。Start 有时序问题。另一个对象在它的 Awake 中触发事件。你在 Start 中订阅的监听器错过了。OnEnable 在生命周期中更早执行。唯一的例外DontDestroyOnLoad对象上的 Persistent Listener。在OnEnable中用AddPersistentListener订阅在OnDestroy中用RemovePersistentListener移除不是 OnDisable因为对于活跃对象OnDisable 在场景切换时就会触发。// Standard: scene-scoped listenersprivatevoidOnEnable(){myEvent.AddListener(HandleEvent);myEvent.AddPriorityListener(HandlePriority,50);}privatevoidOnDisable(){myEvent.RemoveListener(HandleEvent);myEvent.RemovePriorityListener(HandlePriority);}// Exception: DontDestroyOnLoad persistent listenersprivatevoidOnEnable(){myEvent.AddPersistentListener(HandleEvent,0);}privatevoidOnDestroy(){myEvent.RemovePersistentListener(HandleEvent);}Auto Static ResetGES 内置的数据污染预防GES 通过 Auto Static Reset 机制处理 ScriptableObject 持久化问题。退出 Play Mode 时GES 自动清除所有静态事件缓存所有监听器注册所有已调度的事件句柄所有运行时创建的 Trigger 和 Chain 连接每次按 Play 你的事件都是干净的。不需要手动重置方法不需要[RuntimeInitializeOnLoadMethod]之类的 hack。事件资产本身名称、类型、Inspector 配置持久化因为那是设计期数据。运行时状态监听器、调度、流程连接被清除因为那是运行期数据。这个区分是刻意的。设计期数据应该在会话间持久化 —— 你不想每次测试都重新配置事件。运行时数据不应该持久化 —— 你不想上次会话的幽灵监听器。如果你在事件子类上存了自定义状态你自己的属性或字段需要自己处理重置。Auto Reset 覆盖的是 GES 的内部状态不是你的扩展。你自己的静态字段用[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]。递归防护模式GES 不会自动打断递归循环因为有时候重入触发是有意的很少但存在。用一个防护标志代替privatebool_isProcessingHealth;privatevoidHandleHealthChange(intnewHealth){if(_isProcessingHealth)return;_isProcessingHealthtrue;try{// Process health logic...// Safe: wont recurse because of the guardonHealthChanged.Raise(newHealth);}finally{_isProcessingHealthfalse;}}try/finally至关重要。没有它的话处理逻辑里抛个异常就会让_isProcessingHealth永远卡在 true。这个 Handler 在剩余的整个会话里都不会再触发了。对于间接循环A 触发 B 触发 A要么两个 Handler 都加防护要么重构让循环使用一个不会反馈的独立事件// Before (cycles): OnDamage - HandleDamage - raises OnHealthChanged OnHealthChanged - HandleHealth - raises OnDamage (reflected) // After (no cycle): OnDamage - HandleDamage - raises OnHealthChanged OnHealthChanged - HandleHealth - raises OnReflectedDamage (separate event) OnReflectedDamage - HandleReflected - does NOT raise OnHealthChangedRuntime Monitor 的 Warnings 标签页会标记在已经处理中的事件上再次触发的情况。如果你在测试时看到递归警告说明有个循环需要加防护。Handle 管理永远存储永远取消每个RaiseDelayed()和RaiseRepeating()都返回 ScheduleHandle。永远存起来。永远在 OnDisable 中取消。// ANTI-PATTERN: handle lost foreverprivatevoidStartPoison(){onPoisonTick.RaiseRepeating(10,interval:1f,count:-1);// Can never cancel this. Runs until application quits.}// CORRECT: stored and managedprivateScheduleHandle_poisonHandle;privatevoidStartPoison(){_poisonHandleonPoisonTick.RaiseRepeating(10,interval:1f,count:-1);}privatevoidCurePoison(){if(_poisonHandle.IsActive)_poisonHandle.Cancel();}privatevoidOnDisable(){if(_poisonHandle.IsActive)_poisonHandle.Cancel();}多个并发调度的情况privateListScheduleHandle_activeSchedulesnewListScheduleHandle();privatevoidScheduleSomething(){varhandleonEvent.RaiseDelayed(2f);_activeSchedules.Add(handle);}privatevoidCancelAll(){foreach(varhandlein_activeSchedules){if(handle.IsActive)handle.Cancel();}_activeSchedules.Clear();}privatevoidOnDisable()CancelAll();SetInspectorListenersActive批量静音GES 事件可以在 Behavior Window 中可视化配置监听器。批量操作时 —— 加载 100 个物品、处理批量数据、重置状态 —— 触发粒子、音效、UI 动画的可视化监听器会让人崩溃。myEvent.SetInspectorListenersActive(false);try{for(inti0;i100;i){myEvent.Raise(processedItems[i]);}}finally{myEvent.SetInspectorListenersActive(true);}// Final raise with visual feedbackmyEvent.Raise(summary);代码监听器照常触发。只有 Inspector 配置的可视化响应被静音。try/finally确保即使批处理抛异常也能重新启用。精准移除永远不要用 RemoveAllListeners 做清理每个组件只该移除自己的监听器// BAD: destroys everyones subscriptionsprivatevoidOnDisable(){myEvent.RemoveAllListeners();}// GOOD: removes only what you ownprivatevoidOnDisable(){myEvent.RemoveListener(MyHandler);myEvent.RemovePriorityListener(MyOtherHandler);}RemoveAllListeners()只适合全局状态重置 —— 加载全新的游戏会话、测试后重置。它移除 Basic、Priority 和 Conditional 监听器但故意保留 Persistent Listener因为那些显式选择了不参与清理。缓存你的委托方法引用是监听器最安全的模式// BROKEN: anonymous lambda, can never be removedonDamage.AddListener((intamount)health-amount);// CORRECT: method reference, stable identityonDamage.AddListener(HandleDamage);privatevoidHandleDamage(intamount)health-amount;// ALSO CORRECT: cached delegate for when you need closuresprivateSystem.Actionint_handler;privatevoidOnEnable(){_handler(amount)health-amount;onDamage.AddListener(_handler);}privatevoidOnDisable(){onDamage.RemoveListener(_handler);}所有监听器类型都适用。任何你打算移除的监听器都需要一个稳定的委托引用。保持谓词廉价Conditional Listener 的谓词应该是字段读取不是计算// BAD: physics query every time the event firesonPositionUpdate.AddConditionalListener(HandleNearby,()Physics.OverlapSphere(transform.position,10f).Length0,priority:50);// GOOD: update the cache periodically, read it cheaplyprivatebool_hasNearbyEnemies;privatevoidFixedUpdate(){_hasNearbyEnemiesPhysics.OverlapSphere(transform.position,10f,enemyLayer).Length0;}onPositionUpdate.AddConditionalListener(HandleNearby,()_hasNearbyEnemies,priority:50);每个 FixedUpdate 一次物理查询 vs 每次事件触发一次。对于每帧触发多次的事件这是流畅游戏和卡成幻灯片的区别。架构模式Service Event Interface大型项目里把每个子系统的事件连线集中到一个专门的接口类中publicclassCombatEventInterface:MonoBehaviour{[Header(Outgoing Events)][GameEventDropdown,SerializeField]privateInt32GameEventonDamageDealt;[GameEventDropdown,SerializeField]privateSingleGameEventonCombatStarted;[GameEventDropdown,SerializeField]privateSingleGameEventonCombatEnded;[Header(Incoming Events)][GameEventDropdown,SerializeField]privateSingleGameEventonPlayerDied;[GameEventDropdown,SerializeField]privateInt32GameEventonHealReceived;privateCombatSystem_combat;privatevoidOnEnable(){_combatGetComponentCombatSystem();onPlayerDied.AddPriorityListener(_combat.HandlePlayerDeath,100);onHealReceived.AddPriorityListener(_combat.HandleHeal,100);}privatevoidOnDisable(){onPlayerDied.RemovePriorityListener(_combat.HandlePlayerDeath);onHealReceived.RemovePriorityListener(_combat.HandleHeal);}publicvoidNotifyDamageDealt(intamount)onDamageDealt.Raise(amount);publicvoidNotifyCombatStarted()onCombatStarted.Raise();publicvoidNotifyCombatEnded()onCombatEnded.Raise();}CombatSystem 本身完全不知道 GES 的存在。它调用 CombatEventInterface 上的方法。这让战斗系统可以脱离事件进行测试事件连线集中在一个文件里方便审查。出问题时你只需要检查一个类就能看到战斗系统涉及的所有事件。上线前检查清单在认为你的事件架构达到生产就绪之前过一遍每个AddListener都有对应的RemoveListener在相反的生命周期方法中每个AddPersistentListener都有RemovePersistentListener在OnDestroy中每个RaiseDelayed/RaiseRepeating的 Handle 都已存储并在OnDisable中取消需要移除的监听器没有使用 Lambda只用委托缓存或方法引用没有缺少防护标志的递归事件模式RemoveAllListeners()只用于全局重置绝不用于单组件清理Conditional 谓词是廉价的字段读取不是计算高频事件的监听器数量最小化批量操作时 Inspector 监听器被静音Runtime Monitor 在完整通关过程中没有显示任何警告这十项检查能在 Bug 到达玩家之前捕获 95%。剩下的 5% 是你游戏代码里的逻辑 Bug不是事件系统的问题 —— Runtime Monitor 也能帮你找到那些。所有这些的共同规律是一样的事件系统之所以强大正是因为它解耦了东西。但解耦意味着编译器无法捕获那些耦合本可以暴露的错误。你必须自己执行纪律 —— 或者使用一个替你执行的系统。 全球开发者服务矩阵 Game Event System(GES) 主页 国区开发者社区 Unity 中国资产商店 B站官方视频教程 高性能架构技术文档 国内技术交流群 (1071507578) 全球开发者社区 Unity Global Asset Store Discord 全球技术社区 YouTube 官方频道 Unity 官方论坛专贴 GitHub 官方主页欢迎感兴趣的小伙伴前来围观~