《游戏编程模式》四、观察者模式(Unity 实现)

214 阅读8分钟

“在对象间定义一种一对多的依赖关系,以便当某对象的状态改变时,与它存在依赖关系的所有对象都能收到通知并自动进行更新。”

概要

优点

  • 松耦合:主题对象和观察者之间的耦合度较低,主题对象只需要知道观察者实现了更新接口,而不需要了解具体观察者的细节。这使得系统更易于扩展和维护,新增或移除观察者不会影响主题对象的代码。
  • 支持广播通信:主题对象状态改变时,能自动通知所有注册的观察者,实现了一种广播机制,方便在多个对象间同步信息。
  • 符合开闭原则:可以在不修改主题和现有观察者代码的情况下,轻松添加新的观察者,增强了系统的可扩展性。

缺点

  • 可能导致性能问题:如果观察者数量过多,每次主题对象状态改变时通知所有观察者可能会消耗较多的时间和资源,影响游戏性能。
  • 依赖关系复杂:如果观察者之间存在复杂的依赖关系,一个观察者的更新可能会触发其他观察者的连锁反应,导致程序逻辑难以理解和维护。
  • 内存泄漏风险:如果观察者在不需要监听主题对象时没有正确从主题对象中移除,可能会导致内存泄漏。

使用场景

  • 游戏状态更新通知:在很多游戏中,游戏的整体状态(如游戏开始、暂停、结束等)会影响到多个游戏元素
  • 角色属性变化反馈:在角色扮演游戏(RPG)中,玩家角色的属性(如生命值、魔法值、经验值等)发生变化时,可能需要更新多个界面元素或触发特定的游戏逻辑
  • 事件系统:游戏中的各种事件(如怪物死亡、玩家触发机关等)可以通过观察者模式来处理。当怪物死亡事件发生时,相关的任务系统、掉落系统、经验系统等都可能需要做出响应。怪物类作为主题对象,任务系统、掉落系统、经验系统等作为观察者,怪物死亡时通知这些观察者执行相应的逻辑。

4.1 解锁成就

假设我们正在往游戏里添加一个成就系统

转存失败,建议直接上传图片文件

要优雅地实现会比较棘手,一不小心就可能会将系统弄得一团糟,且会使代码库很难维护。我们并不想在碰撞检测算法中调用 unlockFallOffBridge() 函数来解锁“从桥上坠落”成就

我们要怎样实现这些成就且不会耦合系统里的其他代码呢?此时就需要用到“观察者模式”了

为了实现“在从桥上坠落”的成就,我们通过下面代码实现:

class Physics
{
    // 每帧执行
    void UpdateEntity(Object entity)
    {
        bool wasOnSurface = entity.IsOnSurface();
        entity.Accelerate(GRAVITY);
        entity.Update();
        if (wasOnSurface && !entity.IsOnSurface())
        {
            // 之前在地面上,现在不是,发送通知
            notify(entity, "EVENT_START_FALL");
        }
    }
}

成就系统注册它本身为观察者,当物理系统发出通知时,成就系统便会收到通知。然后系统再去检查这个掉落的刚体是否符合成就的达成要求,若满足,就触发解锁成就逻辑,且这一切与物理系统完全解耦

4.2 这一切是怎么工作的

4.2.1 观察者

此例中成就系统就是观察者

class Observer
{
    public virtual void OnNotify(Entity entity, Event evt) {}
}
// 成就系统
class Achievements : Observer
{
    public void OnNotify(Object entity, string evt)
    {
        switch (evt)
        {
            case "EVENT_ENTITY_FALL":
                if (IsEntityOnBridge(entity))
                {
                    // 若实体是主角且在桥上,解锁成就
                    Unlock("ACHIEVEMENT_FELL_OFF_BRIDGE");
                }
            break;
        }
    }
    
    private void Unlock(string ach) {
        Debug.Log($"Achievement: {ach}");
    }
    
    private bool IsEntityOnBridge(Object entity)
    {
        return true;
    }
}
// 再加个音效系统吧
class Audio : Observer
{
    public override void OnNotify(Object entity, string evt)
    {
        switch (evt)
        {
            case "EVENT_ENTITY_FALL":
                Debug.Log("Audio: EVENT_ENTITY_FALL");
                break;
        }
    }
}

4.2.2 被观察者(也可称为“主题”)

两个职责:

  • 持有观察者的列表,暴露增/删此列表的 API
  • 发送通知,通知全部观察者
class Subject
{
    private List<Observer> observerList = new List<Observer>();
    
    public void AddObserver(Observer observer)
    {
        observerList.Add(observer);
    }
    public void RemoveObserver(Observer observer)
    {
        observerList.Remove(observer);
    }
    
