-潘宏
-2012年11月
-本人水平有限,疏忽错误在所难免,还请各位高手不吝赐教
-email: [email protected]
-weibo.com/panhong101
在游戏引擎以及产品开发中,程序员需要让大量的系统模块相互进行通信。在大多数时候,模块的数量是巨大的。在最坏的情况下,N个相互通信的模块需要N*(N-1)/2种依赖关系。就算是最简单的游戏产品,N的数目也是可观的。
如果设计者放任这种相互依赖的模块关系于不顾,整个系统将高度耦合,代码牵一发而动全身。对一个模块的修改,将导致多个模块无法工作,必须进行相应调整。这将导致程序员的焦躁情绪,从而很容易让局面难以控制。 此外,还需要忍受长时间的重新编译、长时间加班修补bug。这些是OOP设计模式(指GoF的经典著作所描述的)所要努力避免的。
一个设计合理、高效的系统,需要对模块进行解耦——使得模块间的依赖关系减少。同时开发者将获得更好的代码维护性和扩展性,并节省大量的编译时间。本文描述了一种基于observer模式的事件分发系统(Event Dispatch System,EDS),用来进行游戏模块的相互通信。在一般的情况下,N个模块的相互通讯只需要N种依赖关系(借助于EDS)。
我们通过描述基本知识来逐步构成我们对系统的深层理解。
Observer模式
Observer模式也叫publish/subscribe模式或listener模式,它实际上是一种利用callback方法进行通信的机制。依照GoF的描述,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。如下图所示,
我把上面的图帮你解释一下(如果你是该模式的专家,可以忽略解释):一个Subject关联任意多个Observer,这在多态下也一样,一个子类ConcreteSubject也可以关联任意多个Observer。而Observer也自成体系,但Subject体系一般只操作基类Observer。开发者可以通过Subject::Attach和Subject::Detach将目标Observer绑定到它所关心的Subject身上,当Subject产生任何变化需要通知Observer的时候,Subject::Notify方法会通知所有被绑定的Observer。同时,得到通知的Observer都可以访问到它们所关心的Subject,得到它们相应的状态。
通俗地说,一个模块A如果想在模块B进入某种状态的时候,做一些事情,我们需要知道B进入状态的时机,并在该时机通知A。这引出我们所说的通知模型概念,在软件开发中,有两种通知模型被使用:Poll和Callback。
Poll通知模型
一种最简单的通知方法叫作轮询(poll)——模块A需要在每个CPU周期中查询B是否进入了该状态——这其实是一种主动的自我通知。比如:
class B { public: //... bool isFinished() const { return m_isFinished; } void cycle() { //... } private: // ... bool m_isFinished; }; class A { public: void poll( B& b ) { if( b.isFinished() ) { // Do something } } }; int main() { A a; B b; for(;;) { b.cycle(); a.poll( b ); // ... } return 0; }
A类的class object a,在每一个主循环周期中通过A::poll对B类的class object b进行状态查询,当发现B::isFinished返回true,也就是条件满足,则进行相应的操作。
这就是状态轮询惯用法——简单、直接,也有些野蛮!它有两个致命的缺点:
1)轮询代码是查询方主动调用,查询方不知道对方何时完成,因此如果需要对查询结果进行快速反应,需要在每个周期中进行查询调用,大量的CPU周期被浪费在无效的查询指令中。
2)当模块的数量提高,这种轮询方式将产生代码急剧膨胀,每两个有依赖关系的模块,都需要在周期中进行相互查询。这样的代码将难以甚至无法维护。
因此,轮询一般只在规模不大的几个模块间调用,并且模块状态不能太多。
Callback通知模型
和轮询相对的,是让B主动的通知A,A被动等待B的完成并通知,在这段等待时间中A可以继续做其他的事情,不用浪费CPU周期无谓地查询目标状态,这叫作callback——回调,也是observer模式的实现策略。很明显,callback克服了poll模型的两个致命缺点,但代码相对复杂一些。在C++ OOP中,我们一般有三种方式来实现callback。
1 使用静态成员函数
class A { public: static void onBFinished( void* param ) { A* _this = static_cast<A*>(param); // ... Do something using _this } void cycle() { // Do something... } }; typedef void (*FPtrOnBFinished)( void* param ); class B { public: void setCallback( FPtrOnBFinished callback, void* param ) { m_callback = callback; m_param = param; } void cycle() { // Finished in some cases m_callback(m_param); // Notify the subscriber } private: FPtrOnBFinished m_callback; void* m_param; }; int main() { A a; B b; b.setCallback( A::onBFinished, &a ); for(;;) { a.cycle(); b.cycle(); } return 0; }
可以看到B存储了A的静态方法地址以及A class object指针,该指针将作为参数传递给A的静态方法,以决定到底是谁——哪个对象对该事件感兴趣,并通知它。
该指针实际上充当了非静态成员函数中this指针的作用——我们给static方法显示地放置了一个“this指针”——实参param,编译器不需要、也不可能干预static成员函数的this指针安插。这可以通过A::onBFinished看出,实参param被转换为A*,然后针对该指针(也就是subscriber本身——希望得到通知的观察者)进行相应操作。
很容易想到,class B的subscriber可以是多个,这也是observer的设计初衷。你可以扩展B的类接口来增加、减少subscriber和它们相应的callback。将它们妥善保存在一个容器里面,并在B::cycle中通过迭代通知每一个subscriber。
回调方法中的形参类型void*保证了subscriber对于publisher是类型无关的,只要它能够在相应的callback方法中被正确的cast成真实的类型,就可以了。这提供了很大的灵活性,是这种回调方法的中心思想。另一方面,这种方式也会导致潜在的类型安全问题——这要求客户程序员必须保证subscriber类型的一致性。如果把一个错误的类型对象作为param给了publisher,程序仍可以经过编译,但在运行期subscriber的callback方法中的cast会以失败告终,这通常是危险的。
2 使用指向成员函数的指针
class A { public: void onBFinished(); void cycle() { // Do something... } }; typedef void (A::*onBFinished)(); class B { public: void setCallback( onBFinished callback, A* a ) { m_callback = callback; m_a = a; } void cycle() { // ... // Finished in some cases (m_a->*m_callback)(); } private: onBFinished m_callback; A* m_a; }; int main() { A a; B b; b.setCallback( &A::onBFinished, &a ); for(;;) { a.cycle(); b.cycle(); } return 0; }
static member function被替换成了指向成员函数的指针。
这对类型安全性起到了一定促进作用:在B::cycle中对指向成员函数的指针进行调用的时候,编译器会进行type-checking,从而将错误的类型匹配拦截在编译期。
另一方面,由于必须指定指向成员函数指针的类作用域,比如A::,B需要知道所有subscriber的类型。这在一定程度上增加了类的耦合性:系统每增加一个subscriber类,就需要在publisher类中定义相应的callback typedef,这将带来维护开销,并增加重编译时间。而这个问题在上面的static方法中是不存在的。
下面的方法则解决了类型安全和类耦合两个问题(这也是observer模式的一种正规实现方式)。
3 使用Listener类
建立一个监听器基类,使用多态方法进行通知。
class Listener { public: virtual ~Listener() {} virtual void onSomeEventReceive() = 0; }; class A : public Listener { public: virtual void onSomeEventReceive() { //...Do something } }; class B { public: void setListener( Listener* l ) { m_listener = l; } void cycle() { // Finished in some cases m_listener->onSomeEventReceive(); } private: Listener* m_listener; }; int main() { A a; B b; b.setListener( &a ); for(;;) { b->cycle(); } return 0; }
这种方法中,B不需要知道listener的具体类,它只需处理listener抽象类。系统通过多态来高效地复用基类代码。这很好地解决了上述的类型安全和类耦合两个问题:
a)至少在B的层面上,B处理的必须是listener类,B只调用它的方法,编译器保证了这一点。
b)增加任何一个新的listener子类,B都不需要关心,它只处理listener类。B的代码不需要重新编译。
该方法有缺点吗?当然。缺点主要在于它使用了C++的virtual机制,这是C++的性能落后于C的主要开销之一——通过增加间阶层来提高设计抽象性而带来的开销。但,这样的性能损失相对于该结构所带来的设计优势而言,是可以接受甚至忽略的。
通过以上三个方法,我们看到了每种回调方式的优缺点。后面我们会使用listener方式来构建我们的事件系统,因为对于我们的系统来说,它更易于维护、扩展,更符合OOP精神。
游戏事件系统
现在我们回到游戏消息系统主题中来。首先我们已经决定使用回调方式来进行消息通知,更进一步来说,我们打算采用listener方案。接下来我们要考虑我们如何定义模块结构。
为了避免文章开头所说的N个模块的最差依赖关系N*(N-1)/2,同时为了保证每两个模块之间都可以进行通信,我们的事件系统将采用一个中介模块,这实际上是一个mediator模式(如果对mediator模式感兴趣,请参考GoF的书)的简化版本。整个系统结构如图所示:
A到H每个模块都和mediator通信,Mediator会把相应的消息发给相应的模块。这样,N个模块的依赖关系只有N个——每个模块只和mediator相依赖。
为了设计出这个系统,我们开始结合observer模式思考,在listener例子里面,我们把subscriber作为一个listener指针存在于publisher中,我们说这个subscriber对这个publisher要发布的内容感兴趣——它等待着publisher的一个特定的条件产生,从而通知subscriber,它会做它该做的事情。然而,在那个例子中,两个模块是直接依赖的,并不适用于我们现在设计的这个系统,我们要实现的系统将提供一种“多路分派”——可以有多个subscriber接收同一个事件。在该系统中,需要把“特定条件的产生”变成一个实体用来在模块和mediator之间传递来进行间接依赖,一个很自然的方案就是command模式( 如果对command模式感兴趣,请参考GoF的书),它封装着通信接收者需要完成的工作信息,将工作延迟到真正的目标。
这里,我不想牵扯太多的模式,因为这样会导致大量的题外细节。因此,我们暂时不使用标准的command模式,改用一个简单的事件结构体:
struct GameEvent
{
int m_eventID;
int m_params[6];
};
一个整型事件ID和若干个自定义整型参数。对,没错,只有整型参数。那我们如何传递其他类型的东西呢?比如一个字符串什么的。一般来说,需要采用辅助数据结构,这和你的游戏引擎结构有很大的关系。比如用一个字符串池,用m_param来传递池索引等等类似的方案。
一个game event的数据类似这样:
#define GAME_EVENT_ID_CLICK_BUTTON 1000
GameEvent event;
event.m_eventID = GAME_EVENT_ID_CLICK_BUTTON;
event.m_params[0] = 15;
event.m_params[1] = 2666;
上面的event可能在游戏中解释为:一个按下按钮的事件,按下的按钮索引为15,这将产生ID为2666的脚本执行。具体的事件参数由接收它的模块来进行解释。
有了这样一个结构,我们就可以把它和对它感兴趣的模块进行关联。比如对GAME_EVENT_ID_CLICK_BUTTON事件感兴趣的是模块A、C、E、F。则在mediator中可以建立一个关联数组保存下面的关联:
{GAME_EVENT_ID_CLICK_BUTTON, {A, C, E, F}}
每当一个publisher发送了一个GAME_EVENT_ID_CLICK_BUTTON事件给mediator,它都会把该消息转发给{A, C, E, F}模块组。这样,在系统运行的某个时刻,mediator将保存一个映射表,描述了注册的游戏事件以及要响应的模块,比如:
{GAME_EVENT_ID_1, {A, B}}
{GAME_EVENT_ID_5, {C}}
{GAME_EVENT_ID_6, {A, M}}
{GAME_EVENT_ID_11, {F, K, M, S}}
{GAME_EVENT_ID_99, {D, B, P, T, X, Z}}
...
系统结构如下所示:
对该系统的实现
接下来我打算给该系统一个简单实现。给模块设计的类为:
中介模块Mediator: EventDispatcher
通信模块A-H: EventListener
EventDispatcher* dispatcher; class EventDispatcher { public: EventDispatcher() { dispatcher = this; } void dispatchEvent( const GameEvent& event ) { ListenerGroup::iterator it = m_listeners.find( event.m_eventID ); if( it != m_listeners.end() ) { list< EventListener* >& listeners = it->second; for( list< EventListener* >::iterator it2 = listeners.begin(); it2 != listeners.end(); ++it2 ) { it2->handleEvent( event ); } } } private: typedef map< int, list< EventListener* > > ListenerGroup; private: ListenerGroup m_listeners; friend class EventListener; }; class EventListener { public: virtual ~EventListener() {} void addListener( int eventID, EventListener* listener ) { EventDispatcher::ListenerGroup::iterator it = dispatcher->m_listeners.find( eventID ); if( it == dispatcher->m_listeners.end() ) { list<EventListener*> l; l.insert( l.begin, listener ); dispatcher->m_listeners.insert( make_pair(eventID, l ) ); } else { list<EventListener>::iterator it2; for( it2 = it->second.begin(); it2 != it->second.end(); ++it2 ) { if( *it2 == listener ) continue; } it->second.insert( it->second.begin(), listener ); } } void removeListener( int eventID, EventListener* listener ) { EventDispatcher::ListenerGroup::iterator it = dispatcher->m_listeners.find( eventID ); if( it != dispatcher->m_listeners.end() ) { for( list<EventListener*>::iterator it2 = it->second.begin(); it2 != it->second.end(); ++it2 ) { if( *it2 == listener ) { it->second.erase(it2); break; } } if( it->second.empty() ) dispatch->m_listeners.erase(it); } } virtual void handleEvent( const GameEvent& event ) = 0; private: }; class A : public EventListener { public: A() { addListener( 5, this ); } virtual ~A() { removeListener( 5, this ); } virtual void handleEvent( const GameEvent& event ) { assert( event.m_eventID == 5 ); assert( event.m_params[0] = 1 ); assert( event.m_params[1] = 2 ); } }; int main() { EventDispatcher eventDispatcher; A a; GameEvent event; event.m_eventID = 5; event.m_params[0] = 1; event.m_params[1] = 2; eventDispatcher.dispatchEvent( event ); return 0; }
(为了阅读方便,我把类的方法实现直接和声明放到了一起,在实际的开发中,除了需要inline的方法,应该将实现和声明分离)
EventDispatch类即mediator保存了事件-模块映射表m_listeners。EventDispatch::dispatchEvent方法用来处理相应事件。
EventListener类是一个监听器基类,任何一个想成为subscriber的模块都该继承于它。两个方法
EventListener::addListener
EventListener::removeListener
用来将subscriber增加到事件-模块映射表中,或将模块从表中删除。
EventListener::handleEvent方法是一个纯虚方法,subscriber子类应该实现该方法来处理它感兴趣的事件。比如class A就是一个subscriber,它将自己和ID为5的事件绑定——表明它对该事件感兴趣。当在main中通过EventDispatcher发送了一个5号事件的时候,A::handleEvent就会被callback并将5号事件发送给它进行处理。这里的模块传递为:
main模块 -> event dispatcher(mediator) -> class A object
改进方向
以上实现只是一个基本结构——它完成基本的功能,但要让该事件系统达到工程级别并使用,还需要进一步的努力。以下几个改进留给感兴趣的读者当作练习完成。
1 将事件分发方法改为异步方式
这个改进是必须的!问题在于我们在EventDispatcher::dispatchEvent中使用了STL的容器迭代器进行事件分发,当一个事件被EventListener的子类模块通过EventListener::handleEvent进行处理的时候,可能会产生另一个event(比如在A::handleEvent里面再用EventDispatch分发一个事件),这会导致EventDispatcher::dispatchEvent中的迭代器被重复使用,STL不允许这种情况产生,程序崩溃!
解决方案是在EventDispatcher::dispatchEvent中只把event存入一个pending容器中而不调用EventListener::handleEvent。然后给EventDispatcher安插一个flushEvents的接口,在里面统一调用EventListener::handleEvent来处理pending events然后清除掉它们。
2 将EventDispatcher设计为singleton模式
EventDispatcher应该只有一个,并应该被各个通信模块方便地访问。这意味着把它写成一个singleton模式是理所当然。就这么做吧!(关于singleton模式,请参考GoF的书)
3 增加通道
为了在不同的游戏界面中采用不同的事件表布局,可以给监听器组增加不同的通道,简单来说就是把EventDispatcher::m_listeners变成一个数组
EventDispatcher::m_listeners[N]
然后增加一个变量
EventDispatcher::m_curChannel
来表示当前使用的是数组的哪个维度——哪个通道。这样,我们就能够保留不同的界面事件布局而不用频繁的重新初始化。
4 增加Listener的优先级属性
可以给listener增加优先级,这样在EventDispatcher::dispatchEvent中对listener进行调用的时候将按照listener的优先级调用。这种特性可以用来实现一个很酷的功能——消息独占模块:把一个模块的优先级调成最大,然后给该listener增加一个“事件处理后中断后续listener处理”标志,可以让dispatcher在EventDispatcher::dispatchEvent的循环中只调用该优先级最大的listener的事件处理方法,然后终止掉循环。一个标准的模态对话框就可以用该特性实现。
总结
以上我们循序渐进地实现了一个基本的事件分发系统,它实现了对模块的解耦——通过一个中介模块event dispatcher。在一个普通的游戏中,事件的数量是很大的,但该方案保证了增加游戏事件的便利性。它保证了最小的耦合度——事件是完全独立的。理论上,发送者和接收者彼此都不需要了解彼此,这更像是一个广播系统,收听者可以随时打开或关闭收音机,广播电台根本不知道谁在收听广播。
这个系统已经在我们自己的多个项目中被使用,它确实提供给开发者一定的便利性和健壮性。希望你能够从这个系统中有所收获。