1. 项目概述与核心价值
在游戏开发这条路上摸爬滚打了十几年,我见过太多项目在初期因为架构问题而举步维艰。代码像一团乱麻,牵一发而动全身,一个小小的功能改动都可能引发一连串的Bug。很多开发者,尤其是刚入行的朋友,往往更关注于实现酷炫的视觉效果和复杂的游戏逻辑,却忽略了代码结构本身的可维护性和可扩展性。这就像盖房子,只想着把房间装修得富丽堂皇,却用了不结实的砖头和混乱的管线,住进去之后漏水、裂缝问题不断,修修补补的成本远高于当初打好地基。
今天要聊的,就是游戏开发中两个看似基础,实则威力巨大的“地基型”设计模式:单例模式和观察者模式。它们不是什么高深莫测的黑科技,而是经过无数项目验证、能切实解决特定痛点的成熟方案。单例模式帮你管理那些“独一无二”的全局管理者,比如游戏管理器、音频管理器、资源加载器;而观察者模式则为你构建一套灵活、低耦合的事件通信机制,让游戏中的各个模块能够优雅地“对话”,而不是硬邦邦地互相调用。
掌握并合理运用这两种模式,你的代码将从“面条式”的混乱状态,进化成“乐高积木式”的清晰结构。每个模块职责单一,接口明确,相互之间通过定义好的事件进行通信。这样的代码,不仅你自己半年后还能看懂,新加入团队的同事也能快速上手。更重要的是,它为项目的长期迭代和功能扩展铺平了道路。接下来,我们就深入实战,看看如何在Unity项目中,将这两个模式从理论落地为实践。
2. 核心设计模式深度解析
2.1 单例模式:全局访问点的利与弊
单例模式的核心思想是保证一个类只有一个实例,并提供一个全局访问点。在Unity游戏开发中,这太有用了。想象一下,你的游戏需要一个GameManager来管理游戏状态(开始、暂停、结束),一个AudioManager来统一播放背景音乐和音效,一个UIManager来管理所有界面。如果这些管理器类可以被随意实例化多个,那状态同步、资源管理将会是一场灾难。
实现方式与选择:在C#中,实现单例有多种方式,但在Unity的MonoBehaviour环境下,我们需要特别考虑。最经典的是“饿汉式”和“懒汉式”。
饿汉式单例:在类加载时就创建实例。优点是简单、线程安全,但可能会提前占用资源,即使这个单例暂时用不到。
public class GameManager { private static GameManager _instance = new GameManager(); public static GameManager Instance => _instance; private GameManager() { } // 私有构造函数 }这种方式在纯C#类中可行,但不直接适用于继承自
MonoBehaviour的类,因为Unity控制着组件的生命周期。懒汉式单例(MonoBehaviour版):这是Unity中最常用的方式,只有在第一次访问时才创建实例。我们需要处理多线程安全(虽然Unity主线程是单线程的,但良好的习惯很重要)和重复创建的问题。
public class SingletonMono<T> : MonoBehaviour where T : MonoBehaviour { private static T _instance; private static object _lock = new object(); private static bool _applicationIsQuitting = false; public static T Instance { get { if (_applicationIsQuitting) { Debug.LogWarning($"[Singleton] Instance '{typeof(T)}' already destroyed on application quit. Won't create again."); return null; } lock (_lock) { if (_instance == null) { _instance = (T)FindObjectOfType(typeof(T)); if (FindObjectsOfType(typeof(T)).Length > 1) { Debug.LogError($"[Singleton] Something went wrong - there should never be more than 1 singleton! Reopening the scene might fix it."); return _instance; } if (_instance == null) { GameObject singletonGo = new GameObject(); _instance = singletonGo.AddComponent<T>(); singletonGo.name = $"(Singleton) {typeof(T).ToString()}"; DontDestroyOnLoad(singletonGo); Debug.Log($"[Singleton] An instance of {typeof(T)} was created with DontDestroyOnLoad."); } else { Debug.Log($"[Singleton] Using instance already created: {_instance.gameObject.name}"); } } return _instance; } } } protected virtual void OnDestroy() { _applicationIsQuitting = true; } }这是一个通用的MonoBehaviour单例基类。任何需要单例的Manager类只需继承它:
public class AudioManager : SingletonMono<AudioManager>。它实现了线程安全的双重检查锁定,自动处理场景中已存在实例和需要新建实例的情况,并标记为DontDestroyOnLoad以跨场景存活。OnDestroy中的标志位是为了防止应用退出时可能发生的虚假创建。
单例模式的陷阱与最佳实践:
- 避免滥用:单例是全局状态,滥用会导致“上帝对象”,难以测试和维护。只对那些真正需要全局唯一访问点的核心管理器使用。
- 依赖注入:对于非核心的、可替换的服务,考虑使用依赖注入容器来管理,而非硬编码的单例,这能提高代码的可测试性。
- 注意生命周期:使用
DontDestroyOnLoad要谨慎,确保在场景切换时清理不必要的状态,或者提供明确的重置方法。 - 性能考量:频繁通过
Instance属性访问,其内部的查找和锁机制(尽管优化过)仍有微开销。对于高频调用的方法,可在Awake中缓存引用。
2.2 观察者模式:解耦通信的利器
如果说单例模式解决了“谁在哪儿”的问题,那么观察者模式解决的就是“发生了什么,谁需要知道”的问题。它的核心是定义了一种一对多的依赖关系,当一个对象(主题Subject)的状态发生改变时,所有依赖于它的对象(观察者Observers)都会自动得到通知并更新。
在游戏开发中,这种模式无处不在:玩家血量变化时,UI血条需要更新;敌人死亡时,需要触发得分增加、播放死亡动画、可能掉落物品;任务状态更新时,任务列表需要刷新。如果没有观察者模式,我们可能会写出这样的代码:
// 在PlayerHealth脚本中 public class PlayerHealth : MonoBehaviour { public HealthUI healthUI; public ScoreManager scoreManager; // 假设死亡扣分 public ParticleSystem deathEffect; void TakeDamage(int damage) { currentHealth -= damage; healthUI.UpdateHealth(currentHealth); // 直接调用UI if (currentHealth <= 0) { Die(); } } void Die() { scoreManager.AddScore(-100); // 直接调用ScoreManager deathEffect.Play(); // 直接控制特效 // ... 其他直接调用 } }这种紧耦合的代码扩展性极差。每增加一个对玩家死亡感兴趣的系统(比如成就系统、音效系统),你都得回来修改PlayerHealth这个类。
C#中的实现演进:C#语言本身对观察者模式提供了强大的原生支持,即event(事件)和delegate(委托)。
- 自定义委托与事件:这是最基础的形式,需要自己定义委托类型。
public class PlayerHealth : MonoBehaviour { // 1. 定义委托(约定观察者方法的签名) public delegate void HealthChangedHandler(int currentHealth, int maxHealth); public delegate void PlayerDiedHandler(Vector3 deathPosition); // 2. 定义基于该委托的事件 public event HealthChangedHandler OnHealthChanged; public event PlayerDiedHandler OnPlayerDied; void TakeDamage(int damage) { currentHealth -= damage; // 3. 触发事件,通知所有订阅者 OnHealthChanged?.Invoke(currentHealth, maxHealth); if (currentHealth <= 0) { OnPlayerDied?.Invoke(transform.position); } } } // 在UI脚本中订阅 public class HealthUI : MonoBehaviour { void Start() { PlayerHealth.Instance.OnHealthChanged += UpdateHealthBar; } void OnDestroy() { PlayerHealth.Instance.OnHealthChanged -= UpdateHealthBar; // 务必取消订阅! } void UpdateHealthBar(int current, int max) { /* ... */ } } - 使用Action/Func泛型委托:对于不需要返回值的事件,我们可以使用
System.Action,它简化了委托定义。public event Action<int, int> OnHealthChanged; // 代替自定义委托 public event Action<Vector3> OnPlayerDied; - UnityEvent:Unity引擎提供了
UnityEvent,它可以在Inspector窗口中可视化地配置事件响应,非常适合设计师和非程序员使用。
在Inspector里,你可以直接拖拽任何游戏对象上的公有方法(符合参数类型)到这个事件上,无需编写代码绑定。using UnityEngine.Events; public class PlayerHealth : MonoBehaviour { [System.Serializable] public class HealthEvent : UnityEvent<int, int> { } public HealthEvent onHealthChanged; // 触发:onHealthChanged.Invoke(currentHealth, maxHealth); }
观察者模式的优势与注意事项:
- 优势:彻底解耦了事件发布者和订阅者。发布者不需要知道谁订阅了它,订阅者也不需要知道事件的具体发布逻辑。系统易于扩展,新增订阅者只需订阅相应事件即可。
- 注意事项:
- 内存泄漏:这是最常见的问题。如果观察者对象被销毁了,但没有取消对事件的订阅,那么主题对象仍然持有对已销毁观察者的引用,导致其无法被垃圾回收。务必在
OnDestroy或OnDisable中取消订阅。 - 事件命名:事件名应清晰表明“发生了什么”,通常以
On开头,如OnHealthChanged,OnEnemyDied。 - 性能:事件调用(
Invoke)有开销,对于每帧触发成千上万次的事件(如Update中),需谨慎使用,或考虑其他优化方案如数据驱动。
- 内存泄漏:这是最常见的问题。如果观察者对象被销毁了,但没有取消对事件的订阅,那么主题对象仍然持有对已销毁观察者的引用,导致其无法被垃圾回收。务必在
3. 实战:构建一个基于事件驱动的游戏管理器
理论说再多,不如动手搭一个。我们来构建一个核心的GameManager,它使用单例模式确保全局唯一,并作为游戏内主要事件的枢纽,使用观察者模式来协调各个系统。
3.1 架构设计与核心类定义
我们的目标是创建一个中心化的游戏状态和事件管理器。它负责:
- 管理游戏全局状态(如游戏是否进行中、是否暂停)。
- 定义游戏内所有重要的事件(玩家事件、系统事件等)。
- 提供触发和订阅这些事件的接口。
首先,我们定义一个静态的事件中心类GameEvent,它不继承MonoBehaviour,仅用于定义所有事件的Action。这样做的好处是将事件定义与具体的单例管理器分离,更清晰。
// GameEvent.cs - 静态事件定义中心 public static class GameEvent { // 玩家事件 public static Action<int, int> OnPlayerHealthChanged; // 当前血量,最大血量 public static Action<Vector3> OnPlayerDied; public static Action<int> OnPlayerScoreChanged; // 新的总分 // 游戏状态事件 public static Action OnGameStart; public static Action OnGamePaused; public static Action OnGameResumed; public static Action OnGameOver; // 系统工具方法:提供一个安全触发事件的封装,避免空引用 public static void TriggerEvent(Action action) { action?.Invoke(); } public static void TriggerEvent<T>(Action<T> action, T arg) { action?.Invoke(arg); } // 可以继续为两个、三个参数重载... }接下来,实现我们的核心GameManager单例。
// GameManager.cs public class GameManager : SingletonMono<GameManager> { public enum GameState { Menu, Playing, Paused, GameOver } private GameState _currentState = GameState.Menu; public GameState CurrentState => _currentState; private int _playerScore = 0; protected override void Awake() { base.Awake(); // 调用基类SingletonMono的Awake确保单例初始化 // 初始化逻辑,例如加载玩家存档、初始化系统 Debug.Log("GameManager Initialized."); } void Start() { // 游戏启动,触发开始事件 StartGame(); } public void StartGame() { if (_currentState != GameState.Menu) return; _currentState = GameState.Playing; _playerScore = 0; GameEvent.TriggerEvent(GameEvent.OnGameStart); Debug.Log("Game Started!"); } public void PauseGame() { if (_currentState != GameState.Playing) return; _currentState = GameState.Paused; Time.timeScale = 0f; // 暂停游戏时间 GameEvent.TriggerEvent(GameEvent.OnGamePaused); } public void ResumeGame() { if (_currentState != GameState.Paused) return; _currentState = GameState.Playing; Time.timeScale = 1f; GameEvent.TriggerEvent(GameEvent.OnGameResumed); } public void GameOver(bool isWin) { if (_currentState != GameState.Playing) return; _currentState = GameState.GameOver; GameEvent.TriggerEvent(GameEvent.OnGameOver); Debug.Log($"Game Over! Win: {isWin}"); // 可以在这里保存分数、弹出结算界面等 } // 提供给其他系统修改分数并触发事件的方法 public void AddScore(int points) { _playerScore += points; GameEvent.TriggerEvent(GameEvent.OnPlayerScoreChanged, _playerScore); } public int GetScore() => _playerScore; }3.2 具体系统实现与事件订阅
现在,我们创建几个具体的系统来演示如何订阅和使用这些事件。
1. UIManager (UI管理器)
// UIManager.cs public class UIManager : SingletonMono<UIManager> { [SerializeField] private Slider healthSlider; [SerializeField] private Text scoreText; [SerializeField] private GameObject gameOverPanel; [SerializeField] private GameObject pauseMenuPanel; void Start() { // 订阅事件 GameEvent.OnPlayerHealthChanged += UpdateHealthUI; GameEvent.OnPlayerScoreChanged += UpdateScoreUI; GameEvent.OnGameOver += ShowGameOverUI; GameEvent.OnGamePaused += ShowPauseMenu; GameEvent.OnGameResumed += HidePauseMenu; // 初始化UI状态 gameOverPanel.SetActive(false); pauseMenuPanel.SetActive(false); UpdateScoreUI(0); // 初始化分数显示 } void OnDestroy() { // 非常重要!取消订阅,防止内存泄漏 GameEvent.OnPlayerHealthChanged -= UpdateHealthUI; GameEvent.OnPlayerScoreChanged -= UpdateScoreUI; GameEvent.OnGameOver -= ShowGameOverUI; GameEvent.OnGamePaused -= ShowPauseMenu; GameEvent.OnGameResumed -= HidePauseMenu; } private void UpdateHealthUI(int currentHealth, int maxHealth) { if (healthSlider != null) { healthSlider.maxValue = maxHealth; healthSlider.value = currentHealth; } } private void UpdateScoreUI(int newScore) { if (scoreText != null) scoreText.text = $"Score: {newScore}"; } private void ShowGameOverUI() { if (gameOverPanel != null) gameOverPanel.SetActive(true); } private void ShowPauseMenu() { if (pauseMenuPanel != null) pauseMenuPanel.SetActive(true); } private void HidePauseMenu() { if (pauseMenuPanel != null) pauseMenuPanel.SetActive(false); } // 供UI按钮调用的方法 public void Button_ResumeGame() => GameManager.Instance.ResumeGame(); public void Button_RestartGame() { /* 重新加载场景的逻辑 */ } }2. AudioManager (音频管理器)
// AudioManager.cs public class AudioManager : SingletonMono<AudioManager> { [SerializeField] private AudioClip bgmNormal; [SerializeField] private AudioClip bgmPaused; [SerializeField] private AudioClip playerHurtSound; [SerializeField] private AudioClip playerDeathSound; [SerializeField] private AudioClip scoreUpSound; private AudioSource _bgmSource; private AudioSource _sfxSource; protected override void Awake() { base.Awake(); _bgmSource = gameObject.AddComponent<AudioSource>(); _sfxSource = gameObject.AddComponent<AudioSource>(); _bgmSource.loop = true; PlayBGM(bgmNormal); } void Start() { GameEvent.OnPlayerHealthChanged += OnPlayerHealthChanged; GameEvent.OnPlayerDied += OnPlayerDied; GameEvent.OnPlayerScoreChanged += OnPlayerScoreChanged; GameEvent.OnGamePaused += OnGamePaused; GameEvent.OnGameResumed += OnGameResumed; } void OnDestroy() { GameEvent.OnPlayerHealthChanged -= OnPlayerHealthChanged; GameEvent.OnPlayerDied -= OnPlayerDied; GameEvent.OnPlayerScoreChanged -= OnPlayerScoreChanged; GameEvent.OnGamePaused -= OnGamePaused; GameEvent.OnGameResumed -= OnGameResumed; } private void OnPlayerHealthChanged(int current, int max) { // 假设血量减少时播放受伤音效(这里简单判断,实际可能需传递变化量) // 更佳实践是定义一个单独的OnPlayerTakeDamage事件 PlaySFX(playerHurtSound); } private void OnPlayerDied(Vector3 pos) => PlaySFX(playerDeathSound); private void OnPlayerScoreChanged(int newScore) => PlaySFX(scoreUpSound); private void OnGamePaused() { _bgmSource.Pause(); // 或者切换为暂停BGM // PlayBGM(bgmPaused); } private void OnGameResumed() => _bgmSource.UnPause(); // 或切回正常BGM private void PlayBGM(AudioClip clip) { /* 播放背景音乐逻辑 */ } private void PlaySFX(AudioClip clip) { /* 播放音效逻辑 */ } }3. PlayerHealth (玩家生命组件)
// PlayerHealth.cs - 挂载在玩家角色上 public class PlayerHealth : MonoBehaviour { [SerializeField] private int maxHealth = 100; private int _currentHealth; void Start() { _currentHealth = maxHealth; // 初始化时通知UI更新满血状态 GameEvent.TriggerEvent(GameEvent.OnPlayerHealthChanged, _currentHealth, maxHealth); } public void TakeDamage(int damage) { if (GameManager.Instance.CurrentState != GameManager.GameState.Playing) return; _currentHealth = Mathf.Clamp(_currentHealth - damage, 0, maxHealth); GameEvent.TriggerEvent(GameEvent.OnPlayerHealthChanged, _currentHealth, maxHealth); if (_currentHealth <= 0) { Die(); } } private void Die() { GameEvent.TriggerEvent(GameEvent.OnPlayerDied, transform.position); GameManager.Instance.GameOver(false); // 玩家死亡,游戏失败 // 禁用玩家控制、播放死亡动画等... gameObject.SetActive(false); } // 示例:碰撞检测触发伤害 void OnCollisionEnter(Collision collision) { if (collision.gameObject.CompareTag("Enemy")) { TakeDamage(10); } } }3.3 场景搭建与测试
- 创建空对象:在Unity场景中,创建三个空GameObject,分别命名为“_Managers”、“_UI”、“_Player”。
- 挂载脚本:
- 将
GameManager、AudioManager脚本挂载到“_Managers”对象上(或分别创建单独对象)。由于它们继承自SingletonMono,挂载一个即可。 - 将
UIManager脚本挂载到“_UI”对象上。 - 将
PlayerHealth脚本挂载到你的玩家角色(例如一个Cube)上,并将该角色放入“_Player”下。
- 将
- 配置UI:
- 在Canvas下创建Slider(作为血条)和Text(作为分数显示),以及两个Panel(GameOver和PauseMenu)。
- 将这些UI元素的引用拖拽到
UIManager脚本的对应序列化字段中。
- 配置音频:
- 将准备好的音频文件拖拽到
AudioManager脚本的对应字段。
- 将准备好的音频文件拖拽到
- 创建敌人:创建一个简单的Sphere作为敌人,Tag设置为“Enemy”,并添加Rigidbody。
- 运行测试:
- 运行游戏,玩家血条和分数应初始化。
- 控制玩家碰撞敌人,血条应减少,并播放受伤音效。
- 当血量为零时,触发死亡,播放死亡音效,显示GameOver界面。
- 在游戏中按ESC键(或其他键),调用
GameManager.Instance.PauseGame(),游戏应暂停,显示暂停菜单,背景音乐暂停。调用ResumeGame后恢复。
通过这个实战案例,你可以清晰地看到整个架构是如何运作的:PlayerHealth只负责计算血量和触发“血量变化”、“死亡”事件,它完全不知道UI和音频的存在。UIManager和AudioManager只负责监听自己关心的事件并做出反应。GameManager作为中枢,协调着游戏状态和核心事件流。各个模块之间通过GameEvent这个静态事件中心进行通信,高度解耦,职责清晰。
4. 进阶技巧、常见问题与优化方案
4.1 单例模式的进阶考量
场景持久性与重置:使用DontDestroyOnLoad的单例在场景切换时不会销毁。这有时会导致问题,比如从主菜单进入游戏场景,GameManager可能还保留着菜单场景的状态。解决方案是提供一个显式的重置方法,在加载新场景时(例如在SceneManager.sceneLoaded事件中)调用。
public class GameManager : SingletonMono<GameManager> { // ... 其他代码 ... public void ResetForNewScene() { _playerScore = 0; _currentState = GameState.Menu; // 清除所有事件的订阅者?不!这很危险,应由订阅者自行管理。 // 更好的做法是触发一个“场景重置”事件,让各个系统清理自己的状态。 GameEvent.TriggerEvent(OnSceneReset); } }泛型单例的线程安全与性能:前面提供的SingletonMono<T>使用了lock来保证线程安全。在Unity主线程环境下,这通常是安全的,但lock有性能开销。对于绝对确定只在主线程访问的单例,可以简化,使用[RuntimeInitializeOnLoadMethod]或更简单的if (_instance == null) _instance = this;配合Awake检查。但为了代码的健壮性和可复用性,保留线程安全的版本是更稳妥的选择。
单例与接口:为了提升可测试性,可以为你的管理器定义接口。例如,定义一个IAudioService接口,AudioManager实现它。其他代码通过接口(如IAudioService.Instance.PlaySFX())而非具体类来访问服务。这样,在单元测试时,你可以轻松地用Mock对象替换掉真实的AudioManager。
4.2 观察者模式的陷阱与最佳实践
内存泄漏(再次强调):这是观察者模式的头号杀手。务必在MonoBehaviour的OnDestroy或OnDisable中取消对所有事件的订阅。一个有用的技巧是使用??=运算符和辅助方法在Awake中确保订阅只发生一次,并在OnDestroy中统一清理。
private bool _hasSubscribed = false; void Awake() { if (!_hasSubscribed) { GameEvent.OnGameStart += HandleGameStart; _hasSubscribed = true; } } void OnDestroy() { if (_hasSubscribed) { GameEvent.OnGameStart -= HandleGameStart; } }事件泛滥与性能:避免在Update中每帧触发非必要的事件。对于高频状态同步(如玩家位置),可以考虑使用数据总线(一个公共的可观察对象)或直接引用,而不是事件。对于UI更新,可以使用“脏标志”模式,只在数据真正改变时触发事件。
事件参数设计:设计事件参数时,遵循“最少知识原则”。不要传递整个庞大的对象,而是传递必要的数据。例如,OnEnemyDied事件传递敌人的ID、死亡位置和死亡原因枚举,而不是传递整个Enemy组件引用。这减少了模块间的耦合。
使用C#的EventHandler标准模式:对于更正式的事件,可以使用EventHandler<TEventArgs>标准模式,这有利于与其他.NET库集成。
public class PlayerHealthChangedEventArgs : EventArgs { public int CurrentHealth { get; } public int MaxHealth { get; } public PlayerHealthChangedEventArgs(int current, int max) { CurrentHealth = current; MaxHealth = max; } } public event EventHandler<PlayerHealthChangedEventArgs> PlayerHealthChanged; // 触发:PlayerHealthChanged?.Invoke(this, new PlayerHealthChangedEventArgs(current, max));4.3 架构扩展:引入事件总线(Event Bus)
当项目规模变大,事件类型繁多时,静态的GameEvent类可能会变得臃肿。此时可以引入一个更高级的“事件总线”模式。事件总线是一个集中管理所有事件发布和订阅的中介者。它通常提供一个泛型接口来注册、注销和触发事件。
// 简单的事件总线接口 public interface IEventBus { void Publish<TEvent>(TEvent @event) where TEvent : class; void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class; void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : class; } // 一个简单的实现 public class EventBus : IEventBus { private readonly Dictionary<Type, List<Delegate>> _handlers = new Dictionary<Type, List<Delegate>>(); public void Publish<TEvent>(TEvent @event) where TEvent : class { Type eventType = typeof(TEvent); if (_handlers.ContainsKey(eventType)) { // 注意:遍历时可能发生集合修改,需要复制列表或使用线程安全集合 var handlers = _handlers[eventType].ToList(); foreach (var handler in handlers) { ((Action<TEvent>)handler)?.Invoke(@event); } } } public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class { Type eventType = typeof(TEvent); if (!_handlers.ContainsKey(eventType)) { _handlers[eventType] = new List<Delegate>(); } _handlers[eventType].Add(handler); } public void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : class { Type eventType = typeof(TEvent); if (_handlers.ContainsKey(eventType)) { _handlers[eventType].Remove(handler); } } } // 使用 public struct PlayerDiedEvent { public Vector3 Position; } EventBus.Instance.Subscribe<PlayerDiedEvent>(e => Debug.Log($"Player died at {e.Position}")); EventBus.Instance.Publish(new PlayerDiedEvent { Position = transform.position });事件总线进一步解耦了事件的发布者和订阅者,双方甚至不需要知道一个具体的静态事件类,只需要知道事件类型。更强大的事件总线实现还会支持异步、线程调度、事件继承等功能。对于中小型Unity项目,静态事件类通常足够;对于大型复杂项目,事件总线是更优雅的选择。
4.4 与其他Unity特性的结合
与UnityEvent在Inspector中的结合:你可以将观察者模式与UnityEvent结合,为设计师提供灵活性。例如,在GameManager中暴露一个UnityEvent OnGameStartUnityEvent,同时在代码内部触发它和静态的GameEvent.OnGameStart。这样,程序员可以通过代码订阅,设计师也可以在Inspector里拖拽配置。
public UnityEvent onGameStartUnityEvent; public void StartGame() { // ... onGameStartUnityEvent?.Invoke(); GameEvent.TriggerEvent(GameEvent.OnGameStart); }与ScriptableObject结合:ScriptableObject是Unity中用于存储数据的强大工具。你可以创建GameEventSO这样的ScriptableObject资产,其中包含一个UnityEvent。多个管理器或游戏对象都可以引用同一个GameEventSO资产来触发或监听事件。这种方式将事件配置数据化,非常适合需要跨场景、由策划配置的事件流。
5. 总结与个人心得
走完这一趟从理论到实战的旅程,你应该能深刻体会到,单例和观察者模式绝非死板的教条,而是活生生的、能解决实际工程问题的工具。它们一个帮你管理全局的、唯一的服务入口,一个帮你搭建灵活、低耦合的模块间通信桥梁。两者结合,构成了许多Unity项目核心架构的骨架。
在我经历过的项目中,早期忽视架构带来的技术债后期偿还起来异常痛苦。而合理运用这些模式,虽然可能在开发初期需要多写一些“样板代码”,比如定义事件、创建管理器,但从第一个功能扩展开始,其收益就显现出来了。新增一个成就系统?只需要创建一个AchievementManager单例,并订阅OnEnemyDied、OnPlayerScoreChanged等事件即可,完全不用修改现有的PlayerHealth、Enemy或UIManager代码。这种可扩展性对于需要持续更新、添加内容的游戏项目来说,是至关重要的。
最后分享一个我自己的小习惯:我会为项目创建一个“Core”或“Framework”文件夹,把SingletonMono<T>、GameEvent(或EventBus)、以及一些其他通用的基础组件(如对象池、状态机基类)放在里面。这形成了一个轻量级的内部框架,新的项目可以直接复用,极大地提升了开发起点和代码质量的一致性。记住,好的架构不是一次性设计出来的,而是在不断应对变化和重构中演化出来的。从今天开始,有意识地在你的代码中运用这些模式,你一定会感受到它们带来的秩序之美。