游戏编程模式--观察者模式
观察者模式
定义:在对象间定义一种一对多的关系,以便在某对象发生改变时,与它存在依赖关系的所有对象都能收到通知并自动进行更新。
观察者模式的使用非常的广泛,我们熟知的MVC框架的底层就是观察者模式,java甚至直接把它集成到系统库中(java.util.Observer),c#更是直接将它集成在了语言层面(event关键字)。
解锁成就系统
在现代的游戏中通常都会有一个成就系统,当你完成某个任务的时候,会解锁相应的成就。例如:“杀死100个恶魔”,“从桥上掉下”等。但要实现这么一个优雅的成就系统是比较棘手的,设想”从桥上掉下“这个任务,如果我们把它写在物理系统的碰撞检测中是可以工作的,但同时我们也引入了一个很强的耦合,这个耦合会让我们的代码显得丑陋而且不利于后期的更新和维护。这个时候我们就需要观察者模式了。
那观察者模式是如何工作的了?简单的来说,它就是发生变化的对象发送一个消息通知所有对这个消息感兴趣的对象,不用关心具体是谁,而接收这个消息的对象根据消息的内容自动更新自己。一段简陋但有效的代码可以说明这个情况:
void Physics::updateEntity(Entity& entity) { bool wasOnSurface = entity.isOnSurface(); entity.accelerate(GRAVITY); entity.update(); if(wasOnSurface && !entity.isOnSurface()) { notify(entity,EVENT_START_FALL); } }
在这里,当对象开始下落时会发送一个“EVENT_START_FALL"的消息,但系统并不关心谁会接到这个消息以及这个消息的处理细节。成就系统需要注册为”EVENT_START_FALL"消息的接收者,当物体掉落时,成就系统就会接收到消息,然后播放解锁成就的烟花动画。而这一切与物理系统完全解耦。
那观察者模式具体时如何实现的?让我们直接从代码开始:
观察者
class Observer { public: virtual ~Observer() {} virtual void onNotify(const Entity& entity, Event event)=0; };
Observer是观察者类,用于接收消息。onNotify由具体的观察者实现,比如在成就系统中,我们可以定义:
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 event... } } private: void unlock(Achievement achievement) { //unlock achievement } };
被观察者
class Subject { public: void addObserver(Observer* observer) { //add to array } void removeObserver(Observer* observer) { //remove from array.. } protected: void notify(const Entity& entity,Event event) { for(int i=0;i<numObservers_;++i) { observers_[i]->onNotify(entity,event); } } private: Observer* observers_[MAX_OBSERVERS]; int numObservers_; };
被观察者维护了一个观察者列表,同时定义了三个接口,分别是:添加观察者、删除观察者、通知观察者。添加和删除接口可以让外部的代码控制谁可以接收通知,而观察者列表则可以让多个观察者可以接收到同一个通知,而不会隐式的耦合在一起。
可被观察的物理模块
如上所述,如果我们想要一个可被观察的物理系统,则只需要如下实现:
class Physics:public Subject { public: void updateEntity(Entity& entity) { //other stuff... //fall event handle if(!entity.isOnSurface()) { notify(entity,EVENT_ENTITY_FELL); } } }
把notify方法声明为受保护的方法,则物理系统可以调用它而外部代码不能,当某一个事件发生时,notify方法会逐个通知观察者对象。
顾虑
- 性能考虑。很多的游戏开发者会顾虑观察者模式太慢了而避免使用观察者模式,甚至可以说他们对设计模式就有一个默认的假设——设计模式会涉及大量的类并且会引入一些间接和其他形式的CPU时钟消耗。
- 同步处理问题。在notity方法中,我们会逐个的调用观察者的onNotify的方法,这个过程极容易产生一个问题,即某一个观察者onNotify方法阻塞了,导致被观察者也被阻塞。在实践中,可能这个问题表现的可能没那么糟糕,但你必须考虑这个事情。对于一些很慢的操作,可以让它们在另一个工作线程或工作队列中执行,同时你要很小心的处理线程和显式锁,避免死锁的情况出现。
- 动态内存分配问题。在上述例子中,我们使用了一个固定长度的数组来存储观察者,这样显得不够灵活,而且无法应对超过最大数量限制的观察者数量的情况,在实践中可能会使用vector等会动态分配内存的集合,但动态内存分配对某些人来说又是不能接受的,这种情况可以推荐一中做法——使用链表存储观察者。链表的节点存储观察者对象的指针,同时搭配链表节点池可以避免动态内存的分配(你可以自己分配一个固定大小的链表节点池,复用已删除的节点)。
余下的问题
观察者模式虽然简单、快速,而且可以和内存管理很紧密的结合,但和所有的设计模式一样,它不是万能的,即使你准确且高效的实现了它,有时候也不是正确的解决方案。设计模式会遭人诟病,大部分是由于使用一个好的设计模式去处理错误的问题,所以事情会变得很糟糕。
对于观察者模式,还有两个问题:一个是技术性问题,一个是可维护级别。首先我们看技术性的问题。
销毁观察者和被观察者
在例子中,被观察者维护一个观察者列表,如果不小心删除的其中的一个观察者对象,观察者列表中的指针就会指向一个已被删除内存的地址,这个时候如果被观察者先这个指针发送消息,程序行为就会变得不可预料。而对于删除被观察者对象则会相对容易一些,因为观察者没有持有被观察者的引用。但如果不处理这种情况,也容易导致问题,因为被观察者删除之后,观察者也就不再是观察者了,但观察者们却不知道,它们还自以为是。这里对于销毁的情况由几种推荐的做法:
1)销毁被观察者
一种做法是被观察者持有观察者列表,所以可在析构函数中告知观察者取消注册自己;
另一种则是被观察者被删除时,在析构函数发送一个“死亡消息”,让观察者自己处理这个消息即可。
2)销毁观察者
在观察者中维护一个被观察者的引用,然后在析构函数中调用被观察者的removeObserver的方法。
很多现在的编程语言都有GC机制,很多人可能会觉得就不用对象的销毁了,一切由系统托管。但仔细想一想,现代的GC机制回收的时那些没有被其他对象引用的对象,假设我们的使用观察者模式注册了消息接收功能,这个时候被观察者即持有了一个观察者的引用,如果我们在某一个场景中把某个对象移除了,但并没有注销观察者(即被观察者中还持有引用),这个时候观察者不会被GC,还是会不断的接收消息,消耗CPU的时钟,比如角色转到下一个场景后,如果不注销本场景的UI界面元素的观察者,则这些UI元素还是会不断的接收的角色发送的消息,而且如果处理过程中还在做其它的事情,比如播放音乐,就会发生明显的错误问题。
这个就是通知系统中常见的问题:失效的观察者。对于这个问题,我们学到的经验就是及时的删除观察者。
调试困难
观察者模式很好的把两处代码进行了解耦,使我们能更专注的解决当前模块的问题。但同时也引入了一个问题,就是如果代码中有很多的bug,需要我们调试的时候,梳理其中的信息流将会变得异常的困难。通过一个显式的耦合,我们很容易理清方法调用的逻辑,而对于观察者模式,观察者的添加和删除时动态的行为,这使得我们不得不去梳理这些动态的、命令式的行为。
对于这个问题的处理也非常的简单,如果你需要经常去理解程序的逻辑而去了解模块间的调用顺序,那就不要用观察者模式,而是用其它更好的方法。
在现代程序中,会涉及很多的模块,我们通常使用“关注点分离”、“内聚和耦合”、“模块化”的手段把不想关的功能模块分离。观察者模式非常适用于不相关的模块间通信,不适合单个紧凑的模块内部的通信。