你朝一台计算机丢一块石头,你不可能砸不到一个用MVC架构写的应用,而在那个架构之下就是观察者模式。观察者模式是那么的普遍以至于Java将其放入核心库(java.util.Observer),而且C#也直接将其加入了语言(关键字event)。观察者模式是应用最广也是最为人熟知的原始的“四人帮”记录的模式,但是游戏开发世界却远离世俗,所以这个模式可能对你来说比较新鲜。以防你还没有离开修道院,让我带你浏览一个激发兴趣的例子。
Achievement Unlocked
假设我们要给游戏添加一个成就系统。它有许多徽章,玩家每次达到特殊的里程碑,像“杀死100只猴怪”,“从桥上跌落”,“只使用一只死黄鼠狼就完成了关卡”,就可以获得相应的徽章。
想要实现得比较简洁是需要技巧的,因为我们有太多的成就,这些成就可能会被各种行为解锁。如果我们不小心成就系统的触手就会缠绕到代码的各个角落。当然,“从桥上跌落”总得结合物理引擎,但是我们真的想在使用线性代数的碰撞检测算法中看到unlockFallOffBridge()的调用吗?
通常,我们喜欢把游戏的跟某一方面相关的代码集中在一起。困难就是成就会被一堆不同的游戏操作激活。如何使成就系统代码与各种游戏操作代码不耦合呢?
这就是观察者模式要解决的问题。它定义一段代码用来表示有趣的事情发生了,而不用关心应该谁来接收这个消息。
例如,我们有一些代码是用来计算物体可能在平滑表面自由游动,或者垂直落下死亡时的重力和运动轨迹。为了实现“从桥上跌落”这个徽章,我们可能就把代码塞到物理代码里了,但是这将会是一片混乱。我们可以这么做:
void Physics::updateEntity(Entity& entity) { bool wasOnSurface = entity.isOnSurface(); entity.accelerate(GRAVITY); entity.update(); if (wasOnSurface && !entity.isOnSurface()) { notify(entity, EVENT_START_FALL); } }
这就是在说:”我不知道谁会关注这个事,但是一个物体的确下坠了。随便谁去处理这个事件。“
成就系统注册之后,无论什么时候物理代码发出一个消息,成就系统都能接受到。然后,他就检测这个下落的物体是不是我们落魄的英雄,还有它在跌落之前所在的位置是否是一座桥。如果是,那么就解锁相应的成就,同时会放出烟花、吹响号角,这整个过程都没有物理代码的参与。
实际上,我们可以改变成就系统的集合或者删掉整个成就系统,都不会碰到一行物理代码。他仍然会发出一系列消息,但是根本不知道已经没有人再接收这些消息了。
How it Works
如果你还不知道如何实现这个模式,你可能会从前面的描述中猜测,但是为了让它变得简单,我会快速讲解。
The Observer
我们会从爱打听什么时候什么有趣的事情发生了的类讲起。这些好奇的类会定义这样的接口:
class Observer { public: virtual ~Observer() {} virtual void onNotify(const Entity& entity, Event event) = 0; };
任何具体的类都会实现这个接口变成一个Observer。在我们的例子中,就是成就系统,所以我们会这样做:
class Achievements : public Observer { public: virtual void onNotify(const Entity& entity, Event event) { switch (event) { case EVENT_ENTITY_FELL: if (entity.isHero() && heroIsOnBridge_) { unlock(ACHIEVEMENT_FELL_OFF_BRIDGE); } break; // Handle other events, and update heroIsOnBridge_... } } private: void unlock(Achievement achievement) { // Unlock if not already unlocked... } bool heroIsOnBridge_; };
The Subject
这个onNotify()函数会被受观察的对象调用。用”四人帮“的说法,受观察的对象称为”Subject“。它有两个工作。第一个,它拥有一个等待它发出消息的Observer的列表:
class Subject { private: Observer* observers_[MAX_OBSERVERS]; int numObservers_; };
最重要的代码是可以修改列表的公共API:
class Subject { public: void addObserver(Observer* observer) { // Add to array... } void removeObserver(Observer* observer) { // Remove from array... } // Other stuff... };
这将允许外部代码控制谁可以接收消息。Subject与Observer进行通信,但是并不耦合。在我们的例子,物理代码不会出现成就系统的代码。但是,它仍然能与成就系统进行通信。这就是这个模式聪明之处。
Subject拥有一个Observer的列表而不是单独一个,也是很重要的。这保证了Observer不会暗中地相互耦合。例如,我们假设音频系统也监视这个下落事件以播放合适的声音。如果Subject只拥有一个Observer,那么当音频系统注册之后,成就系统就注册不了了。
这就意味着这两个系统会互相干涉-以险恶的方式,因为第二个会把第一个顶掉。拥有一个列表就可确保让Observer分别处理。
另一个工作是发出消息:
class Subject { protected: void notify(const Entity& entity, Event event) { for (int i = 0; i < numObservers_; i++) { observers_[i]->onNotify(entity, event); } } // Other stuff... };
Observable Physics
现在,我们只需要把这些挂到物理引擎上,这样当它发送消息后,成就系统就可以接收到了。我们会接近原始的《设计模式》的方式使用继承:
在实际中,我会避免使用继承,而是使用组合,使physics拥有一个Subject的实例
class Physics : public Subject { public: void updateEntity(Entity& entity); };
这要求我们把notify()定义为protected。这样继承类Physics可以调用这个函数发送消息,但是外部代码不可以。同时,addObserver()与removeObserver()是public的,所以所有要监视物理引擎的对象都可以监视它。现在物理引擎做出值得注意的事情,就会调用notify()函数,就像上面那个激发兴趣的例子。那会遍历Observer列表,并通知每一个Observer。
很简单,对吧?就一个类拥有一个Observer的列表指向各Observer实例。很难相信无数子程序与应用框架之间的通信是那么简单直白。
但是这个模式少不了批评者。当我询问其他开发者关于此模式的看法时,他们提出了一些抱怨。让我们看看我们能做些什么来处理他们,如果有的话。
”It is too slow“
我听到很多这个说法,一般都是不太了解这个模式细节的人提出的。他们都有个默认的假设,就是只要是使用了“设计模式”就要用到大量的类,大量的间接操作浪费cpu周期。
观察者模式收到特别差的责备是因为它总是伴随着“event”,“message”,“data binding“这些字眼出现。这些系统里的一些,会减慢速度(一般是故意的,为了某种理由)。因为他们会对消息进行排序或者为每个消息做动态内存分配。
但是,现在你已经看到这个模式是如何实现的,就知道他不是这么回事。发送消息然后遍历Observer列表并调用虚函数是很简单的。当然,这样做是比静态派发函数慢一点,但是这点代价是微不足道的只要不是在性能要求比较严格的情况下。
我发现这个模式非常适合外部热代码,所以你通常都可以使用动态派发。除此之外,几乎没有任何开销。我们不为消息分配内存。我们不会对消息进行排队。他就是对一个函数间接地同步的调用。
It is too fast?
实际上,你要小心一点,因为观察者模式是同步的,这意味着Subject在所有Observer的notify()函数未执行完毕之前,是不会继续干自己的工作的。一个慢速的Observer会阻塞Subject。
这听起来很可怕,但是在实际中,并不是世界末日。它这是一些你必须注意的一点。UI程序员-一直做着基于事件的编程好多年-有一个历史悠久的座右铭:”远离UI线程“。
如果你正在同步地响应一个事件,你需要快速地完成并返回以不使UI线程阻塞。当你有慢速工作要做时,把它放到其他线程或者一个工作队列中去做。
但是你需要小心把Observer与线程还有显示锁搞到一起。如果一个Observer访问了Subject的锁,那么将会导致死锁。在一个高度多线程的引擎中,你最好使用异步通信像消息队列(Event Queue)。
”It does too much dynamic allocation“
整个程序界的程序员-包括一些游戏开发者-已经转向了垃圾回收机制的语言,动态分配内存已经不再受人诟病。但是像性能要求比较高的软件像游戏,内存分配依然重要,即便在垃圾回收机制的语言中。动态分配花费时间,即便是自动分配的。
在之前的例子中,我使用了一个固定大小的数组,为的是实现简单。在实际的实现中,Observer列表大小基本上都是动态的,随着添加删除Observer大小也跟着增大减少。这样增减会惊吓到一些人。
当然,第一个需要注意的是只有当Observer上线才可以分配内存。发出一个消息不会有任何的内存分配-它只是一个方法调用。如果你要在游戏一开始就把Observer添加进去并且不太混乱,那么游戏中的动态分配将是最小的。
如果它仍是一个问题,那么我来给你一个方法可以添加删除Observer不用动态分配内存。
Linked Observers
到现在,我们的Subject拥有一个指向Observer的指针列表。但是Observer没有对这个列表的引用。他就只是个纯虚函数。接口比具体的、有状态的类更好,所以这个一般是更好的选择。
但是,如果我们愿意在Observer中添加一些状态,那么我们就能解决分配问题通过把Observer串起来。这样Subject的列表就不是分散的集合了,而是变成Observer的链表:
为了实现它,我们首先把Observer的数组换成一个指针指向Observer链表头结点:
class Subject { Subject() : head_(NULL) {} // Methods... private: Observer* head_; };
然后,我们为Observer添加一个指向Observer的next指针:
class Observer { friend class Subject; public: Observer() : next_(NULL) {} // Other stuff... private: Observer* next_; };
我们又让Subject作为Observer的友元类。Subject拥有添加和删除Observer的API,Observer列表的管理被放到了Observer类中。最简单的查找列表的方法是使Subject变成友元类。
注册一个Observer就直接把它添加到列表中即可,我们选择简单地方式,把它添加到链表首部:
void Subject::addObserver(Observer* observer) { observer->next_ = head_; head_ = observer; }
另一个方式是把它添加到链表尾部,但是会有点复杂。Subject或者遍历链表找到尾节点或者使用一个tail指针保存尾节点。
添加到头部是很简单,但是有一个副作用。当我们遍历Observer发送消息时,总是最新添加的Observer最先响应消息。所以,如果A,B,C按顺序注册之后,实际中他们会按照C,B,A顺序接受消息。
理论上,这可能不要紧。有一个原则对于观察者模式就是两个Observer不要有顺序上的依赖。如果顺序很重要,这就意味着两个Observer会有耦合,最终会咬到你。
让我们看看删除工作:
void Subject::removeObserver(Observer* observer) { if (head_ == observer) { head_ = observer->next_; observer->next_ = NULL; return; } Observer* current = head_; while (current != NULL) { if (current->next_ == observer) { current->next_ = observer->next_; observer->next_ = NULL; return; } current = current->next_; } }
因为这个链表是单链表,所以不得不遍历来找到要删除的节点。如果我们使用普通数组也还是要做相同的事。如果使用双链表,删除就会使常量时间。如果实际使用,我会这么做。
最后剩下的一个要做的是发消息。这很简单就是遍历个链表:
void Subject::notify(const Entity& entity, Event event) { Observer* observer = head_; while (observer != NULL) { observer->onNotify(entity, event); observer = observer->next_; } }
还不错是不是?一个Subject可以有任意多的Observer,而不用动态分配内存。添加与删除跟使用简单数组时一样快。但是,我们牺牲了一个小功能。
因为将Observer作为链表的节点,这预示着Observer只能作为一个Subject的一部分。也就是说一个Observer同一时间只能监视一个Subject。在每个Subject都有自己列表的更传统的实现中,一个Observer可以同时出现在多个Subject的列表中。
你可能不会介意这个问题。但是我发现一个Subject有多个Observer很普遍。如果这对你来说是个问题,还有另一个复杂的解决方法不用动态分配。它太长了不可能全写进这章,但是我会讲个大概,细节的部分由你来填充。
A pool of list nodes
还是跟上面一样,每一个Subject都有一个Observer的链表。但是,链表节点不再只是Observer了。相反,这些“list node”包含一个指向Observer的指针和一个next指针。
因为多个节点可以指向同一个Observer,这意味着一个Observer可以同时出现在多个Subject的链表中。我们又回到了可以同时一个Observer同时监视多个Subject的情况。
你避免动态分配的方法很简单:因为所有的节点大小相同类型也相同,你可以预先为它们分配一个“对象池”。这样你就有了一个固定大小的节点堆,你可以使用并重复使用他们不需要实际的内存分配器。
Observers Today
《设计模式》出现于1994年。当时,面向对象是非常火的范式。每一个地球上的程序员都想“30天学会面向对象编程”,中层管理人员按照他们写的类的个数给他们发工资。工程师通过他们继承的层级数判断他们的品质。
观察者模式在那个时代非常流行,所以毫不惊讶程序使用了太多的类。但是现代的主流程序员都愿意使用函数式编程。实现一个完整的类只是为了接收一个消息不符合今天的审美。
它感觉起来繁重并且刚性。实际上也是。例如,你不能使用一个类实现对不同的Subject调用不同notify()函数。
一个现代的方法是把Observer作为一个函数的引用。在first-class function的语言中,特别是支持闭包的语言中,这是一个更普遍的方法。
例如,C#语言中的“event”。通过它,你注册的Observer是一个“delegate”,“delegate”是语言的关键字,是对函数的引用。在JavaScript的消息系统中Observer可以是支持特殊的“EventListener”协议的对象,但是,也可以直接是函数。后者几乎是所有人使用的方式。
如果今天是我来设计观察者模式,我会选择function-based而不是class-based。即使在C++中,我也会使用函数指针作为Observer而不是类的实例作为Observer。
Observers Tomorrow
消息系统还有其他类似观察者的模式,在今天难以置信的普遍。它是一个用了很久的模式。如果你使用这个模式写了几个大型程序,你就会注意到几个问题。大量的代码最终都很相似。它通常是这样的:
获取通知一些状态改变了
强制性地修改UI来反映新状态
就这些,“啊,英雄现在的血量是7?让我把血量槽设置成70像素宽。”过一会,你就感到单调乏味。计算机科学学者和软件工程师们一直想试图消除这个乏味已经很长时间了。他们已经提出了一些概念:“dataflow programming”,“functional reactive programming”等。
尽管在有限的领域像音频处理、芯片设计这些领域取得了成功,但是还是不完美。在此期间,一个朴实的方法开始越来越有吸引力。许多最近的应用框架在使用“data binding”。
不像其他激进的模型,data binding不会完全消除强制代码也不会重建整个应用。它要做的是把你调整UI或计算属性来反映哪些值改变这种工作自动化。
像其他声明性系统,数据绑定有点慢并且复杂,不适合引擎核心代码。但是,如果我看不到在不太关键的模块使用它,我将很吃惊。
同时,原始的好的观察者模式还是在这等着我们。没错,它不像其他火热的技术那么激动人心,但是它很简单并且好用。对我来说,这往往是解决方案最重要的两个标准。