Model-View-Controller架构是你在接触应用程序时不可避免的东西, 而其底层则是Observer Pattern(观察者模式). Observer被广泛使用, Java为其开设核心类库(java.util.Observer), C#则将直接其设计入自身(event关键字).

MVC与其他许多软件一样由Smaltalks在70年代创造. Lispers(LISP的使用者)声称在60年代就有关于此的构想但此处按下不表.

Observer是设计模式四人组的书中介绍的广泛使用且知名的设计模式之一, 但游戏开发的世界在当时还比较封闭, 你可能完全没听说过这个模式. 要是你还活在过去的犄角旮旯里, 我下面会用启发性示例的形式带你速览这一模式.

成就已解锁

假设我们要给游戏增加成就系统. 该系统可能包含十数个在游戏关键节点完成时达成的徽章: “百猴斩”, “坠落大桥”, “过关?武器是死掉的臭鼬?”等.

(译)Observer In GameDesign|游戏设计中的观察者 - 图1

我发誓我这里没有玩梗.

实现这一系统比较棘手, 因为我们有一大堆的不同行为对应的成就. 稍不留神我们的代码里就会四处散布着成就系统的代码段. 显然”坠落大桥”是同物理引擎联系起来的, 但我们真的想要在引擎里的碰撞算法的线性代数中间插上一句对 unlockFallOffBridge 的调用吗?

这是个反问句. 有自尊的物理引擎程序员绝对不会允许我们用走走过场的游戏性破坏他们美妙的数学公式.

我们想要的是所有执掌某一层面的代码整齐地堆放在同一处. 难点在于成就是被游戏中很多不同的层面触发的, 如何做到在不耦合的情况下实现需求呢?

观察者模式就此登场. 它允许一段代码宣布发生了一些有意思的事件, 但并不考虑谁接收到了这一通知.

例如, 我们有一段处理重力的代码, 它追踪哪些身体能在平面上舒展姿势, 哪些又会在不断下坠中迎来死亡结局. 要实现”坠落之桥”的徽章, 我们可以把成就逻辑放在物理逻辑里面, 但那就会是一团乱麻. 取而代之的是这样的方式:

  1. void Physics::updateEntity(Entity& entity){
  2. bool wasOnSurface = entity.isOnSurface();
  3. entity.accelerate(GRAVITY);
  4. entity.update();
  5. if(wasOnSurface && !entity.isOnSurface()){
  6. notify(entity, EVENT_START_FALL);
  7. }
  8. }

上面代码就是在说:”哦我要掉下去了不管有没有人注意反正我消息发出去了剩下的你看着办吧.”

物理引必须决定要发送何种通知, 所以这种方式没有完全解耦. 但在架构上, 虽不至完美, 却臻于优秀.

成就系统对自身进行注册, 所以无论何时物理代码发送了通知成就系统都会接收到. 然后他就能鉴定这个掉下去的身体是否属于我们惨淡的主角.而且不巧他先前所在的高出是在一座桥上, 那么伴随着烟火与小号声, 这一成就就会解锁了. 这一系列的过程不涉及任何物理代码.

实际上, 我们也可以在完全不触碰物理代码的情况下把成就系统剥离开来. 通知照发, 对已经没有人接受通知的实时全然不知.

而当我们永久地移除了成就系统, 就没有地方接受物理引擎发出的通知, 我们可能也需要移除发送通知的代码. 但是随着游戏的进化, 这一点其实很灵活方便.

它是如何工作的

如果你还不会实现观察者模式的话, 也能从上面的描述中了解一二. 不过为了方便起见, 我快速演示一波.

观察者

我们从观察类出发, 它接受其他地方发来的通知, 其由接口定义:

  1. class Observer{
  2. public:
  3. virtual ~Oberver(){}
  4. virtual void onNotify(const Entity& entity, Event event) = 0;
  5. };

onNotyfy()方法的参数由你自行决定. 这是观察者模式, 而不是”观察者-复制粘贴”模式. 典型的参数是发送通知的对象以及一个用来存储其他细节的通用”数据”类型的参数. 如果你是在使用有泛型或是模板类的语言的话, 可以在这里使用, 不过指定特殊的使用场景也是可以接受的. 在这里我是通过硬编码使用游戏的实体类(entity)和一个枚举类(event)来描述发生的事件.

