游戏编程设计模式

【翻译】游戏编程设计模式-Observer

Observer模式

原文连接:http://gameprogrammingpatterns.com/observer.html

你再也找不出一台电脑,里面没有用MVC架构构建的软件。MVC的底层就是Observer 模式。Observer模式应用如此之广泛,以至于java将其纳入核心库(java.util.Observer),C#甚至已经把它固化到语言中(event 关键词)。

Observer是经典GOF设计模式中,最被广泛使用和熟知的模式之一。但是游戏开发界却被诡异地隔绝了,所以对你来说,可能比较陌生。如果你还没有染指过,让我来带你看一个激动人心的例子。

 成就解锁

话说我们要在游戏中加入成就系统。它表现为许多奖章,当玩家完成了一些特定的目标时就会获得。例如“杀掉100个猴怪”,“放下吊桥”,或者“只用一个Dead Weasel(死黄鼠狼?)过关”。

我们有如此多的成就牵扯到游戏中各种各样的行为,所以很难实现的很干净。稍不小心,成就系统的代码就会在某个一男的角落跟我们的功能代码纠缠在一起。的确,“放下吊桥”会与物理引擎联系在一起,但是我们真的希望在碰撞检测算法中的线性代数代码里面调用像unlockFallOffBridge() 这样的具体业务逻辑的函数吗?

通常我们希望游戏中一块功能的代码,能够很好得集中到一起。但难点在于成就系统是被一大堆其他游戏逻辑触发的。我们怎样才能避免成就代码散布在这些逻辑之中呢?

这正是Observer模式要解决的问题。他可以让代码只需要广播发生的事件,而不需要关心谁来接收。

举个例子,我们有一段物理相关的代码,来处理重力和一个光滑平面上锤向尽头的槽。为了实现“放下吊桥”,我们可以直接把成就代码写在那里,但那会显得很丑陋。所以我们换个写法:

所有的这些就是在说:“啊,我不知道谁关心这件事,但是它发生了,顺其自然吧。”

成就系统是自己进行注册的,所以无论何时物理代码抛出这个事件,成就系统都会收到。然后它可以检测落下来的是不是桥。如果是,它就会解锁相应的成就,并播放一些特效和音效。所有的这些都不会影响物理代码。

事实上,我们可以修改一些成就,或者干脆去掉成就系统,都不需要改变一行物理代码。它依然会抛出事件,并不在意已经无人倾听的事实。

它是如何工作的

如果你不知道如何实现这个模式,你可以试着根据前面的描述猜一下,但是为了更简单点,我们迅速过一下。

观察者

我们从一个大鼻子类开始,它很想知道其他对象发生了什么有意思的事情。这些好奇的对象是用这个接口定义的:

所有实现这些接口的类都成了Observer. 在我们的例子中,那是成就系统,所以我们得到了这些:

The subject

这些通知都是有那些被观察的对象发出的。按照GOF的说法,这些对象被叫做“subject”。它有两个工作,第一,他维护了一个观察者列表,他们耐心地等待着被通知。

重要的一点是这个subject暴露了几个公有API,去修改这个列表:

这让外部的代码可以控制谁接收通知,Subject跟Observer沟通但是它不与他们耦合在一起。在我们的例子里,没有一行代码提到成就系统。但是依然通知了成就系统。这就是这个模式的聪明之处。

subject维护一个observer列表,而不是一个observer也很重要。它保证了observer之间相互独立。例如,声音引擎也监听下落事件,这样它可以播放相应的音乐。如果subject只支持一个observer,当这个声音引擎注册的时候,就需要把成就系统反注册掉。

这就意味着两个系统相互排斥,后来者会禁用掉前者,多么无耻!对Observer列表的支持明确了Observer之间不再相互依赖。他们只知道自己是唯一监听这个Subject的对象。

剩下的工作就是subject发送通知了:

可观察的物理引擎

现在我们只需要把这些跟物理引擎挂接起来,这样它就可以发送通知,而且成就系统可以绑定自己去接受通知了。我们已经很接近原始的设计模式秘诀了,继承Subject:

让我们把Subject中的notify()定义为protected类型,这样它的子类Physics类就可以调用它去发送通知了,但是外部的代码不能。与此同时,阿爹到Observer()和removeObserver()定义为public,这样所有能得到物理引擎对象的类,都可以观察它。

现在,每当物理引擎发生了一些重要的事件,都可以像前面的例子那样调用notify()。遍历观察者列表,通知他们。

observer-list

 

很简单,对吗?只是一个类,包含了一个指向一些对象接口的指针。很难相信,一些如此简单的东西正是那些不计其数的程序和应用框架的基石。

