赞
踩
先来看一段比较正式的介绍:
观察者模式是软件开发中一种十分常见的设计模式,又被称为发布-订阅(Publish/Subscribe)模式,属于行为型模式的一种。它定义了一种一对多的依赖关系,让多个观察者对象(Observer)同时监听某一个主题对象(Subject)。这个主题对象在状态变化时,会通知所有的观察者对象,使他们能够自动更新自己。
以运动会的跑步比赛为例,假设场上有这几个对象:裁判,运动员,观众。那怎么才能知道比赛开始呢?这时候运动员和观众就会作为观察者,“关注”裁判(此时裁判就是主题对象),当裁判的发令枪响起时(主题对象状态发生改变),标志着比赛开始。然后运动员和观众收到“比赛开始”的通知后,各自做出他们的响应(观察者状态更新):跑步运动员向终点奔去,观众开始注视场上的赛况。
所以整合一下,观察者模式要包括这些组成部分:
1)一个主题对象,这个名词看起来比较抽象,我们干脆叫它 “被观察者” 吧
2)多个 关注/订阅 被观察者的 观察者
3)被观察者的状态发生改变时,观察者会收到通知,然后观察者会做出他们各自的响应(或者说改变他们自己的状态)
关于“观察者模式和发布-订阅模式算不算两个独立的设计模式”这一讨论也是争议不断。
之前提过观察者模式的别称是“发布-订阅模式”,但是有些地方会说这两种模式是不同的两个模式。
从两者的实现结构来看,确实会有些不同。我这里用两张图来进行比较:
可以明显地看到,发布-订阅模式在原来的观察者和被观察者之间加了一个调度中心。
那么消息发送者(Publisher)就不会将消息直接发送给订阅者(Subscriber),这意味着发布者和订阅者不知道彼此的存在。他们之间的通信全交给了作为第三方的调度中心。
同样举个生活中的例子:一个 CSDN 博主被好几个粉丝关注,这些粉丝充当了“订阅者”的角色,他们“关注”(订阅)了博主。每当博主(消息发送者)发了一条新的博客,这条博客是发到了 CSDN 平台(作为调度中心)上,那么 CSDN 平台会将“博主发了一条新博客”这个消息通知给关注博主的粉丝们,然后这些粉丝就会做出他们各自的响应(比如浏览博文,点赞之类的)。
有了调度中心后,博主只要安心地专注于发博客这件事情身上,他不用管谁是他的粉丝,因为“把更新消息发给粉丝”这件事是由 CSDN 平台这个调度中心来执行的,无需博主亲自通知;粉丝关注博主也是借由 CSDN 平台来记录的。
总结来说,此时 CSDN 平台知道一个博主的粉丝具体是谁,然后当博主在 CSDN 平台上发博客时,CSDN 平台就通知该博主的所有粉丝。
用程序的话语来解释:
订阅者把自己想订阅的事件注册到调度中心,在发布者发布该事件到调度中心后,由调度中心统一调度订阅者用于响应事件的处理代码(订阅者收到事件触发消息后所要做的事)。
那么对于发布-订阅模式:
1)一共有3个组成部分:发布者,订阅者,调度中心
2)发布者和订阅者完全不存在耦合。
对于观察者模式:
1)一共有2个组成部分:被观察者,观察者
2)被观察者和观察者是直接关联的,但它们是松耦合。这个是指被观察者状态变化时会自动触发观察者状态的变化,只是被观察者需要知道谁是观察者。
但是发布-订阅模式弱化了对象之间的关联也会存在一些缺点,过度使用可能会使代码不好理解(这个后面会根据实际例子进行说明)
从组成结构上来看,它们确实会有不同。
从实现目的来看,它们是相同的,都是一个对象的状态发生改变时会通知那些与此对象关联的其他对象。
我的个人理解是,发布-订阅模式是观察者模式的变种,也可以说是观察者模式的优化升级。我们也许不必把太纠结于“它们是不是同一种设计模式”,而是要充分学习它们的思想,在合适的时候运用到实际的开发中,为我们带来便利。不过理解它们在组成结构上的区别还是有必要的,万一面试会考呢?
试想一个常见的游戏场景:玩家 Gameover(死亡)
玩家死亡时,会伴随着其他的一些事情发生,比如敌人停止移动,界面出现游戏失败 UI …
这里我们先来考虑玩家死亡时敌人停止移动怎么实现
注:这里就不展示敌人停止移动具体的代码实现了,我们用一个输出语句来表示;然后为了快速表示玩家死亡,我直接按下键盘的J键来触发
那么借助 Unity 引擎和 C# 语言,我们用一种简单的实现方式:
首先简单搭下游戏场景
球表示玩家,方块表示敌人。
敌人脚本:
public class Enemy : MonoBehaviour
{
public void StopMove()
{
print($"{gameObject.name}停止移动");
}
}
敌人停止移动时把信息输出在控制台上。
玩家脚本:
public class Player : MonoBehaviour { public Enemy[] enemies; void Update() { if (Input.GetKeyDown(KeyCode.J)) { PlayerDead(); } } private void PlayerDead() { NotifyEnemy(); } private void NotifyEnemy() { for (int i = 0; i < enemies.Length; i++) { enemies[i].StopMove(); } } }
让玩家持有敌人的引用,然后玩家死亡时去调用敌人的 StopMove 方法。
然后我们要在 Unity 编辑器里通过拖拽的方式把敌人游戏物体赋给 Player 的 enemies 数组
那么当我们运行游戏,按下 J 键时就会看到控制台输出了我们想要的结果:
到这里我们的需求就实现完了,是不是很简单呀?不用什么观察者模式都能实现。
那么现在我给项目增加需求(你是故意找茬是不是?)(其实需求变化在软件开发中是很常见的事啦,习惯就好)
玩家死亡后,不仅要让敌人停止移动,还要显示游戏失败的UI
为了表示方便,我还是用输出语句来模拟
UI 脚本:
public class GameoverUI : MonoBehaviour
{
public void ShowGameOver()
{
print("Game over");
}
}
修改玩家脚本:
我们同样在编辑器中通过拖拽的方式为新增的公有变量 gameoverUI 赋值,并且还要修改 PlayerDead 方法。
可以看到玩家脚本需要持有与它相关联的其他所有脚本对象,进而去调用这些脚本拥有的方法。
也就是一个类要去调用另一个类的方法,一种最简单的方式就是去引用另一个类的对象。Player 脚本拥有了 Enemy 和 GameoverUI 类的成员,在通过面板拖拽实例化后,便能调用 Enemy 类和 GameoverUI 类的方法。
那么设想如果玩家死亡会触发一系列对象状态的改变,远不止我们前面设置的2个需求,我们就不得不在玩家类中添加其他脚本的对象引用,这么做伴随着几个缺点:
现在我们用观察者(发布-订阅)模式对代码进行优化。
先看一种比较标准的观察者模式结构,这里用一种不大标准的 UML 图简要的表示下(用种简要的图来表示观察者模式中的各组成部分之间的联系):
Observer:抽象观察者,提供收到被观察者状态变化的通知时触发的方法,我们先不管这个“抽象”的意思,先来看 Subject。
Subject:抽象主题对象(被观察者),持有抽象观察者的列表,因为一个被观察者可以有很多个观察者,但是观察者的类可能是不同的,为每种观察者定义一个列表显然是麻烦的,那我们要定义一个什么样的列表来容纳各个种类的观察者呢?这时候就要用上“基类”的思想,可以让所有的观察者继承自同一个父类,最后列表里装的是这个父类就行了,而这个父类其实并不需要是具体存在的某一个观察者,我们只需把它定义成抽象的,然后在运行期间让这个抽象的父类去指代某一个具体的观察者(有点像多态,也是面向对象设计原则中 “里氏替换原则” 的应用)。这样我们写代码时只用处理抽象基类,而这个抽象基类具体指代的是哪一个具体的子类,是程序运行时会根据实际情况转化的。这种思想可以用两种方式来实现:抽象类和接口。我推荐用接口来实现,原因如下:
因此可以定义一个接口作为抽象观察者,让各个观察者去实现这个接口,那么 Subject 的列表里装的就是抽象的接口,在运行期间去访问观察者列表是实际上访问的也就是具体的那些观察者。
被观察者也可以有一个统一的接口,提供添加观察者,移除观察者,以及通知观察者的方法。每个具体的被观察者可以实现这个接口。
ConcreteObserver:实现了 Observer 接口的具体观察者。
ConcreteSubject:实现了 Subject 接口的具体被观察者。
以上是从代码层面介绍观察者模式各组成部分与各部分之间的联系。这样被观察者只负责在自身状态发生改变或做出某种行为时向自身的订阅清单(也就是存储观察者的列表)发出“通知”(Notify);观察者只负责向目标“订阅”它的变化(通过 Subject 的 Attach),以及定义自身在收到目标“通知”后所需要做出的具体行为 (Observer 的 Update)。至于被观察者怎样准确地通知到每一个观察者,这件事交给被观察者的抽象观察者列表去处理,在运行期间再转化为具体的观察者对象,而不是 “被观察者先持有所有观察者的对象,再直接调用这些对象的行为(方法)”。
那么用代码实现观察者模式的实现思路就是:
1)定义抽象观察者的接口,定义自身在收到通知后触发的方法,然后用具体观察者去实现接口。
2)定义抽象被观察者的接口,定义添加观察者,移除观察者,通知观察者的方法,然后用具体被观察者去实现接口。
因为 C# 的接口不能定义字段,所以我们不能在抽象被观察者中定义一个列表。在实际的使用过程中,我倾向于把定义观察者列表这一操作放到具体被观察者中去实现。
3)接下来就是让观察者和被观察者关联到一起。虽然被观察者仍然持有了观察者列表,但是这个列表里装的东西是抽象的接口,我们不必直接持有每一个观察者对象的引用,像之前写的 Player 脚本那样:
而是存储统一的类似所有观察者基类的抽象观察者,所以我们能用抽象观察者去概括具体观察者,能用一个统一的列表去涵盖所有的观察者。
public class Player : ISubject{
private List<IObeserver> observers=new List<IObeserver>();
...
}
然后每个观察者在自己的类中把自己添加到被观察者的观察者列表中(相当于订阅了被观察者),当被观察者发起通知时,会去遍历持有的观察者列表,调用每个抽象观察者的 “Update” 方法,那么调用抽象层实际上也就会调用具体观察者重写的抽象接口中的方法。
在被观察者的眼中,它所交互的都是抽象的观察者,因此不管观察者的代码怎么发生变动,在被观察者的眼中始终是一模一样的抽象观察者,只是实际运行时抽象才指代具体,这样对被观察者的代码本身没有任何影响。所以说观察者模式是低耦合的。
因此从代码层面理解观察者模式,就是在原先的结构上加了“抽象”层。
为什么被观察者的观察者列表要是抽象的?为什么抽象能帮助代码解耦?如果看到这里你能够在心中回答这两个问题,相信你有能力手写观察者模式的代码了。如果还是不太清楚也没关系,毕竟概念可能会有一点“抽象”,那么我们直接通过实战来学习!
下面用具体代码对之前玩家死亡的案例进行改进,来帮助大家加深对上述概念的理解。(这里只会给出部分代码,因为我把重点放在更实用的发布-订阅模式上)
如果用严格意义上的观察者模式,作为观察者的敌人需要把自己添加给被观察者的列表,但是添加的方法是定义在抽象被观察者接口中的。
因此具体的被观察者,也就是玩家,持有“添加观察者”这个实例方法,如果要调用一个类的实例方法,就必须先实例化这个类,这就导致我们要在具体观察者的类中持有对玩家的引用,提高了观察者与被观察者之间的耦合性,就像下面这张图这样:
当然,稍微变通一下是可以解决。比如将玩家类加上单例模式,或者在被观察者接口中删去添加观察者和移除观察者的方法,然后把玩家类中的观察者列表改为 static,这样我们可以直接在具体观察者类中获取玩家类中静态的列表,调用列表本身的添加方法:
但是使用静态会让一个类的所有实例共享这个数据,有时候可能并不适用。把所有被观察者设为单例也并不是个好的选择。
那之前说了,观察者模式的升级版——发布-订阅模式添加了一个调度中心,能够使观察者和被观察者完全解耦,这在实际开发中是很实用的。因此接下来我会着重于用接口来实现发布-订阅模式。
我们用一个 GameManager 作为调度中心,相当于一个管理者来管理所有的观察者,并且对外提供添加、移除观察者和通知的方法。那么原先的被观察者发布通知,直接调用的是 GameManager 的通知方法,观察者把自己添加到观察者列表,调用的是 GameManager 的添加方法。观察者与被观察者之间不建立任何联系,全靠第三方的调度中心通信,这样可实现跨模块的交互。
观察者接口:
public interface IObserver
{
public void ResponseToNotify();
}
这里因为有了 GameManger,我们就无需写个多余的被观察者接口。而且像 GameManager 这种作为管理者的脚本,整个游戏期间只需有唯一的对象,因此建议利用单例模式把管理器脚本设为单例,一旦将 GameManager 实例化后,之后使用的都是这个唯一存在的 GameManager【本篇博客不会详细介绍单例模式的相关知识点,但会演示如何使用,并且使用的也是简单的单例模式版本。想要了解更多关于单例模式的可以看这篇文章 Unity 单例基类(结合单例模式)。】
GameManager脚本(无需继承 MonoBehaviour,我们不必把此脚本挂到任何游戏物体上):
public class GameManager { //单例模式应用 private static GameManager instance; public static GameManager Instance { get { if (instance == null) instance = new GameManager(); return instance; } } private List<IObserver> observers = new List<IObserver>(); //添加观察者 public void AddObserver(IObserver observer) { observers.Add(observer); } //移除观察者 public void RemoveObserver(IObserver observer) { observers.Remove(observer); } //发送通知给观察者 public void Notify() { for (int i = 0; i < observers.Count; i++) { observers[i]?.ResponseToNotify(); } } }
玩家脚本:
public class NewPlayer : MonoBehaviour
{
void Update()
{
if (Input.GetKeyDown(KeyCode.J))
{
GameManager.Instance.Notify(); //触发玩家死亡通知
}
}
}
敌人脚本:
public class NewEnemy :MonoBehaviour, IObserver
{
private void Start()
{
GameManager.Instance.AddObserver(this);
}
private void OnDestroy()
{
GameManager.Instance.RemoveObserver(this);
}
public void ResponseToNotify()
{
print($"{gameObject.name}停止移动");
}
}
游戏结束 UI 脚本:
public class NewGameOverUI : MonoBehaviour,IObserver
{
public void ResponseToNotify()
{
print("游戏结束");
}
void Start()
{
GameManager.Instance.AddObserver(this);
}
private void OnDestroy()
{
GameManager.Instance.RemoveObserver(this);
}
}
那么以上就是用接口实现发布-订阅模式的代码。
可以看到,当被观察者 Player 发布死亡通知时,GameManager 会去遍历自身的抽象观察者列表,在它的眼中,无论是敌人还是 UI,全都当作抽象观察者来处理。因此在不修改接口的前提下,观察者与被观察者的代码变动互不影响。被观察者只管将消息发布到 GameManager,然后通知观察者的事全让 GameManager 来做。观察者的其他代码不管怎么改,在 GameManager 眼中始终是抽象的观察者,而且与被观察者也没有任何联系。
需要注意的是:
将观察者添加到观察者列表后,必须在合适的时候把观察者从观察者列表中移除掉!!!
举个例子,如果在当前游戏场景把敌人添加到列表中,然后转入下一个没有敌人的游戏场景。因为 GameManager 相当于全局的对象,此时前一个场景的敌人仍然存在于观察者列表里,我们知道发布通知时会通知观察者列表里的所有对象,可是此时敌人在场景中已经不存在了呀,这时可能就会发生诡异的事情了。
一般来说推荐的组合是:
1)在 Awake/Start 方法中把观察者添加到列表中,在 OnDestroy 方法中把观察者从列表中移除。
2)在 OnEnable 方法中把观察者添加到列表中,在 OnDisable 方法中把观察者从列表中移除。
现在大家回想一下观察者(发布-订阅)模式的实现目的,是不是和 C# 事件的概念差不多啊?
C# 事件的概念大致是:
一个类或者对象中的事件发生后会通知订阅了这个事件的其他类、其他对象。别的类、对象在接收到这个通知之后就会纷纷作出他们各自的响应。
完美契合观察者模式。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。