任何实现这一接口的类都会成为一个观察者. 在本例中, 即为成就系统, 我们可以这样写:

  1. class Achievement : public Observer{
  2. public:
  3. virtual void onNotify(const Entity& entity, Event event){
  4. switch(event){
  5. case EVENT_ENTITY_FELL:
  6. if(entity.isHero() && heroIsOnBridge_){
  7. unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
  8. }break;
  9. }
  10. }
  11. private:
  12. void unlock(Achievement achievement){
  13. // Unlock if not already unlocked...
  14. }
  15. bool heroIsOnBridge_;
  16. };

主体

正在被观察的对象唤起接受通知的方法. 在设计模式四人组里, 该对象被叫做”subject”, 它有两项任务: 其一是保存正在等待通知的观察者列表:

  1. class Subject{
  2. private:
  3. Observer* observers_[MAX_OBSERVERS];
  4. int numObservers_;
  5. };

在实际编码中你会使用动态大小的集合来代替定死的数组. 我这么写是为了照顾不会使用C++标准库的人.

重要 的地方在于subject暴露了公共的API来操作这个列表:

  1. class Subject{
  2. public:
  3. void addObserver(Observer* observer){
  4. // add to array
  5. }
  6. void removeObserver(Observer* observer){
  7. // Remove from array
  8. }
  9. // Other stuff
  10. };

这使得外部代码可以控制谁来接受通知. subject和其他观察者交流, 却相互不耦合(译者按尽管subject还是对observer产生了依赖). 在本例中, 没有一行物理引擎代码会涉及成就. 然而它还是能够与成就系统交互. 这就是该模式高明的地方.
另一个重点是subject持有多个观察者.这样做是为了确保观察者之间不会相互耦合. 举个例子, 假设音频引擎为了播放合适的SFX同样需要观察这个掉落成就, 如果subject只能支持单个观察者, 那么当音频引擎注册自己的时候, 同时会删除成就系统的注册.
这意味着这两个系统会以一种坏的方式相互影响, 因为后者会使前者失效.
subject的另一要务是发送通知:

  1. class Subject{
  2. protected:
  3. void notify(const Entity& enity, Event event){
  4. for(int i = 0; i < numObservers_; i++){
  5. observers_[i]->onNotify(entity, event);
  6. }
  7. }
  8. };

这些代码默认观察者不会再自己的 onNotify() 方法里修改观察者列表. 更稳健的代码要么会阻止或者优雅地处理并发修改行为.

可观察的物理

现在我们只需要把以上这些和物理引擎挂钩, 这样它就能发送通知而成就系统也能将自身嵌入其中来接收通知.
我们按照Design Pattern书中的方法来继承Subject:

  1. class Physics:public Subject{
  2. public:
  3. void updateEntity(Entity& entity)
  4. }

这样使得 Subject 中的 notify() 成为受保护的方法, 在继承自物理引擎的类中可以调用并发送通知, 而外部代码中则不能. 同时, addObserver()removeObserver() 是公有方法, 因此任何访问物理系统的代码都可以观察通知.

在实际编码中, 我会在这里避免使用继承, 而是使用包含. 让 Physics 包含一个 Subject 实例. Subject实例会成为一个单独代表”掉落事件”的对象而不是让物理引擎观察自身状态. 观察者可以用像下面这样的代码来注册自己:

  1. physics.entityFell().addObserver(this);

对于我来说, 这在于”观察者”系统和”事件”系统的分别. 前者是你在观察做出了某些事情的物体; 而后者则是你在观察某个代表了某事发生的物体. 译者注这就跟你是 在现场看奥运会开幕 还是 通过今早的报纸获悉此事 一样.

现在只要物理引擎做出了某些值的通知一番的事情, 它就会像上文中的示例那样调用 notify() 方法. 它会遍历观察者列表并一一知会他们.
image.png
是不是很简单呢? 只有一个类在维护一个指向一些接口实例的指针列表. 叫人很难相信如此直白的机制正是支持无数程序和应用框架通信的脊骨.
但观察者模式并非无可非议, 我咨询了其他的游戏程序员对这个模式的看法, 他们给出了一些怨言. 如果这些抱怨属实, 让我们看看该如何处理这些问题.