但是Observer模式并非没有被指责。当我问起他游戏程序员对Observer模式怎么看时,他们会提出一些不满。如果可以的话,让我们试着说服他们。

“太慢了”

我听到太多了,通常出自那些不真正理解这个模式的程序员之口。他们有一个偏见,那就是所有看起来像设计模式的东西,都会包含一大堆类,一堆间接调用等等,这些都是在浪费CPU时钟。

Observer模式被大大得误解了,因为它身边围绕了许多名声不好的家伙,叫做“时间”,“消息”,甚至“数据绑定”。这些都会拖慢系统(经常是故意的,理由正当的)。他们为维护了像队列这种东西,并且每次发送通知都会动态申请内存。

但是现在你看到这个模式是如何实现的了,你知道这不是事实。发送一个通知只是简单的遍历一个列表,调用一些虚函数。这会比直接静态调用要慢一点点,但是消耗微不足道。

我认为,这个模式最适合在不被频繁调用的代码中使用。所以你可以放心得动态分发。除此之外,再也没有多余的消耗了。我们没有为消息创建对象,没有队列。只是一个同步的间接调用而已。

太快了?

事实上,你需要小心,因为Observer模式是同步的。Subject直接调用observer,这意味着知道所有observer的处理函数返回,Subject才能继续工作。一个很慢的observer可能会阻塞整个subject。

这有点耸人听闻,但这不致命。这只是一个你需要注意的点。经常使用基于事件编程的UI程序员有一个经验只谈:“远离UI线程”。

如果你在处理一个同步的事件,你需要尽快结束并返回,这样才不至于阻塞UI线程。当你有比较慢的工作要做时,把它抛到另外一个线程或者一个工作队列中。

你必须小心地处理observer的线程和显式的锁。如果observer被一个subject持有的锁卡住。那可能造成整个游戏的死锁。在多线程引擎中,你最好使用异步结构Event Queue。

过多的动态内存申请

整个程序员界(包括很多游戏程序员),已经投奔支持垃圾回收的语言了,动态申请内存不再像原来那样可怕了。但是对于性能敏感的软件像游戏,内存申请依然受关注,即时实在托管语言中。动态申请内存会消耗一定的时间,回收内存也一样,即使他们是自动的。

在前面的示例代码中,我使用了一个定长的数组是为了尽量简化。在实际的应用中,observer列表往往是动态创建的,随着observer的添加和删除,列表会相应的增长和缩短。这个内存搅拌器吓住了很多人。

当然,首先应该注意的是,只有当observer产生的时候,会申请内存。发送通知并不会带来内存申请,它只是一次方法调用。如果我们在游戏开始的时候就准备好这些observer,而且不再瞎折腾。内存申请的量是次数是很少的。

如果这仍然是个问题,不怕,我将介绍一个不需要任何动态内存申请就可以添加删除observer的方法。

链接的observer

到目前为止的代码中,Subject持有一个指向Observer指针的列表。Observer类自己没有列表的引用。它只是一个纯虚接口。接口被具体的有状态的类继承,这通常极好的。

但是如果我们在Observer中加入一些小的状态,我们可以用拆分subject中列表到observer中的这种方式来解决内存申请问题。subject不再持有这些指针,observer对象变成了一个链表的节点:

observer-linked-300x75

实现上,首先我们扔掉Subject中的数组,用一个指向Observer列表头的指针代替:

然后我们在Observer中加入一个指向下一个Observer的指针:

我们可以声明Subject为友元类。Subject有添加删除object的API,但是列表是在Observer类中自己管理的。给Subject添加管理列表能力的最简单的方法,就是声明他为友元。
注册一个新的Observer就是把它写入到列表中。我们就简单的把它插在前面:

另一个选择是加到链表的后面。这么做要麻烦一些。Subject必须要么遍历链表找到后面,要么保存另一个tail_指针,永远指向最后一个节点。
添加到链表前面会简单一些,但是也有其他影响。当我们遍历链表去发送通知到Observer的时候,最后添加的Observer最先收到通知。所以如果我们按照这样的顺序注册Observer A、B、C,他们会按照C、B、A这样的顺序接收通知。
理论上,选择那种方法都无所谓。有一个好的Observer规则是:一个Subject中的两个Observer不应该有对顺序关系的依赖。如果需要关注顺序,说明两个Observer耦合的太紧了,迟早会给你带来麻烦。
我们添加清除的方法:

由于我们使用了单链表,我们必须先遍历找到这个observer,然后删除。如果我们用普通数组,也需要做同样的事情。如果我们使用双向链表,每一个observer都有一个指向前一个和后一个的指针。我们就可以在一个确定的时间内删除元素。在实际项目中我会这么做。
剩下的就是发送通知了,像遍历一样简单:

