概述
发布订阅模式又叫观察者模式(Observer Pattern),它是指对象之间一对多的依赖关系,每当那个特定对象改变状态时,所有依赖于它的对象都会得到通知并被自动更新。
原理
严格意义上来说,观察者模式与发布订阅模式还有一些区别,观察者模式之间的发布者与订阅者是双向关联的,而发布—订阅模式多了一个事件中心,负责存放事件和订阅者关系,完全解耦了发布者和订阅者。发布订阅模式是观察者模式的一种,但是又有部分区别:
1.观察者模式中,发布者与订阅者直接关联,发布者维护观察者列表,发布者状态变更通知订阅者;在发布-订阅模式中,订阅者与发布者相互不了解,通过事件中心进行通信;
2.耦合性方面,发布订阅者中发布者与订阅者完全松耦合;
案例
观察者模式
假设我们要往游戏里添加一个成就系统,玩家在游戏中可能会解锁一个成就,比如“从桥上坠落”等。
以“从桥上坠落“为例,该情况可能会和物理引擎相关联,但我们真的想在碰撞检测算法中来具体实现该成就吗?
这里就发生了一个问题,成就的触发可能与玩家在游戏世界里面的很多行为相关。因此,我们要实现这些成就系统并且不会耦合系统里面的其他代码。
如前文所述,观察者模式能使得代码发出一个消息,并通知对消息感兴趣的对象,而不用关心具体是谁接收到了通知。
原本的物理系统中当检测到玩家从桥上坠落后将会开始实现解锁成就、甚至跌落音效、UI变化等与物理系统无关的功能,经过观察者模式设计后,物理系统中只需发出一个通知”玩家从桥上坠落“这短短一行代码,至于玩家坠落后其他系统会发生什么,则在订阅了该事件的系统中单独实现。
// 被观察者基类
using UnityEngine;
using System.Collections.Generic;
public class Subject : MonoBehaviour
{
// 观察者数组
private static readonly int MAX_OBSERVERS = 10;
private int numObservers;
protected List<Observer> observers = new List<Observer>(MAX_OBSERVERS);
// 添加进观察者列表
public void AddObserver(Observer observer)
{
observers.Add(observer);
}
// 从观察者列表中移除
public void RemoveObServer(Observer observer)
{
observers.Remove(observer);
}
protected virtual void Notify(GameObject gameObject, string _event)
{
for (int i = 0; i < observers.Count; i++)
{
// 依次对观察者发送消息
observers[i].OnNotify(gameObject, _event);
}
}
}
// 观察者基类
using UnityEngine;
public class Observer : MonoBehaviour
{
public virtual void OnNotify(GameObject gameobject, string _event) { }
}
被观察者允许外部代码来控制谁可以接收通知。这个被观察者对象负责和观察者对象进行沟通,但是,它并不与它们耦合。在该例子中,没有一行物理代码会涉及成就系统。
同时,被观察者对象拥有一个观察者对象的集合,而不是单个观察者。此举保证了观察者们并不会隐式地耦合到一起。例如,声音引擎也注册了落水事件,这样在该成就达成的时候就可以播放一个合适的声音。如果被观察者对象不支持多个观察者的话,当声音引擎注册这个事情的时候,成就系统就无法注册该事件了。
以下是实例代码:
using UnityEngine;
public class Player : Subject
{
#region 移动基本参数
Rigidbody2D rig;
public float moveSpeed;
#endregion
#region 观察者模式实现
public bool isOnBridge;
public Observer achievent;
#endregion
private void Awake()
{
rig = GetComponent<Rigidbody2D>();
}
private void OnEnable()
{
AddObserver(achievent);
}
private void OnDisable()
{
RemoveObServer(achievent);
}
void Update()
{
float horizontalMovement = Input.GetAxisRaw("Horizontal");
Vector3 direction = new Vector3(horizontalMovement, 0f, 0f);
rig.velocity = direction * moveSpeed;
}
private void OnCollisionExit2D(Collision2D collision)
{
if(collision.gameObject.name == "Bridge")
{
base.Notify(this.gameObject, "FallOfBridge");
}
}
}
using UnityEngine;
public class Achievement : Observer
{
public override void OnNotify(GameObject gameobject, string _event)
{
switch (_event)
{
case "FallOfBridge":
if (gameobject.name == "Player" && gameobject.GetComponent<Player>().isOnBridge)
{
Debug.Log("解锁从桥上坠落成就");
}
break;
//......处理其他事件......
//......更新玩家状态......
default:
break;
}
}
}
Achievent是观察者,Player是被观察者,Player在启用时便初始化观察者集合,(当Player被禁用时需要删除对观察者的引用,避免发生内存泄漏),Achievent始终观察者Player,当Player发生了”从桥上坠落“的事件时,Achievent做出相应的行为(打印文字)。
运行结果:
发布—订阅者模式
相同的案例,以 发布—订阅者模式 的方式来实现一次。由于发布者和订阅者完全解耦,因此它们可以不用使用父类继承,但为了和上面形成对比,仍然按照经典设计模式的继承关系来写代码
// 发布者
using UnityEngine;
public class Publisher : MonoBehaviour
{
}
// 订阅者
using UnityEngine;
public class SubScriber : MonoBehaviour
{
public virtual void OnNotify(MyEvent _event, MyEventArgs myEventArgs) { }
}
// 事件
using System;
using System.Collections.Generic;
using UnityEngine;
public class MyEvent : MonoBehaviour
{
// 事件数组
private static readonly int MAX_EVENTS = 10;
private int numEvents;
protected List<SubScriber> subScribers = new List<SubScriber>(MAX_EVENTS);
// 添加进订阅者列表
public void AddSubScriber(SubScriber subScriber)
{
subScribers.Add(subScriber);
}
// 从订阅者列表中移除
public void RemoveSubScriber(SubScriber subScriber)
{
subScribers.Remove(subScriber);
}
public virtual void CallEvent(GameObject gameObject, bool isOnBridge)
{
if (subScribers.Count > 0)
{
for (int i = 0; i < subScribers.Count; i++)
{
subScribers[i].OnNotify(this, new MyEventArgs()
{
gameObject = gameObject,
isOnBridge = isOnBridge,
});
}
}
}
}
public class MyEventArgs : EventArgs
{
public GameObject gameObject;
public bool isOnBridge;
}
订阅者与发布者完全解耦,它们仅存有事件的引用,由发布者发布事件“从桥上坠落”,然后“从桥上坠落”事件通知所有订阅了该事件的订阅者,这些订阅者做出相应的行为。事件允许外部代码来控制谁可以订阅通知以及谁可以发布通知。发布者负责和事件沟通,事件负责和订阅者进行沟通,但是,它们都不互相耦合。在该例子中,发布者与订阅者仅持有事件的引用,而事件仅有通知订阅者的功能。
事件拥有一个订阅者对象的集合,而不是单个订阅者。此举保证了订阅者们并不会隐式地耦合到一起。例如,声音引擎也订阅了落水事件,这样在该成就达成的时候就可以播放一个合适的声音。
以下是实例代码:
using UnityEngine;
public class Player : Publisher
{
#region 移动基本参数
Rigidbody2D rig;
public float moveSpeed;
#endregion
#region 发布—订阅者模式实现
public bool isOnBridge;
FallOfBridgeEvent fallOfBridgeEvent;
#endregion
private void Awake()
{
rig = GetComponent<Rigidbody2D>();
fallOfBridgeEvent = GetComponent<FallOfBridgeEvent>();
}
void Update()
{
float horizontalMovement = Input.GetAxisRaw("Horizontal");
Vector3 direction = new Vector3(horizontalMovement, 0f, 0f);
rig.velocity = direction * moveSpeed;
}
private void OnCollisionExit2D(Collision2D collision)
{
if(collision.gameObject.name == "Bridge")
{
fallOfBridgeEvent.CallFallOfBridgeEvent(this.gameObject, isOnBridge);
}
}
}
using UnityEngine;
public class Achievement : SubScriber
{
public FallOfBridgeEvent fallOfBridgeEvent;
private void Awake()
{
fallOfBridgeEvent = GetComponent<FallOfBridgeEvent>();
}
private void OnEnable()
{
if (fallOfBridgeEvent != null)
{
fallOfBridgeEvent.AddSubScriber(this);
}
}
private void OnDisable()
{
if (fallOfBridgeEvent != null)
{
fallOfBridgeEvent.RemoveSubScriber(this);
}
}
public override void OnNotify(MyEvent _event, MyEventArgs myEventArgs)
{
switch (_event)
{
case FallOfBridgeEvent:
if (myEventArgs.gameObject.name == "Player" && myEventArgs.isOnBridge)
{
Debug.Log("解锁从桥上坠落成就");
}
break;
//......处理其他事件......
//......更新玩家状态......
default:
break;
}
}
}
using UnityEngine;
public class FallOfBridgeEvent : MyEvent
{
public void CallFallOfBridgeEvent(GameObject gameObject, bool isOnBridge)
{
base.CallEvent(gameObject, isOnBridge);
}
}
首先解释一下为什么要以public override void OnNotify(MyEvent _event, MyEventArgs myEventArgs)的形式传递参数。
这是C#的处理事件的标准委托类型EventHandler。对于事件的使用,.NET框架提供了一个标准模式。事件使用的根本模式就是使用System命名空间声明的EventHandler委托类型。
通俗来讲,就是在使用事件时,第一个参数为事件本身(作为事件的引用),第二个参数为该事件所需要的参数,这些所需要的参数需要自定义一个继承于EventArgs的参数类来集合起来。(EventArgs是System提供的一个空类,因此可以随意扩展,该继承就是用来标识该类是一个参数类)。经过这样的处理后所有的事件都只有两个参数,增强了代码的可读性。
订阅者Achievement在激活时就订阅了事件FallOfBridgeEvent“从桥上坠落”。之后发布者Player发布了事件FallOfBridgeEvent“从桥上坠落”,订阅者Achievement订阅了该事件,于是便通知它做出相应的行为(打印文字)。
结果如下:
观察者模式的问题
经过刚才的原理分析后可以看出,观察者模式是同步进行的,被观察者对象可以直接调用观察者们,这意味着,所有的观察者们都从它们的通知返回后被观察者才能继续工作,那么,其中任何一个观察者对象都有可能阻塞被观察者对象。
如果按照同步的方式来处理,那么则需要马上完成响应,然后将控制权尽可能快的返回到当前代码,这样当前的工作才不会被卡住;若当前的操作很慢时,可以试着让它们在另外一个工作线程或者工作队列里执行。
但是,当使用多线程时要十分小心,因为如果一个观察者想要取得被观察者对象的锁,那就有可能会让整个游戏死锁。因此最好使用事件队列来处理异步通信问题(发布订阅者大多数是异步进行的,使用的是消息队列)。
链式观察者
从我们已经看过的代码中,被观察者类拥有一个观察者的指针列表。观察者类本身并没有一个指向此列表的引用。它只是一个纯虚接口。优先使用接口而不是具体的有状态的类,通常是一个好的设计。但是,如果我们愿意在观察者类里面添加一些状态,那么就能够通过将列表与观察者串起来的方法解决我们的分配问题。这里不是让被观察者类拥有一系列观察者的集合,而是让观察者们变成链式列表的一个节点。
实现方式与链表相似:
将数组从被观察者类中移除,并替换成一个指向链式列表中第一个观察者的指针;
class Subject
{
private Observer head;
public Subjet(Observer head = null)
{
}
}
然后,我们在观察者类中增加一个指向链式列表中下一个观察者的指针:
class Observer
{
private Observer next;
public Observer(Observer next = null)
{
}
}
注册一个新的观察者只需要把它插入到这个列表中就可以了,最简单的方式是将它添加到链表头部(头插法);另一种方式则是将观察者添加在链表尾部(尾插法)。那样做的话,可能会有一点点复杂。被观察者对象要么从头至尾遍历一次来找到最后一个节点,要么通过维护一个tail_指针,让这个指针永远指向最后一个节点。把观察者每次都添加到链表表头会更简单一些,但是这样做有一个缺点。当我们从头至尾遍历这个链表来给每一个观察者发送通知的时候,最近注册的观察者会最先收到通知。所以如果你按照A、B、C的顺序来注册观察者,那么收到通知的观察者的顺序便是C、B、A。
理论上,哪种顺序无关紧要。这里有一个原则,如果两个观察者观察同一个被观察者对象,则它们两个不会因为注册顺序而受到影响。如果注册顺序对观察者有影响的话,那么这两个观察者便产生了耦合并有可能带来不必要的麻烦。
同样,删除一个观察者也就和链表中删除一个节点一样,发送消息也和遍历链表的操作差不多,这里就不赘述了。
这种实现方法里,一个被观察者对象可以包含任意多个观察者,而且添加和删除观察者并不会造成任何动态内存分配。注册观察者和移除观察者的操作和普通数组操作一样快。但是,我们这样做是以牺牲了一个功能特性为代价的。因为我们的观察者对象本身也是链表的一个节点,所以,这意味着我们的观察者必须是被观察者对象的观察链表的一部分。换句话说,一个观察者在任意时刻只可以观察一个被观察者对象(链表中的next会被新的节点覆盖,因此一个链表中的节点只能存在于一个链表中)。在一些更一般的实现中,每一个被观察者对象都维护一个独立的观察者链表,那样一个观察者就可以同时观察多个被观察者对象了。
链表节点池
和之前一样,每一个被观察者对象都维护一个观察者列表。但是,现在这些链表节点并不是观察者本身。相反,我们维护一个链表,这个链表里面的节点包含一个指向观察者对象的指针和一个指向下一个节点的指针
多个链表节点可以指向同一个观察者,这意味着一个观察者可以同时观察多个被观察者对象,这样一来,我们又可以同时观察多个被观察者对象了。我们避免动态内存分配的方法很简单:由于所有的节点都是同样的大小和类型,因此你可以预先分配一个内存对象池。这样你就有了一个固定大小的链表节点池,并且可以根据需要去重用而不用自己处理一个内存分配器。
观察者和被观察者的销毁
我们目前看到的代码示例是健壮的,但是它也显示出了一个重要的问题:当你删除一个观察者或者被观察者的时候呢?如果你粗心地对观察者对象调用delete方法,则此时被观察者对象可能还持有被删除的观察者的引用。此时,我们就有一个指向了一块被删除的内存的指针。当被观察者对象尝试对这个指针发送通知的时候便会出现大问题。
销毁一个被观察者对象在大部分实现里面都会更容易一些,因为观察者没有一个指向被观察者对象的引用。但是,即使是这样,把被观察者对象的内存直接放到回收池里面也容易导致问题。这些观察者还是期望在之后收到通知,但是,现在它们并不清楚这一切。这些观察者实际上不再是观察者了(因为此时已经没有被观察者可供它们观察了,也就是说,已经没有指向它们的指针了)。但是,它们还自以为是。你可以用多种不同的方法来处理这个问题。最简单的方法就是按照我在这里介绍的去做。当一个被观察者对象被删除时,观察者本身应该负责把它自己从被观察者对象中移除。通常情况下,观察者都知道它在观察着哪些被观察者,所以需要做的只是在析构器中添加一个removeObserver()方法。
当一个被观察者对象被删除时,如果我们不想让观察者来处理问题,则可以修改一下做法。我们只需要在被观察者对象被删除之前,给所有的观察者发送一个“死亡通知”就可以了。这样,所有已注册的观察者都可以收到通知并进行相应的处理。
一个更靠谱的方法是每一个被观察者对象被删除的时候,所有的观察者都自动取消注册自身。如果你在你的观察者基类里面实现这些逻辑,则每一个人都不用记住它。这样做确实添加了不少复杂度,但是,它意味着每一个观察者都需要维护一个它观察的被观察者对象列表。
我认为当每一个被观察者对象被删除的时候,可以让每一个被观察者对象中对应的观察者全部取消注册,这样就不需要观察者维护一个它观察的被观察者对象列表
GC的问题
想象一下:你有一个UI界面,它显示了玩家的许多信息,比如血条、经验值等。当玩家进入这个状态的时候,你会创建一个新的UI实例。当你把UI界面关闭的时候,你完全可以忘记这个对象,因为垃圾收集器会处理它。每一次角色的脸(或者其他别的地方)被击打,它就会发送一个通知。UI界面接收到了这个事件,并且更新血条显示。太好了。那么,当玩家离开场景(或者玩家死亡被销毁),并且你没有注销观察者的时候呢?此时UI界面不再可见,但是,它也不可能被垃圾回收,因为角色对象的观察者仍然持有玩家的引用。每一次场景重新加载时,我们会添加一个新的UI界面实例到越来越长的观察者链表中。玩家整个时间就是玩游戏,跑来跑去,打来打去,我们可以在任意场景里面侦听这个消息。虽然我们的场景可能没有显示,但是,它还是一样会收到通知,一样会消耗CPU时钟来更新这些不可见的UI元素。如果它们做其他一些事件,比如播放音乐,你会发现明显的错误行为。
这是一个在通知系统中普遍存在的问题:失效观察者。由于被观察者对象持有它们的侦听者对象的引用,因此最后会导致一些僵尸UI对象留在内存中。我们学到的经验就是要及时删除观察者。
为了避免该情况的发生,我们通常在OnEnable和OnDisable中来订阅和取消订阅观察者
资料参考:
小侃设计模式(十八)-发布订阅模式_发布订阅者模式-CSDN博客
《游戏编程模式》