“太慢了”

我经常从并不了解此模式的程序员口中听到这句话. 他们对于”设计模式”之类的东西有着先入为主的断言, 认为其会引入一堆类和间接引用, 以及其他的会挥霍大量CPU周期的创造性操作.
观察者模式由于经常被人和”事件”,”消息”,甚至”数据绑定”这类词汇联系起来而得到了特别不好的评价, 因为后者的性能并不好(通常是有意为之且事出有因). 他们引入了像队列或者每次通知都要动态分配的行为.

这就是为什么我认为给模式编写好文档是至关重要的事情. 当我们苦于晦涩不堪的术语时, 我们也就失去了清晰简明的交流能力. 你说的是”观察者”, 而一些人耳中听到的则是 “事件”或”消息”, 因为他们要么根本不知道其中的区别要么就是刚好没研究过.

我这本书的目的正在此处. 为了坐实我的论点, 我单独开辟章节用来讲事件和消息: Event Queue.

但现在既然你已经看到了模式的实现你应该已经知道这不是同一回事. 发送通知只是遍历列表并调用虚函数而已.
诚然这样要比静态的分发调用要慢一点点, 但除了最要求性能体验的代码外这种性能损失是可以忽略不计的.

I find this pattern fits best outside of hot code paths anyway, so you can usually afford the dynamic dispatch. 除此之外, 没有可见的资源支出. 我们没有给消息分配对象, 也没有排队等待. 这就只是一个间接的同步方法调用.

太快了?

实际上你要留心观察者模式是同步的. subject直接唤起观察者, 这意味着在所有观察者从他们自己的通知方法返回之前subject都不能恢复自己的工作. 一个缓慢的观察者就能阻塞整个subejct.

听起来可怕但好在实践中这样的情况并非世界末日. 这只是你需要了解的事情. UI程序员已经使用基于事件的编程机制好几十年了, 他们在这个问题上有着历久弥新的格言: “离UI线程远点儿”.

如果你在同步地回应一个事件, 你需要尽快返回控制这样UI才不会阻塞. 而当你有执行得比较慢的任务时, 要把它放在另一个线程或者任务队列里.

但是你也必须注意将观察者和线程以及显示的锁混用的情况. 如果观察者试图获取subejct持有的锁, 这就是个死锁. 在高度线程化的引擎中, 你最好使用Event Queue进行异步通信.

“过多的动态分配”

包括游戏开发者在内的程序员群体都转移到了拥有垃圾回收机制的语言上去了, 动态分配也不再像过去那样是个祸星了. 但对于像游戏这样吃性能的程序, 即使在管理好内存的语言中, 内存分配还是有分量的. 即使是自动进行的动态分配在获得内存时也需要时间.

相较于分配内存, 许多游戏开发者更在乎内存碎片. 当你的游戏需要不崩溃地连续运行好几天才能获得认证的时候, 不断增加的堆内存碎片会阻拦你发售游戏的脚步. 在Object Pool一章中对这一问题进行了更深入的讨论并提供了常用的避障手段.

在上边的代码中, 我使用固定大小的数组只是为了简单. 而在真正的实现代码中, 观察者列表几乎总是动态地分配空间而且会随着观察者的添加和移除而增加和缩小. 这种内存扰动使人心惊.

当然了, 首先需要注意的是观察者只在被织入的时候才会分配内存, 而发送通知根本不需要内存分配—他就只是个方法调用. 如果你在游戏一开始就把观察者设置好了, 在之后的过程中又不会乱操作他们, 这样的分配其实很小.

如果这依然是个问题, 我会进行不使用动态分配的添加和移除观察者的代码实现.

链式观察者

在我们目前看到的代码中,Subject拥有一个指向监视它的每个观察者的指针列表。观察者类本身在这个列表中没有引用。它只是一个纯粹的虚拟接口。接口优于具体的有状态类,因此这通常是一件好事。
但如果我们愿意在观察者中放入一些状态,我们可以通过通过观察者本身来线程化主题列表来解决分配问题。
Subject不再有一个单独的指针集合,观察者对象成为链表中的节点:

image.png
要实现这个,首先我们将删除Subject中的数组,并用一个指向观察者列表头的指针替换它:

  1. class Subject
  2. {
  3. Subject()
  4. : head_(NULL)
  5. {}
  6. // Methods...
  7. private:
  8. Observer* head_;
  9. };

然后我们将扩展Observer,用一个指针指向列表中的下一个观察者:

  1. class Observer
  2. {
  3. friend class Subject;
  4. public:
  5. Observer()
  6. : next_(NULL)
  7. {}
  8. // Other stuff...
  9. private:
  10. Observer* next_;
  11. };

我们还将Subject设为friend class。Subject拥有用于添加和删除观察者的API,但是它将要管理的列表现在在观察者类本身内部。
最简单的方法就是把它当成友元。注册一个新的观察者只是将它连接到列表中。
我们将采取简单的前插法:

  1. void Subject::addObserver(Observer* observer)
  2. {
  3. observer->next_ = head_;
  4. head_ = observer;
  5. }

另一种选择是尾插法。
这样做会增加一些复杂性。
Subject必须要么遍历列表以找到尾节点,要么保持一个单独的尾指针,始终指向最后一个节点。

将它添加到列表的前面更简单,但是有一个副作用。
当我们遍历该列表向每个观察者发送通知时,最先通知的是最近注册的观察者。
如果你按这个顺序注册观察者A B C,他们会按C B A顺序收到通知。

从理论上讲,这无关紧要。
观察同一主题的两个观察者之间应该没有顺序依赖关系,这是良好的观察者规程的原则。
如果顺序真的很重要,那就意味着这两个观察者之间存在某种微妙的联系,最终可能会让你吃不透。

移除代码的实现:

  1. void Subject::removeObserver(Observer* observer)
  2. {
  3. if (head_ == observer)
  4. {
  5. head_ = observer->next_;
  6. observer->next_ = NULL;
  7. return;
  8. }
  9. Observer* current = head_;
  10. while (current != NULL)
  11. {
  12. if (current->next_ == observer)
  13. {
  14. current->next_ = observer->next_;
  15. observer->next_ = NULL;
  16. return;
  17. }
  18. current = current->next_;
  19. }
  20. }

从链表中删除一个节点通常需要一些难看的特殊情况处理来删除第一个节点,就像你在这里看到的那样。 有一种更优雅的解决方案,使用指向指针的指针。 这里我没有这么做,因为至少有一半的人会被我展示的迷惑。 不过,这是一个值得您做的练习:它可以帮助您真正从指针的角度来考虑问题。

因为我们有一个单链表,我们必须遍历它来找到我们要移除的观察者。
如果我们使用常规数组,我们也会做同样的事情。
如果我们使用一个双重链表,其中每个观察者都有一个指向前后观察者的指针,我们可以在常量时间内删除一个观察者。

译者注这里指的是给定某个节点n作为删除的参数的情形, 你可以直接通过n->pre和n->next来操作. O(1) 但对于只知道节点序号而不是节点本身的引用的时候还是需要你去手工遍历. O(n) 虽然作者没说观察者列表是否保持有序, 但我觉得耗费些空间把双向链表给出一个跳表的实现其实也不是不可行, 这样至少免去了手工遍历的麻烦.

如果这是真实的代码,我会这样做。
唯一要做的就是发送一个通知。这很简单:

  1. void Subject::notify(const Entity& entity, Event event)
  2. {
  3. Observer* observer = head_;
  4. while (observer != NULL)
  5. {
  6. observer->onNotify(entity, event);
  7. observer = observer->next_;
  8. }
  9. }

在这里,我们遍历整个列表并通知其中的每个观察者。
这确保了所有的观察者具有同等的优先级,并且彼此独立。
我们可以对其进行调整,以便当观察者收到通知时,它可以返回一个标志,指示主题应该继续遍历列表还是停止。
如果你这样做,你就非常接近于责任链模式

还不错,对吧? 一个Subject可以有任意多的观察者,而不会有任何动态内存。注册和取消注册的速度与简单数组一样快。
不过,我们牺牲了一个小功能。
由于我们使用观察者对象本身作为列表节点,这意味着它只能是一个Subject的观察者列表的一部分。换句话说,观察者一次只能观察一个物体。在更传统的实现中,每个Subject都有自己的独立列表,观察者可以同时出现在多个列表中。
你也许可以忍受这种限制。我发现一个Subject有多个观察者比观察者有多个Subject更常见。
如果这对您来说是一个问题,那么您可以使用另一种更复杂的解决方案,它仍然不需要动态分配。
把它塞进这一章太长了,但我会把它概括出来,让你来填补空白……