不错把?一个Subject想有多少observer就可以有多少,不需要一点多余的动态内存。注册和反注册就像用简单数组一样快。不过,我们还是牺牲了一个小特性。
一旦我们把Observer当作一个列表的节点,他就只能作为Subject中Observer列表的一部分了。换个说法,一个Observer同时只能观察一个Subject。在更传统的实现里,每一个Subject都有一个相互不依赖的列表,一个observer可以同时在多个列表中。
你也许可以忍受这种限制,我发现一个Subject有多个observer会更常见一些。如果无法忍受,还有一个更复杂的解决方案,仍然不需要动态内存申请。在本章写就有点太长了,但是我可以说个大概,抛砖引玉。

链表节点池

跟前面一样,每一个Subject有一个Observer的链表。但是链表的节点不再是Observer对象本身。他们被划分为一些小“链表节点”对象,里面含有一个指向Observer的指针和和一个指向下一个节点的指针。

observer-nodes-300x120

多个节点可以指向同一个observer,也就意味着一个Observer可以同时存在与多个Subject的列表。我们就找回可以观察多个Subject的能力了。

避免内存申请的思路比较简单:既然所有的节点都拥有相同的大小和类型,你可以预申请一个对象池。这就给了你一个固定大小的节点列表,你可以使用甚至重复使用它,不需要再去进行实际的内存申请。

遗留问题

我想我们已经干掉了三个让人们畏惧这个模式的三个魔鬼。我们可以看到,他简单、快速、在内存管理方面表现良好。但是这是否说明我们在任何时候都应该用Observer模式呢?

现在,问题来了。像所有设计模式一样,Observer模式不是万能的。即使实现地既正确又高效,它也可能不是一个正确的选择。设计模式被误解往往是因为人们使用一个好的设计模式去解决一个错误的问题,结果似的问题更加糟糕。

有两个挑战依然存在,一个是技术层面的,另一个是可维护性层面的。我们先解决技术层面的,因为这个往往比较简单。

销毁Subject和Observer

我们前面的代码介绍是很平滑的,但是忽略了一个问题:当我们销毁Subject或者Observer时会发生什么?如果我们一时不小心销毁了一些Observer, 一些Subject可能依然持有指向它的指针。现在它就是一个指向一块被回收内存的野指针。当这些Subject尝试发送通知的时候,额…我们会迎来一个欢乐时光。

销毁Subject要容易一些,因为在大多数实现中,Observer不会有它的引用。即时如此,把Subject推进内存管理的垃圾箱也会带来一些问题。那些Observer也许仍然希望收到通知,但他们不知道他们永远也收不到了。他们不再是Observer了,但他们依然相信他们是。

你可以用多种方法解决。最简单也是我最看好的一个。当一个Observer销毁的时候,它需要自己从Subject中反注册掉自己。通常Observer知道那些Subject正在被观察,所以一般会在析构函数中加入一个removeObserver的调用。

如果你希望Subject销毁的时候释放对Observer的持有,也很简单。在Subject销毁之前,发送一个最后的“垂死”消息。这样,所有的observer就可以收到,然后进行合适的处理。

人类,即使是在机器公司呆过足够时间的人,也会染上一些自然小毛病——不靠谱。这正是我们为什么发明计算机:他们不会犯一些我们经常犯的错误。

一个更靠谱的答案是让Observer在销毁的时候自己去各个Subject中反注册。如果你在Observer基类中实现这套逻辑,所有用到这个基类的人都不需要关注这一点了。这样会增加一些复杂度,也就是,每个Observer需要维护一个他所观察的Subject列表。最后你得到了一些双向指针。

别担心,我们有垃圾回收

现在的孩子们因为有了支持垃圾回收的语言而自我感觉良好。认为你不再为此担心,因为你不再需要显式地删除任何东西。仔细想想!

想象一下:你有一个UI显示主角信息,如血量等等。当主角显示的时候,为他你创建一个对象。当它关闭的时候,你就忘记了他的存在让垃圾回收去清除它。

每当主角被打了脸(或者其他的,我猜),就发送一个通知。UI 会接收到,并且更新血条。很好,现在主角消失,你又没有反注册Observer,会发生什么呢?

UI再也不会显示了,但是垃圾回收不会清除它,因为主角的Observer还拥有他的引用。每一次UI被加载,我们就会往那个长长的列表上添加一个对象。

在玩家玩游戏的整个过程中,瞎转悠,或者进行战斗,主角不断的发送通知,所有的这些UI都会收到。他们已经不再显示了,但是依然接收通知,更新那些不可见的UI元素,浪费CPU时钟。如果我们在声音播放上也这样,我们会被震碎狗耳朵。

