本文译自Robert Nystorm的 Game Programming Patterns 一书的Design Patterns Revisited章节。今后也会挑选一些其他有趣的章节翻译,当然我也不是什么专业译者,做个参考即可啦:D
观察者模式
你朝着电脑砸了一块板砖一定会砸到一个以MVC模式构建的应用,这就潜在着观察者模式。
观察者模式如此之强,JAVA把它放在自己的核心类库之下java.util.Observer
而C#将其整合进了该语言当中(event
关键字)。
MVC由Smalltalkers初创于70年代。Lispers可能称这一时间是在60年代,有点扯远了。
观察者模式是广泛应用和被人熟知的“设计模式四人组”之一,但是在游戏开发领域可能对你来说还是个新鲜事,你可能已经太久没复习了,现来过一过例子吧。
成就已解锁
就好比你在做一个游戏的成就系统吧,玩家玩的过程中可以获得一堆游戏徽章,比如 百猴斩、桥之坠落或者 黄鼠狼通关法。
没有第二层意思,别猜了
我们有一堆行为可以解锁成就,想要实现成就系统并不简单。一个不小心,我们成就系统的代码就有可能遍布代码库的各个角落。行吧,桥之坠落 大概和物理引擎是挂钩的,但是我们真的需要在求解碰撞的线性方程中突然来调用一句 unlockFallOffBridge()
吗?
这只是打个比方,一个自重的物理引擎程序员怎么可能就直接让你那走个过场的游戏代码污染他精妙的数学计算呢。
而我们一直想要做的事情是把反映游戏某一方面的代码都堆放在一起。难点在于成就会被不同的游戏层面触发,如何才能使成就系统不用和这些层面耦合在一起呢?
这就是观察者模式在做的事情了--让一段代码宣称某时某地发生了某事,而不去管谁接收到了这一提醒。
例如,要做些物理效果的编码让重力和轨道起作用,这样人物摊在平台上的时候会掉下去摔死。这个桥之坠落成就的实现不耦合代码的话可以这样写:
void Physics::updateEntity(Entity& entity){
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if(wasOnSurface && !entity.isOnSurface()){
notify(entity, EVENT_START_FAIL);
}
}
以上代码就是说:“呃,不知道你们在不在意,反正这个东西掉下去了。该干啥干啥吧。”
物理引擎确实需要决定哪些提醒是要被发送的,所以这样也不算是完全的解耦。但在架构上,我们只是要弄得更好一点就可以了而不是尽善尽美。
成就系统会注册自身,以便每当掌管物理的代码发送通知时,成就系统都会收到通知。于是它就能检查刚刚掉下去的东西是不是我们悲催的主角,而如果他先前所在的地方不巧是座桥,这一并不愉快的古典力学遭遇将会伴着彩带、焰火和小号的欢呼为玩家解锁一枚徽章。全程不需要物理代码参与进来。
实际上,我们可以改变成就的集合或是在不涉及物理代码的情况下剔除成就系统。提醒还是照常发送,只是没人接受了而已。
当然啦,如果永久地去除成就系统就没东西去监听物理引擎的提醒,我们可能也要把发出提醒的代码删掉。但是在游戏开发的过程中有点灵活性总是很好的。
观察者模式的运作方式
如果你之前还不知道怎么实现这个模式,现在也差不多猜到该怎么做了,不过为了你的方便起见,我会迅速过一遍代码。
The obeserver
我会从nosy类开始做起,这个包打听类想要知道其他对象做了什么趣事,由如下接口定义:
class Observer{
public:
virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
};
onNotify()的参数你可以自由设置,这就是为什么他叫 “观察者” 模式 而不是 “观察别人写好的代码然后粘贴到你的游戏” 模式。典型的参数是发送提醒的对象和一个包含了杂七杂八的细节的通用的数据类型。
如果你用到了具有模板和泛型的语言,这里就用得上了,但你也可以视具体情况而定。这里我只是硬编码了游戏的entity和一个描述事件的枚举类。
任何实现它的实体类都会成为观察者,在我们的例子中就是成就系统了,于是就有了如下操作:
class Achivements: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
被观察的对象调用了这个提醒方法,在设计模式中,这个对象被称作 subject。 它有两个职能:保持正在等待的观察者列表
class Subject{
private:
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
}
在实际代码中,你经常会用到是动态的集合,这里介绍一下以防有不知道C++标准库的人
重要的地方是 subject 暴露了可以编辑那个列表的公共API:
class Subject{
public:
void addObserver(Observer* observer){
// Add to array
}
void removeObserver(Observer* observer){
// Remove from array
}
// Other stuff
}
这就允许了外部代码去控制谁收到了通知(提醒),subject 和 observers 交流但并不耦合。在这个例子中没有物理代码提到成就系统,但它还是可以和成就系统沟通。这就是该模式聪明的地方。
同样重要的是subject拥有的是一个列表而非单个对象,这确保了观察者们之间不会耦合。比如在观察所有事件以控制合适音声播放的音声引擎里,如果subject仅支持一个observer,当音声引擎注册的时候,我们的成就系统就会下线。
这意味着两个系统会以糟糕的方式相互作用,因为两者互斥。
subject 的另一个职能是发送通知:
class Subject{
protected:
void notify(const Entity& entity, Event event){
for (int i = 0; i < numObservers_; i++){
observers_[i]->onNotify(entity, event);
}
}
// Other stuff
}
注意这里的代码假设观察者们不会在它们的onNotify()方法中修改这个列表。一个更稳健的实现方式要么禁止要么良好地处理这样的并发修改。
可观察的物理引擎
现在只需要将上面代码和物理引擎挂钩就可以让他发送通知给成就系统了。
然后让我们把目光放到原始的观察者模式和继承Subject上:
class Physics:public Subject{
public:
void updateEntity(Entity& entity);
};
这个代码使得Subject
中的notify()
收到保护,继承Physics的类可以发送通知但外面的就不行。同时,addObserver()
和 removeObserver()
是公有的,因此任何用得上物理引擎的地方都可以观察。
实际的编码中,我不会在这里使用继承,而是让Physics拥有一个Subject实例。subject会成为一个与之分离的“坠落事件”对象而不是区观察物理引擎自身。观察者可以使用下面的方式注册自身:
physics.entityFell().addObserver(this);
对我来说,这就是“观察者”系统和“事件”系统的区别。前者是你观察到一些有趣的事情;对于后者则是观察到一个表示发生的有趣的事情的对象。
现在当物理引擎做了一些值得标记的事情,他就像上面的例子那样调用notify()
遍历观察者列表。
挺简单的是吧?仅仅一个类就持有所有实现某类接口的实例指针。很难相信者其实就是无数程序和框架的交流骨架。
但是观察者模式也不是没有差评。我咨询了一些游戏开发者对于该模式的看法,于是就有了如下的怨言,让我们来看看该如何解决它们。
“太慢啦”
这种声音通常来自于其实并不了解观察者模式细节的程序员们。他们总是假定任何“设计模式”必须要导入一大堆类、引用和挥霍CPU性能的方法。
观察者模式的有些差评是因为它用到诸如“事件”、“消息”甚至是“数据绑定”这样可疑的字眼。涉及这些系统有些确实很慢(通常事出有因或故意为之)。他们使用队列或者在每次通知时动态分配内存。
这就是我觉得为啥给设计模式写文档是重要的。当我们被一堆术语搞糊涂的时候,就不能简练有效地沟通。你觉得,“观察者”和一些人听到的“事件”、“消息传递”,要么他们压根就不知道这些东西的分别,要么就是他们正好没有读过相关的文档。
本书要做的事情也是如此,为了这个我专门有一章介绍事件和消息:Event Queue
但你都看到这里了,心里对观察者模式也该有点数了。发送一个通知其实就是遍历列表和调用几个虚函数。这也确实是比直接调用慢一点,但这样的牺牲除了在关注性能的代码是外微不足道的。
有点过快了?
事实上你得小心点因为观察者模式是同步地进行(synchronously)。subject直接唤起observers,意味着直到所有观察者从他们的通知方法返回前subject都在挂着,一个慢点的observer就会阻塞整个subject。
听起来唬人,不过实践上并非如此。这只是你需要留意的地方罢了。在UI编程--已经使用事件机制几十年了--有句老话:“离UI线程远点”。
你要是给一个事件同步响应,你手脚得麻利点这样UI才不会锁。当你有一项工作是做得很慢的,把它推给其他线程或是工作队列里吧。
然而对待混合有线程编程和显示锁的观察者你还是得小心点,要是一个观察者试图subject持有的锁,那游戏就死锁了。在高度线程化的游戏引擎里,你最好使用事件队列(Event Queue)进行异步通信。
未完待续