0x0f.观察者模式的另一个示例
观察者模式(Observer Pattern)就是专门用来解决“对象之间怎么高效通信”的绝对王者。
在游戏开发中,它还有另外几个大家更熟悉的名字:事件驱动(Event-Driven)、发布-订阅模式(Pub/Sub)。它是实现模块之间“解耦(Decoupling)”的最强武器。
💡 为什么要用观察者模式?
假设你正在开发一款动作游戏,当玩家角色死亡时,游戏里需要发生一连串的事情:
UIManager:弹出“胜败乃兵家常事”的死亡界面。AudioManager:停止激烈的战斗 BGM,播放悲伤的死亡音效。AchievementManager:记录死亡次数,看看是否能解锁“受苦之魂”成就。EnemyManager:通知所有敌人停止攻击并原地嘲讽。
新手的灾难写法(极度耦合):
public class PlayerHealth : MonoBehaviour
{
// 玩家必须把所有相关的系统都认识一遍!
public UIManager uiManager;
public AudioManager audioManager;
public AchievementManager achievementManager;
public EnemyManager enemyManager;
public void Die()
{
uiManager.ShowGameOver();
audioManager.PlaySadMusic();
achievementManager.AddDeathCount();
enemyManager.StopAllEnemies();
}
}
这太糟糕了! 玩家的血量脚本凭什么要懂 UI 和音效怎么运作?如果有一天策划要求删掉成就系统,你还得跑回 PlayerHealth 里面去删代码,稍微删错一点就会引发空指针报错。
观察者模式的优雅解法:
让 PlayerHealth 变成一个只管大喊大叫的“广播电台(发布者)”。它死亡时,只需要向全服广播一句:“我死了!”。
至于谁想听(订阅者/观察者)?玩家根本不在乎。UI 和音效系统只需要默默收听这个频道,听到“玩家死亡”的广播后,各自执行自己的逻辑即可。
一、 C# 现代标准写法:使用 Action 事件
在传统的 Java 或 C++ 中,观察者模式需要你自己写 List<IObserver> 来管理订阅者。但在 C# 中,微软把观察者模式直接做进了语言底层,也就是委托(Delegate)和事件(Event)。
我们现在最常用的是 System.Action。
1. 定义发布者(被观察的对象:玩家)
using System;
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
public int health = 100;
// 1. 核心:定义一个事件(广播频道)
// static 意味着这是一个全局频道,任何人都可以方便地收听
public static event Action OnPlayerDeath;
public void TakeDamage(int damage)
{
health -= damage;
if (health <= 0)
{
Die();
}
}
private void Die()
{
Debug.Log("玩家:啊,我死了!(开始广播)");
// 2. 触发事件:向所有订阅者广播!
// ?. 的作用是:如果没有人订阅(频道空无一人),就不发送,防止报错
OnPlayerDeath?.Invoke();
Destroy(gameObject);
}
}
看!玩家的代码变得极其干净,它不再包含任何 UI 或音效的引用。
2. 定义订阅者(观察者:UI 和 音效)
using UnityEngine;
public class UIManager : MonoBehaviour
{
// 在对象启用时,订阅(收听)事件
private void OnEnable()
{
PlayerHealth.OnPlayerDeath += ShowGameOverScreen;
}
// 在对象禁用时,必须取消订阅(退订)!
private void OnDisable()
{
PlayerHealth.OnPlayerDeath -= ShowGameOverScreen;
}
// 听到广播后实际要执行的动作
private void ShowGameOverScreen()
{
Debug.Log("UI系统:收到死亡广播,正在弹出 Game Over 面板...");
}
}
public class AudioManager : MonoBehaviour
{
private void OnEnable()
{
PlayerHealth.OnPlayerDeath += PlaySadMusic;
}
private void OnDisable()
{
PlayerHealth.OnPlayerDeath -= PlaySadMusic;
}
private void PlaySadMusic()
{
Debug.Log("音效系统:收到死亡广播,正在播放悲伤的二胡 BGM...");
}
}
二、 🌟 进阶:带参数的广播电台
很多时候,光喊一句“有事发生了”不够,我们还需要传递数据。比如玩家受击时,UI 需要知道扣了多少血才能更新血条。
这时可以使用 Action<T> 来传递参数。
发布者(广播带有数值):
// 广播频道:谁受伤了?扣了多少血?当前还剩多少血?
public static event Action<string, int, int> OnPlayerTakeDamage;
public void TakeDamage(int damage)
{
health -= damage;
// 广播:发送具体的数据给观察者
OnPlayerTakeDamage?.Invoke("Player", damage, health);
}
订阅者(接收带数值的广播):
private void OnEnable()
{
// 订阅的函数的参数列表,必须和 Action<string, int, int> 完全一致
PlayerHealth.OnPlayerTakeDamage += UpdateHealthBar;
}
private void UpdateHealthBar(string targetName, int damageTaken, int currentHp)
{
Debug.Log($"UI系统更新:{targetName} 扣除了 {damageTaken} 滴血,剩余血量 {currentHp}");
}
🚨 避坑指南:观察者模式的“致命诅咒” (内存泄漏)
这是全宇宙 Unity 新手在用观察者模式时最容易踩的终极天坑,请务必死死记住:
只要你 += 了,就必须在离开时 -=!
- 为什么? 如果你在
UIManager里写了PlayerHealth.OnPlayerDeath += ShowGameOver,这意味着PlayerHealth的事件列表里死死地抓住了UIManager的引用。
如果此时UIManager被销毁了(比如切换了场景),但你忘记了-=。当玩家再次死亡触发事件时,事件系统会试图去寻找那个已经被销毁的UIManager,这会直接导致 NullReferenceException(空指针异常),甚至造成严重的内存泄漏(GC 无法回收 UI 占用的内存,因为它还被事件抓着)。 - 最佳实践: 永远成对使用 Unity 的生命周期函数:
- 在
OnEnable中+=订阅。 - 在
OnDisable中-=退订。
(或者在Start中订阅,在OnDestroy中退订)。