这个是通知系统一个比较常见的问题,它有一个名字:监听失效问题。Subject持有他的监听者的引用。你可以结束掉那些在内存中赖着不走的僵尸UI。这里的教程是关于反注册规则的。

接下来呢?

Observer模式深层次的问题来自它本身的目的。我们使用它,是因为它能帮我们实现两部分代码的解耦。让Subject可以不与Observer直接打交道。

当你专心处理Subject的行为时,这是极好的,因为这时你对其他无关的事情会很反感。如果你在处理物理引擎,你肯定不会希望你的编辑器,或者你的注意力被一大堆成就相关的事情占据。

话说回来,如果你的程序不能工作了,在Observer链的某个地方出了bug,查找原因就会变得很很困难。在显示调用下,很容易找到调用的方法。由于是静态的绑定关系,这对你的IDE来说是小菜一碟。

但是如果这发生在一个observer列表中,找到谁将会被通知的唯一途径,就是在运行时的observer列表中查找。你不是小弄清楚程序结构中的静态原因,而是必须在动态的行为中寻找答案。

我解决这个问题的指导方针很简单。如果你经常需要统筹两边的交互,才能明白一方的程序,那就不要用Observer模式来表达这种关系。直接一些更好。

当你在开发一个大规模程序的时候,你倾向于把你处理的所有模块搞到一起。我们有很多这样的术语:“分离关注点”,“连贯与衔接”还有“模块化”,但是归结起来一句话“把这些搞一起,别跟那些搀和”。

Observer模式非常擅长让一些互不相干的模块之间交互,而不需要耦合到一起。但是在一个浑然一体的模块内部就用处很小。

这就是为什么它非常适合我们的例子:成就系统和物理引擎根本互不相干,通常是不同的人实现的。我们把他们之间的交互最小化,这样在一边工作的程序员就不需要另一边的知识。

现在的Observer

设计模式是在1994年出现的。那个时候,面向对象编程是热点。每一个地球上的程序员都想“30天学会面向对象”,中层管理按照他们创建的类的数量付钱。工程师用继承深度来彰显勇气。

Observer正是在那个时代流行起来,所以就不要奇怪为什么“类泛滥”。但是当今的主流程序员更喜欢函数式编程。实现整个接口去接收通知已经不符合今天的审美了。

它感觉起来很笨重,它的确很笨重。例如,你不能得到一个类,去用不同subject发送的不同通知。

一个更现代化的Observer只会是一个指向方法或者函数的引用。在有一类函数的语言中,特别是有闭包的,这是一个更通用的方法。

例如,C#有一个语言支持的“events”。这样Observer就作为“代理”被注册,在C#中其实就是一个指向方法的引用。在Javascript的Event系统中,Observer可以是那些支持EventListener协议的对象,但是也可以是函数。后者更常用。

如果让我现在设计一个Observer系统,我会用基于函数的实现,而不是基于类。我更倾向于让你注册一个函数指针,而不是一个Observer接口。

未来的Observer

事件系统和其他一些类似于Observer模式的系统在现在很流行。而且已经是老生常谈了。但是如果你用他们做一个稍微大点的应用,你会注意到一些问题。在你Observer中有很多代码看起来很像。像这样:

1、获取一些状态改变的通知。

2、机械地修改一些UI去响应新状态。

就这些,“哎呀,英雄的血已经变成7了?让我们把血条宽度变成70像素吧。”过一会,就会感觉很乏味。长久以来,计算机科学研究者和软件工程师都在想尽办法避免这种工作。他们的努力伴随着一串名字出现:“数据流编程”,“函数反射编程”等等。

虽然有一些成功,但是只限定在一些想音频处理和芯片设计这些场合里,“圣杯”还没有被找到。在此期间,一个不太显眼的进步在悄悄发生。许多现代的应用框架都在用“数据绑定”。

不像其他的一些极端的模型,数据绑定并不试图完全去掉无效代码,也不试图用一个巨大的数据图架构整个程序。它要做的是把一些繁重的工作自动化,像摆放UI元素或者计算UI属性去响应数据变化。

像其他描述性系统,数据绑定可能太慢太复杂不适合在游戏引擎内部使用。但是如果我看不到它在游戏的一些其他对性能要求不高的部分(像UI)中使用,我会感到很奇怪。

现在,经典的Observer依然静静得立在那里。的确,它没有那些充满“函数式”“反射”这类热点技术新鲜,但是它足够的简单并且好用。对我来说,这两者是一个解决方案最重要的两点。