一个基于observer模式的游戏事件分发系统

-潘宏

-2012年11月

-本人水平有限,疏忽错误在所难免,还请各位高手不吝赐教

-email: popyy@netease.com

-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。在一个普通的游戏中,事件的数量是很大的,但该方案保证了增加游戏事件的便利性。它保证了最小的耦合度——事件是完全独立的。理论上,发送者和接收者彼此都不需要了解彼此,这更像是一个广播系统,收听者可以随时打开或关闭收音机,广播电台根本不知道谁在收听广播。


这个系统已经在我们自己的多个项目中被使用,它确实提供给开发者一定的便利性和健壮性。希望你能够从这个系统中有所收获。

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 19
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值