
1. 项目概述为什么游戏开发离不开观察者模式在Unity游戏开发中你是否遇到过这样的场景一个敌人被击败需要同时触发UI更新分数、播放死亡音效、生成掉落物、并可能解锁一个成就。最直接的写法可能是在敌人的Die()方法里手动调用UI管理器、音频管理器、道具生成器和成就系统的各个方法。代码很快就变成了“意大利面条”牵一发而动全身维护和扩展成了噩梦。这正是我们今天要深入探讨的“观察者模式”所要解决的核心问题。观察者模式也被称为发布-订阅模式是游戏事件系统的基石。它定义了一种一对多的依赖关系当一个对象称为“主题”或“发布者”的状态发生改变时所有依赖于它的对象称为“观察者”或“订阅者”都会自动得到通知并更新。在Unity的语境下这意味着你可以将游戏中的各种事件如“玩家受伤”、“金币收集”、“关卡开始”与处理这些事件的逻辑如更新血条、播放音效、生成敌人完全解耦。我见过太多项目初期因为图省事直接用FindObjectOfType或静态引用来硬编码对象间的通信到了中后期代码耦合度极高添加一个新功能就像在布满地雷的战场上行走。而一个基于观察者模式构建的、清晰的事件总线Event Bus或消息系统能让你的项目架构保持灵活和健壮。无论是处理简单的UI交互还是构建复杂的游戏状态机观察者模式都是你必须掌握的核心设计模式。接下来我将结合C#和Unity的特性从设计思路到具体实现为你完整拆解如何构建一个高效、解耦的游戏事件系统。2. 核心设计思路与架构选型在动手写代码之前我们必须明确目标我们要构建的系统应该足够灵活以应对游戏开发中频繁变化的需求同时又要保持简洁避免过度设计。一个典型的事件系统通常包含几个核心部分事件定义、发布者、订阅者以及一个中枢——事件管理器或事件总线。2.1 为什么不用UnityEvent或C#原生事件Unity自带了UnityEvent系统C#也有原生的事件event关键字和委托delegate。它们都能实现观察者模式那为什么我们还要自己构建一套UnityEvent优点是可以在Inspector面板中可视化地拖拽关联函数对设计师和非程序人员非常友好适合配置简单的、脚本内的响应。但其缺点是性能开销相对较大涉及序列化和反射并且难以进行跨场景的、全局性的事件管理。当事件需要携带复杂数据时使用起来也不够直观。C#原生事件性能最好类型安全。但它的问题是作用域受限。事件通常需要在某个类的实例上声明和触发。如果你想实现一个全局的“游戏结束”事件可能需要一个静态类或单例来承载它这在一定程度上引入了全局状态管理不当也会带来问题。我们自建系统的目标是取长补短获得接近C#原生事件的性能同时具备全局访问、强类型、支持复杂事件数据、易于调试和追踪的特性。我们将构建一个类型安全的事件总线任何脚本都可以向总线发布事件也可以从总线订阅感兴趣的事件而彼此无需直接引用。2.2 核心架构设计我们将采用一个基于泛型和接口的强类型事件系统。其核心组件如下IEvent一个空接口用于标记所有自定义事件类。这提供了类型约束让我们的事件总线能处理所有实现该接口的事件对象。自定义事件类例如PlayerDamageEvent、EnemyDiedEvent。它们是简单的C#类可以包含事件相关的所有数据如伤害值、敌人类型、死亡位置等。这些类就是传递的消息体。Action Event Handler事件处理委托。我们使用ActionT来定义其中T是IEvent的子类。这代表一个能处理特定类型事件的方法。EventBus事件总线系统的核心单例管理器。它内部使用字典DictionaryType, object来存储每种事件类型对应的处理器列表。提供SubscribeT,UnsubscribeT,PublishT三个核心方法。这种设计的优势在于完全解耦发布者只知道要发布什么事件不知道谁在处理订阅者只关心自己感兴趣的事件不知道是谁发布的。强类型安全编译器会检查事件类型和处理方法的签名是否匹配避免运行时错误。易于扩展添加新事件只需创建一个新的IEvent类无需修改事件总线。便于调试可以在事件总线中加入日志记录所有事件的发布和消费方便追踪游戏逻辑流。注意关于单例模式的使用需要谨慎。事件总线作为全局访问点使用单例是合理的。但务必确保其生命周期清晰并在游戏退出或场景切换时妥善清理订阅关系防止内存泄漏。一种更高级的做法是引入“作用域”概念区分全局事件和场景局部事件。3. 事件系统核心实现详解理论说完了我们直接上代码。我将分步骤构建这个事件系统并解释每一行代码的意图。3.1 定义事件基接口与基础事件类首先我们创建所有事件的根基。// IEvent.cs namespace MyGame.EventSystem { /// summary /// 所有游戏事件的标记接口。 /// 一个空接口主要用于泛型约束。 /// /summary public interface IEvent { } }接下来我们创建几个具体的事件类。事件类就是纯粹的数据容器DTO最好设计为不可变的readonly字段构造函数这能避免事件在传递过程中被意外修改。// ExampleEvents.cs namespace MyGame.EventSystem { /// summary /// 玩家受到伤害事件 /// /summary public class PlayerDamageEvent : IEvent { public readonly int DamageAmount; public readonly GameObject DamageSource; // 伤害来源如敌人 public readonly Vector3 HitPoint; // 击中点 public PlayerDamageEvent(int damage, GameObject source, Vector3 hitPoint) { DamageAmount damage; DamageSource source; HitPoint hitPoint; } } /// summary /// 敌人死亡事件 /// /summary public class EnemyDiedEvent : IEvent { public readonly EnemyType Type; public readonly Vector3 DeathPosition; public readonly int PointsAwarded; public EnemyDiedEvent(EnemyType type, Vector3 position, int points) { Type type; DeathPosition position; PointsAwarded points; } } /// summary /// 游戏状态改变事件如开始、暂停、结束 /// /summary public class GameStateChangedEvent : IEvent { public readonly GameState PreviousState; public readonly GameState NewState; public GameStateChangedEvent(GameState previous, GameState newState) { PreviousState previous; NewState newState; } } // 可以定义更多事件如 ItemCollectedEvent, LevelCompletedEvent 等 }3.2 实现事件总线EventBus这是最核心的部分。我们将实现一个线程安全考虑到Unity主线程特性这里简化处理的通用事件总线。// EventBus.cs using System; using System.Collections.Generic; using UnityEngine; namespace MyGame.EventSystem { /// summary /// 全局事件总线采用单例模式提供访问。 /// 负责事件的订阅、退订和发布。 /// /summary public class EventBus { // 单例实例 private static EventBus _instance; public static EventBus Instance _instance ?? (_instance new EventBus()); // 核心数据结构存储事件类型与对应的处理器列表的映射。 // 外层字典的Key是事件类型(Type)Value是一个“处理器列表”。 // 由于每种事件的处理器类型ActionT不同这里用object存储列表在存取时进行转换。 private readonly DictionaryType, object _eventHandlers; private EventBus() { _eventHandlers new DictionaryType, object(); } /// summary /// 订阅指定类型的事件。 /// /summary /// typeparam nameTEvent事件类型必须实现IEvent/typeparam /// param namehandler事件处理器/param public void SubscribeTEvent(ActionTEvent handler) where TEvent : IEvent { var eventType typeof(TEvent); if (!_eventHandlers.ContainsKey(eventType)) { // 如果第一次订阅此类型事件创建一个新的处理器列表 _eventHandlers[eventType] new ListActionTEvent(); } var handlers _eventHandlers[eventType] as ListActionTEvent; if (handlers ! null !handlers.Contains(handler)) { handlers.Add(handler); } } /// summary /// 取消订阅指定类型的事件。 /// /summary public void UnsubscribeTEvent(ActionTEvent handler) where TEvent : IEvent { var eventType typeof(TEvent); if (_eventHandlers.ContainsKey(eventType)) { var handlers _eventHandlers[eventType] as ListActionTEvent; handlers?.Remove(handler); // 可选如果该事件类型已经没有订阅者移除这个条目以节省内存 if (handlers ! null handlers.Count 0) { _eventHandlers.Remove(eventType); } } } /// summary /// 发布一个事件。所有订阅了该类型事件的处理器都将被调用。 /// /summary /// param nameevent事件实例/param public void PublishTEvent(TEvent event) where TEvent : IEvent { var eventType typeof(TEvent); if (_eventHandlers.ContainsKey(eventType)) { var handlers _eventHandlers[eventType] as ListActionTEvent; if (handlers null) return; // 注意这里遍历的是副本防止在处理器内部进行订阅/退订操作导致集合被修改异常。 foreach (var handler in handlers.ToArray()) { try { handler?.Invoke(event); } catch (Exception e) { // 非常重要一个处理器的异常不应影响其他处理器的执行。 Debug.LogError($Error invoking handler for event {eventType}: {e}); } } } } /// summary /// 清空所有订阅。在场景切换或游戏重置时调用防止旧订阅残留导致错误。 /// /summary public void ClearAllSubscriptions() { _eventHandlers.Clear(); } } }关键点解析线程安全在Unity中游戏逻辑通常运行在主线程所以这个简单实现是可行的。如果你使用多线程进行事件发布则需要引入锁lock机制。遍历副本Publish方法中我们使用handlers.ToArray()进行遍历。这是因为事件处理器handler在执行时有可能会调用Subscribe或Unsubscribe从而修改正在遍历的集合导致InvalidOperationException。遍历副本可以避免这个问题。异常处理每个handler的调用被包裹在try-catch中。这是至关重要的防御性编程。假设有10个处理器订阅了“玩家死亡”事件第3个处理器抛出了异常如果没有异常处理后面的7个处理器将不会被执行可能导致游戏状态错误比如死亡音效没播放、UI没更新。捕获并记录错误让其他逻辑得以继续。内存管理Unsubscribe中有一个可选逻辑当某个事件类型的处理器列表为空时将其从字典中移除。这对于事件类型繁多的项目可以节省一点内存但不是必须的。3.3 在MonoBehaviour中的使用范例现在我们看看在具体的游戏脚本中如何发布和订阅事件。发布者示例EnemyController// EnemyController.cs using MyGame.EventSystem; using UnityEngine; public class EnemyController : MonoBehaviour { public int scoreValue 100; public EnemyType enemyType; private void OnDestroy() { // 当敌人被销毁死亡时发布一个事件 // 注意要判断是否是应用退出时的销毁避免在游戏退出时发布事件。 if (!gameObject.scene.isLoaded) return; var deathEvent new EnemyDiedEvent(enemyType, transform.position, scoreValue); EventBus.Instance.Publish(deathEvent); Debug.Log($Enemy {name} died, event published.); } }订阅者示例多个系统UIManager订阅EnemyDiedEvent更新分数// UIManager.cs using MyGame.EventSystem; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour { public Text scoreText; private int _totalScore 0; private void OnEnable() { // 在脚本启用时订阅事件 EventBus.Instance.SubscribeEnemyDiedEvent(OnEnemyDied); } private void OnDisable() { // 在脚本禁用时取消订阅这是防止内存泄漏和空引用的关键 EventBus.Instance.UnsubscribeEnemyDiedEvent(OnEnemyDied); } private void OnEnemyDied(EnemyDiedEvent evt) { _totalScore evt.PointsAwarded; scoreText.text $Score: {_totalScore}; Debug.Log($UI updated. New score: {_totalScore}); } }AudioManager订阅EnemyDiedEvent播放音效// AudioManager.cs using MyGame.EventSystem; public class AudioManager : MonoBehaviour { public AudioClip enemyDeathSound; private void OnEnable() { EventBus.Instance.SubscribeEnemyDiedEvent(PlayDeathSound); } private void OnDisable() { EventBus.Instance.UnsubscribeEnemyDiedEvent(PlayDeathSound); } private void PlayDeathSound(EnemyDiedEvent evt) { // 根据敌人类型播放不同的音效这里简化了 AudioSource.PlayClipAtPoint(enemyDeathSound, evt.DeathPosition); } }AchievementSystem订阅PlayerDamageEvent和EnemyDiedEvent解锁成就// AchievementSystem.cs using MyGame.EventSystem; public class AchievementSystem : MonoBehaviour { private int _damageTaken 0; private int _enemiesKilled 0; private void OnEnable() { EventBus.Instance.SubscribePlayerDamageEvent(OnPlayerDamaged); EventBus.Instance.SubscribeEnemyDiedEvent(OnEnemyKilled); } private void OnDisable() { EventBus.Instance.UnsubscribePlayerDamageEvent(OnPlayerDamaged); EventBus.Instance.UnsubscribeEnemyDiedEvent(OnEnemyKilled); } private void OnPlayerDamaged(PlayerDamageEvent evt) { _damageTaken evt.DamageAmount; if (_damageTaken 1000 !IsUnlocked(DamageSponge)) { UnlockAchievement(DamageSponge); } } private void OnEnemyKilled(EnemyDiedEvent evt) { _enemiesKilled; if (_enemiesKilled 50 !IsUnlocked(Hunter)) { UnlockAchievement(Hunter); } } private bool IsUnlocked(string id) { /* ... */ } private void UnlockAchievement(string id) { /* ... */ } }4. 高级技巧、性能优化与避坑指南一个基础的事件系统已经搭建完成但要将其投入到真正的生产项目中还需要考虑更多细节。以下是我在实际项目中总结的经验和教训。4.1 内存管理与防止泄漏这是使用事件系统最容易出问题的地方。订阅者必须在其生命周期结束时取消订阅。黄金法则在MonoBehaviour的OnEnable中订阅在OnDisable中取消订阅。这能完美匹配Unity对象的激活/失活周期。对于非MonoBehaviour对象需要在合适的析构或清理方法中取消订阅。静态事件处理器如果你的处理器是静态方法或Lambda表达式且订阅者对象本身可能被销毁你需要持有订阅的引用以便后续退订或者确保事件总线不会阻止订阅者被垃圾回收我们的实现中使用Action委托如果它捕获了实例方法会持有该实例的引用可能导致泄漏。场景切换清理在加载新场景时旧场景中的对象被销毁但全局的EventBus单例依然存在。如果旧场景的对象没有正确取消订阅事件总线中会残留无效的委托引用指向已销毁对象的方法下次发布事件时调用它们会导致MissingReferenceException。解决方案在场景加载前如在SceneManager.sceneUnloaded事件中调用EventBus.Instance.ClearAllSubscriptions()。但要注意这会清空所有订阅包括那些跨场景持久存在的系统如音频管理器的订阅。更精细的做法是引入“频道”或“作用域”概念。4.2 性能考量装箱与拆箱我们的实现使用了DictionaryType, object存储处理器列表时存在装箱ListActionT转为object读取时存在拆箱。对于高频事件如每帧发布的UpdateEvent这可能成为性能瓶颈。优化方案是使用更高效的数据结构比如Unity.Collections中的NativeHashMap配合Burst编译但这属于高级优化范畴大多数游戏项目的基础事件系统不会受此影响。委托调用开销调用委托比直接调用方法略慢但对于事件系统来说这是必要的开销通常可以接受。避免在每帧发布大量事件。事件对象创建每次Publish都需要new一个事件对象。对于极高频事件这会产生GC垃圾回收压力。可以考虑使用对象池Object Pool来复用事件对象。例如创建一个EventPoolT在发布事件时从池中获取对象并设置数据在处理器执行完毕后回收到池中。这需要非常小心地管理事件对象的状态确保数据不会被错误地复用。4.3 调试与可视化当事件流复杂时调试变得困难。可以增强事件总线加入调试功能。// 在EventBus类中添加 public bool EnableLogging false; // 在Publish方法开始处添加 if (EnableLogging) { Debug.Log($[EventBus] Publishing {event.GetType().Name}: {JsonUtility.ToJson(event)}); } // 在Subscribe和Unsubscribe方法中添加类似的日志更高级的做法是创建一个编辑器窗口实时显示所有事件的发布和订阅关系这对于调试复杂的状态交互非常有帮助。4.4 应对复杂场景事件排序与异步事件处理顺序默认情况下事件处理器的调用顺序就是它们订阅的顺序List.Add的顺序。如果某些处理器有严格的先后依赖比如必须先更新数据再保存这种隐式依赖是脆弱的。更好的做法是避免这种依赖或者通过事件本身来传递“阶段”信息。例如发布一个GameSaveBeginEvent所有系统保存数据然后再发布一个GameSaveEndEvent。异步事件处理如果某个事件处理器需要执行耗时的操作如加载资源不应该阻塞事件发布线程。可以让处理器启动一个Coroutine或Task但需要小心管理生命周期和异常。4.5 常见问题排查表问题现象可能原因解决方案发布事件后没有任何反应1. 订阅者的脚本未启用OnEnable未执行。2. 订阅/发布的事件类型不匹配拼写错误或命名空间错误。3. 订阅者在事件发布后才启用。1. 检查订阅者GameObject和脚本的激活状态。2. 使用调试日志确认Subscribe和Publish被正确调用。3. 确保订阅逻辑在发布逻辑之前执行可通过脚本执行顺序或初始化阶段控制。调用事件处理器时抛出NullReferenceException或MissingReferenceException1. 订阅者对象已被销毁如场景中的GameObject被Destroy但未取消订阅。2. 事件处理器方法访问了已销毁的组件或对象。1.严格遵守在OnDisable中取消订阅。2. 在事件处理器方法开头检查this对于实例方法或关键组件是否为null。3. 使用EventBus的异常捕获至少不会影响其他处理器。同一事件被处理了多次同一订阅者多次订阅了同一事件如在OnEnable中重复调用Subscribe。在Subscribe方法内加入Contains检查我们的实现已包含。确保订阅逻辑只执行一次。游戏退出时报错在OnDestroy中发布事件而事件总线或某些订阅者可能已被提前销毁。在发布事件前检查gameObject.scene.isLoaded或应用是否正在退出Application.isPlaying。感觉事件系统有延迟或卡顿1. 某个事件处理器执行了非常耗时的操作。2. 单帧内发布了海量事件。1. 优化处理器逻辑或将耗时操作移到帧末或异步执行。2. 考虑对高频事件进行节流Throttling或合并如将多次位置更新合并为一次。5. 实战扩展构建更强大的事件系统基础事件总线已经能满足80%的需求。但对于大型项目我们可能需要更多功能5.1 带优先级的事件处理有时你需要确保某些处理器先于其他处理器执行比如输入处理要先于逻辑更新。可以修改订阅方法允许传入一个优先级参数。public void SubscribeTEvent(ActionTEvent handler, int priority 0) where TEvent : IEvent { // 存储时将处理器和优先级一起存储 // 发布时根据优先级排序后再调用 } // 数据结构可以改为 DictionaryType, List(ActionTEvent handler, int priority)5.2 事件拦截与取消有些事件可能允许被“取消”。例如一个PlayerTryOpenDoorEvent可以被一个拥有“门禁卡”检查的处理器拦截如果检查失败则取消这个事件后续的开门处理器就不会执行。这需要事件类包含一个IsCancelled属性并且事件总线在发布时一旦检测到事件被取消就停止调用后续处理器。5.3 与Unity引擎事件的集成我们的自定义事件系统可以和Unity的生命周期事件很好地结合。你可以创建一个MonoBehaviour基类或辅助类自动将Unity事件如OnTriggerEnter转换为自定义事件发布出去进一步解耦游戏逻辑与引擎回调。public class CollisionEventPublisher : MonoBehaviour { private void OnTriggerEnter(Collider other) { EventBus.Instance.Publish(new TriggerEnterEvent(this.gameObject, other.gameObject)); } } // 这样任何逻辑都可以通过订阅TriggerEnterEvent来响应碰撞而不需要写在OnTriggerEnter里。5.4 使用ScriptableObject作为事件通道这是一种在Unity社区越来越流行的模式。你可以创建一种GameEvent的ScriptableObject资源。发布者持有对该资源的引用并调用Raise()订阅者通过该资源的RegisterListener和UnregisterListener来关联。它的优点是高度可配置在Inspector中拖拽分配事件资源非常直观。便于复用同一个事件资源可以被多个发布者和订阅者共享。支持编辑器工具可以方便地创建编辑器工具来查看所有事件的引用关系。其内部实现原理本质上也是观察者模式只是将事件总线的功能部分转移到了ScriptableObject资产中。你可以根据项目规模和团队偏好选择是否采用。构建一个健壮的事件系统是迈向专业Unity开发的重要一步。它迫使你思考代码的耦合度并采用更清晰、更模块化的方式组织逻辑。一开始可能会觉得多了一层抽象有些麻烦但当你需要修改或添加功能时你会感谢自己当初所做的设计。记住好的架构不是一次性建成的而是在不断重构和优化中演进。从今天这个简单的事件总线开始根据你的项目需求逐步扩展它它将成为你游戏代码库中最可靠的基础设施之一。