链表节点池

如上文所述, 每个subject都持有一个observer链表. 但是这些链表节点并不代表observer. 他们是包含了指向各个observer指针的list node.
image.png
由于不同的指针可以指向同一个observer, 那么单个observer在多个subject持有的链表中出现就成为了可能.于是我们又能同时够观察到多个subject了.

链表有两种主要形式, 学校里教给你的是使用包含了数据的节点, 而在前文中则正相反, 是数据(也就是observer)包含了节点(也就是next指针). 后一种模式叫”侵入性”链表, 因为在列表中使用对象的方式侵入了该对象自身的定义. 这使得侵入性列表损失了灵活性, 却也换来了一些执行效率. 在Linux内核中这种模式很普遍, 因为对于linux来说这样的取舍还是很有意义的.

而你避免动态分配的方式很简单: 既然所有节点都具有相同的大小和类型, 你可以分配节点的对象池 . 这样你就有了一个固定大小的链表节点可供使用, 并且你可以在不触发实际内存分配器的情况下进行对象复用.

仍存的问题

吓跑程序员的Observer模式的三个内鬼现已被我们逐出门外, 而诚如你所见, 这一模式简单高效, 稍加调式就可以与内存管理协作融洽. 但这意味着你应该一直都使用观察者吗?

这是个不同的问题. 观察者模式像其他所有设计模式一样不是万灵药. 即使你高效而正确地去使用它, 也不见得就是最好的解决方案. 设计模式风评被害的原因就是人们常把优秀的模式应用到错误的问题之上而火上浇油.

还剩下两个挑战, 其一是技术方面的, 另一个是维护性方面的. 我们会先解决技术问题, 因为技术总是最简单的.

销毁观察者和主体

我们之前写下的代码凿凿, 却回避了一个重要问题: 要是删除一个subject或者observer会怎样? 如果你随意地使用delete方法移除某个observer, subject可能还仍持有指向它的指针. 这就成了指向为分配内存空间空指针. 当subject试着去发送通知的时候, 你通常不会有什么好果子吃.

不是我鸡蛋里挑骨头, 但我注意到 设计模式 这本书里也没有提到这个问题. 按还是有GC好啊, 比如Java, 充其量你就获得个NPE而已.

销毁subject更容易,因为在大多数实现中,观察者没有任何对它的引用。但即使这样,将subject的内存区发送到内存管理器的回收站也可能会导致一些问题。
这些观察者可能仍然期望在将来收到通知,但他们不知道现在再也不会收到通知了。他们根本不是观察者,真的,他们只是认为自己是。

你可以用几种不同的方法来处理这个问题。最简单的方法就是做我做过的事,然后把赌注押在调用者上面。观察者的工作之一就是是在被删除的对象中注销自己。通常来说,观察者知道它在观察哪些主体,因此通常只需向其析构函数添加一个removeObserver()调用。

常言道, 难的不是去做, 而是记住需要去做.

如果你不想让观察者观察已经不存在subject幽灵, 那么你大可以在subject删除前发送最后一条死亡通知, 告诉观察者自己要被销毁了. 这样观察者就能收到消息并做出合理的响应.

合理的响应: 哀悼、送花、写挽歌等 :D

人们,甚至是那些花了足够的时间与机器为伴,并把它们的某些精确本性融入我身的人,在可靠性方面确实很不可靠。
这就是我们发明电脑的原因:它们不会犯我们经常犯的错误。

更安全的方式是让观察者在被摧毁时自动注销他们自己。
如果你的基本观察者类中实现了该逻辑,那么使用它的人不必每次都记住调用注销方法。
不过,这确实增加了一些复杂性。这意味着每个观察者都需要一张它所观察的对象的列表。你需要使用双向指针。

不必惊慌失措,我有垃圾收集

你们这些用惯了有垃圾回收器的新潮又现代的语言臭小子们现在肯定是十分得意了. 如果你认为你不用显示删除任何东西就可以高枕无忧那就大错特错了!