文章目录

  • 前言
    • 概念简介
    • 观察者模式?发布-订阅模式?
  • 观察者(发布-订阅)模式应用
    • 不用设计模式实现
    • 用接口实现
      • 观察者模式代码结构介绍
      • 实现发布-订阅模式
    • 用事件实现
    • 改进
      • 接口法改进方式
      • 事件管理中心

前言

概念简介

先来看一段比较正式的介绍:
观察者模式是软件开发中一种十分常见的设计模式,又被称为发布-订阅(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个需求,我们就不得不在玩家类中添加其他脚本的对象引用,这么做伴随着几个缺点

  1. 玩家类和其他与玩家死亡所关联的类会紧紧地耦合在一起,比如说 Enemy 类原先的 StopMove 方法换了个名字,那么我们不得不回到 Player 类中进行对应的修改。当一个类发生修改会影响到另一个类时,会对项目的维护和更新增添许多麻烦。
  2. 如果增加一个玩家死亡触发的事情,比如玩家死亡后播放一段音效,那么我们还要回到 Player 类对 PlayerDead 方法进行修改。
  3. 玩家类持有其他相关类的引用,这些引用变量要实例化后才能使用,否则会报 NullReferenceException。前面的例子中我是声明 public 成员变量,然后在编辑器面板中通过拖拽的方式进行赋值实例化。假如玩家类有很多其他类的引用,那我们还要在面板中一个个地拖拽。随着项目量的增大,有时候大量的拖拽赋值反而会很麻烦,也会显得很乱。当然有的童鞋可能会想把其他相关类的引用声明成 private,然后通过 GameObject.Find(“xxx”) ,GameObject.FindWithTag(“xxx”) 等方法来找到对应的游戏物体,接着通过 GetComponent 方法去找到脚本的组件将对象实例化,来替代面板上的拖拽。虽然也可以实现目的,但是依然比较麻烦,找游戏物体的过程中也会损耗性能,并且仍然存在前两点提的缺点:耦合性比较强

现在我们用观察者(发布-订阅)模式对代码进行优化。

用接口实现

观察者模式代码结构介绍

先看一种比较标准的观察者模式结构,这里用一种不大标准的 UML 图简要的表示下(用种简要的图来表示观察者模式中的各组成部分之间的联系):

Observer:抽象观察者,提供收到被观察者状态变化的通知时触发的方法,我们先不管这个“抽象”的意思,先来看 Subject。

Subject:抽象主题对象(被观察者),持有抽象观察者的列表,因为一个被观察者可以有很多个观察者,但是观察者的类可能是不同的,为每种观察者定义一个列表显然是麻烦的,那我们要定义一个什么样的列表来容纳各个种类的观察者呢?这时候就要用上“基类”的思想,可以让所有的观察者继承自同一个父类,最后列表里装的是这个父类就行了,而这个父类其实并不需要是具体存在的某一个观察者,我们只需把它定义成抽象的,然后在运行期间让这个抽象的父类去指代某一个具体的观察者(有点像多态,也是面向对象设计原则中 “里氏替换原则” 的应用)。这样我们写代码时只用处理抽象基类,而这个抽象基类具体指代的是哪一个具体的子类,是程序运行时会根据实际情况转化的。这种思想可以用两种方式来实现:抽象类和接口。我推荐用接口来实现,原因如下:

  1. 像一些语言如 C# 和 Java ,只允许单继承,如果我们用抽象类来表示的话,会占用掉唯一的继承位,比如 Unity 挂在物体上的游戏脚本要继承自 MonoBehaviour,这种情况下我们只能用接口,因为一个类可以实现多个接口。很多编程语言都有类似“接口”的相关语法,因此用接口实现观察者模式是比较通用的思想,基本不受各语言语法差异的影响。
  2. 这些观察者的共同点只是收到主题对象状态的通知后要触发某些事情,假如我们用一个 Update 方法来表示触发时执行的方法(观察者状态的更新),那既然每个观察者只要实现各自的 Update 方法就行了,其实我们不妨用“实现接口”来替代“继承父类”。只实现抽象的方法更符合接口的定义。

因此可以定义一个接口作为抽象观察者,让各个观察者去实现这个接口,那么 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# 事件的概念大致是:

一个类或者对象中的事件发生后会通知订阅了这个事件的其他类、其他对象。别的类、对象在接收到这个通知之后就会纷纷作出他们各自的响应

完美契合观察者模式。

观察者模式(结合C#,Unity)相关推荐

  1. 《大话设计模式(C#实现)》(Yanlz+VR云游戏+Unity+SteamVR+云技术+5G+AI+设计模式+GoF+UML+单例模式+观察者模式+抽象工厂+代理模式+框架编程+立钻哥哥++OK+)

    <大话设计模式(C#实现)> 版本 作者 参与者 完成日期 备注 YanlzFramework_GoF_V01_1.0 严立钻 2020.02.10 ##<大话设计模式(C#实现)& ...

  2. Unity之C#——委托与事件,观察者模式,猫和老鼠事例

    委托与事件,观察者模式,猫和老鼠事例 在Unity游戏开发中,我们经常需要在一个类中,调用另一个类中的方法,比如,当玩家进入到某个地方,敌人就开始攻击玩家.这时就需要利用委托与事件,设计观察者模式. ...

  3. Unity下落式音游实现——(3)实现观察者模式

    Unity下落式音游实现--(3)实现观察者模式 前言 本来这一部分是计划放在后面的,但在整理鼓盘敲击判定时优化了原来的部分代码(删掉了一个不必要地函数),顺理成章地出了bug.最后发现是这个函数原会 ...

  4. [Unity] C#使用委托和事件实现Unity消息中心(观察者模式)

    前言: 最近在学习ue的gameplay框架设计和设计模式,再回过头看一下自己过去一年写的unity项目框架(屎山)代码,感慨良多.体会到做游戏写脚本,语言语法只是表层功夫,学会它可以让游戏跑起来.走 ...

  5. Unity游戏框架学习笔记——03基于观察者模式的事件中心

    Unity游戏框架学习笔记--03基于观察者模式的事件中心 基于观察者模式的事件中心 一如既往指路牌:https://www.bilibili.com/video/BV1C441117wU?p=5. ...

  6. Unity游戏设计模式(二)观察者模式(Observer Pattern)

    最近看游戏设计模式,当看到观察者模式时被搞得云里雾里的,什么观察者,被观察者,抽象观察者,抽象被观察者.听着这些词就觉得可怕,其实理解以后还是比较简单的. 当我们玩游戏时,经常会出现一些事件,而这个事 ...

  7. Unity设计模式之观察者模式

    在平常玩游戏的时候会遇到这种情况,以简单的Rpg举例. 勇者击杀了怪物,怪物死了,勇者摆出胜利姿势,系统提示怪物死亡 .如果按照一般逻辑可能会在怪物死亡的方法中去获取Player.Dialog,这样看 ...

  8. Unity 2017 Game Optimization 读书笔记(4)Scripting Strategies Part 4

    1.Avoid Find() and SendMessage() at runtime SendMessage() 方法和 GameObject.Find() 相关的一系列方法都是开销非常大的.Sen ...

  9. PureMVC在Unity游戏开发中的应用

    作为开发人员,我们都想写出优雅的代码,可又苦于自身能力不知该如何下手,而框架的作用正在与能够让你规范的去开发. 之前写Web的时候,总被要求采用MVC架构,的确非常好用,也从来没有质疑过这种架构的好与 ...

最新文章

  1. 一个整数,它加上100后是一个完全平方数,再加上168又是一个完全平方数,请问该数是多少?...
  2. python【蓝桥杯vip练习题库】BASIC-28Huffuman树(贪心 Huffuman)
  3. SCCM2012SP1---配置客户端发现方法和边界组
  4. 神策数据《银行4.0数字化运营体系构建的方法与实践》正式发布
  5. c语言比较麻烦的编程题,C语言编程题,比较简单
  6. VTK:PolyData之MultiBlockMergeFilter
  7. 剑指offer试题(PHP篇一)
  8. 98.set_include_path()
  9. 对MYSQL进行压力测试
  10. 动手设计 CPU(一)—— 各类元件功能表
  11. 如何绕过开机密码开启计算机,win10怎么绕过开机密码,win10如何强制跳过密码
  12. 基于YOLO3的人数统计程序
  13. dota2api的介绍与使用
  14. ps怎么对比原图快捷键_用Photoshop调出图片冷暖色对比
  15. 美国enom域名的优势
  16. TCP/SCTP知识点
  17. SQL Transformation
  18. DevOps元素周期表——1号元素 Gitlab
  19. 一个很有意思的并查集详解
  20. python爬取网易云音乐百强榜单

热门文章

  1. 浅谈Flash Socket通信安全沙箱
  2. c 当前程序的语言,c语言实现获取macos当前的系统语言
  3. Git提交gitlab项目string) [], ParseException +FullyQualifiedErrorId :UnexpectedToken 异常,commit failed
  4. 密码算法详解——AES
  5. HI3559A系统卡死问题-修复
  6. 用于冗余音频数据的RTP负载格式(RFC2198)
  7. 游戏建模:想要做好人物角色模型,先了解人体的构造
  8. E500 TLB miss 及 DSI处理分析(2)
  9. 海尔电视 android,海尔电视怎么投屏
  10. OpenCV 学习笔记(5) 使用opencv打开笔记本摄像头