    public void Notify(Object entity, string evt)
    {
        foreach (var observer in observerList)
        {
            observer.OnNotify(entity, evt);
        }
    }
}

4.2.3 可被观察的物理模块

现在我们只需要将这些与物理引擎挂钩使得它能够发送通知

class Physics : Subject
{
    void UpdateEntity(Object entity) {
        //...
        if (wasOnSurface && !entity.IsOnSurface())
        {
            // notify(entity, "EVENT_START_FALL");
            OnEntityFall(entity);
        }
    }
    
    public void OnEntityFall(Object entity)
    {
        Notify(entity, "EVENT_ENTITY_FALL");
    }
}

public class ObserverExample : MonoBehaviour
{
    private Achievement achievement = new Achievement();
    private SoundMgr soundMgr = new SoundMgr();
    private Physics physics = new Physics();

    void Start()
    {
        // 物理引擎添加两个观察者
        physics.AddObserver(achievement);
        physics.AddObserver(soundMgr);
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // 按下空格模拟触发事件
            physics.OnEntityFall(this);
        }
    }

}

效果:

4.3 它太慢了

发送一个通知,只不过需要遍历一个列表,然后调用一些虚函数。虚函数带来的开销几乎可忽略不计。我发现这个模式适用于不是代码性能瓶颈的地方,这样你可以实现动态分配

除此之外,我们并没有为消息分配对象,它只是一个同步方法调用的间接实现

它太快了

因为观察者模式是同步的,被观察者可以直接调用观察者们,这意味着所有观察者们都处理完消息后,被观察者才能继续工作,其实任何一个观察者都有可能阻塞被观察者

4.4 太多的动态内在分配

拥有 GC 机制的语言中,动态分配消耗时间,重新获取内在也是一样,即使这一切是自动完成的

观察者列表总是一个动态分配的集合,当增/删观察者时,该集合会动态的扩展或收缩,这种内存分配有时会令人头疼不已

4.4.1 链式观察者

若我们愿意在观察者类里面添加一些状态,那么就能通过将列表与观察者串起来的方式解决我们的内在分配问题。这里不是让被观察者拥有一系列观察者集合,而是让观察者们变成链式列表的一个节点

优点是增/删观察者不会造成任何动态内在分配,缺点是一个观察者在任意时刻只能观察一个主题(被观察者)。

虽然有这样的限制,但实际应用应该没什么影响。因为我发现一个被观察者对象包含多个观察者是更普遍的情况,而反之却不常见。

4.4.2 链表节点池

每个被观察者都维护一个观察者列表,但这些链表节点并不是观察者本身,而是包含一个指向观察者对象的指针和一个指向下一个节点的指针(即包含两个指针)

这样一个观察者就可以同时观察多个被观察对象了。避免动态内存分配的方法很简单:由于所有节点都是同样的大小和类型,因此可以预先分配一个内存对象池

4.5 余下的问题

这模式简单、快速,但这意味着你在任何时刻都应该使用它吗?答案是否定的,好的设计模式去处理错误的问题,结果会更加糟糕

还存在两个问题,一个是技术性问题,另一个是可维护性级别

4.5.1 销毁被观察者和观察者

当你粗心地对观察者对象调用 delete 时,此时被观察者还持有被删除的观察者的引用。当被观察者尝试对这个指针发送通知时……噩梦就来了

反之亦然,虽然直接删除被观察者看起来没什么后患,因为观察者们没有一个指向被观察者的引用。但观察者们还在默默等待着通知……

解决方法

被观察者对象被删除时:

  • 在析构函数中添加 RemoveObserver()方法
  • 或者发送一个“死亡通知”,让观察者们自行处理

4.5.2 不用担心,我们有 GC

尽管现代编程语言有 GC 机制,但也要注意及时删除观察者,避免观察者列表越来越长的问题

4.5.3 接下来呢

如果你经常需要为了理解程序的逻辑而去梳理模块之间的调用顺序,那么就不要用观察者模式而使用其他更好的办法了

观察者模式非常适合于一些不相关模块之间的通信,但不适合于单个紧凑的模块内部的通信

4.6 观察者模式的现状

这模式在面向对象时期是很流行的,所以基本上都是基于类来做。但这样看起来很重量级且死板,不符合现在的编程美学

比如,C# 在语言层面就有一个“event”关键字,这样观察者就变成了一个“代理”

4.7 观察者模式的未来

基础乏味的功能(如 UI 更新),在现在的框架里面,都会用“数据绑定”来解决

但,它非常简单,且运转良好。对于我来说,这两点是解决方案中最重要的两条标准