通过全局事件的“注册/派发”机制降低模块间的耦合度

按我个人的理解,“事件”在游戏中就是对象的行为,比如主角会有使用道具的行为、炸弹会有爆炸的行为、敌人可以有被攻击的行为,执行这些行为就是触发了事件,触发了一个事件,必定会导致一个结果,例如玩家点击道具按钮(这是一个事件),触发了主角使用炸弹的事件(由前一个事件触发后导致的结果,同时它也是一个被触发的事件),使用炸弹事件触发了炸弹的爆炸事件,炸弹的爆炸事件触发了敌人被攻击事件。这一系列的事件,被程序猿封装成相应对象中一个个的方法,触发事件,其实就是调用相应的方法。上面举例的一系列行为,简化点描述就是:主角攻击敌人,用伪码描述就是:

player.attack(enemy) {
	if (enemy) {
		enemy.reduceHP();
	}
}

假如上面的代码是在主角模块定义的,那么这个模块就得包含敌人模块,这样才能引用敌人模块中的方法。正因为这样,导致了这两个模块间的耦合度变高,一个模块改变,另一个也得跟着改变,例如敌人降低HP的方法名称不是reduceHP()了,那主角模块也得相应变化。模块间必定得有交互的,那么如何降低模块间的耦合度呢?来看下事件机制。
首先来想象下事件系统长什么样:每个对象都可能要访问这个事件系统,因此用单例模式将它定义为一个全局对象,方便使用。事件肯定有很多个,因此需要一个数据结构来存放不同的事件。同时每个事件都应该有一个唯一的名称(一般是用字符串),用来访问对应的事件。还得有个方法用于注册事件,使得触发事件时可以执行对应的事件逻辑。要执行对应事件的逻辑,当然得有个方法用于触发事件(发送消息)。最后,当某些事件不需要了,得有个方法用于注销事件。一个全局事件系统,大概有这几个属性就够了。用伪码描述,它看起来像这样:

// 事件结构
Event {
	// 事件名
	name: string
	// 事件回调,也就是触发该事件后所需执行的逻辑
	callback: func
	// 事件发送者,可能并不一定用到
	sender: object
	// 事件接收者,也不一定需要
	caller: object
}
// 事件系统
EventSystem {
	// 事件系统在全局中的唯一一个实例
	instance: EventSystem
	// 事件列表,用于存放事件
	eventTable:  [Event]
	// 方法,注册事件
	registerEvent(eventName, callback): func
	// 方法,发送事件,data参数用于传递数据给回调用
	sendEvent(eventName, data): func
	// 方法,注销事件中的回调
	unregisterEvent(eventName, callback): func
}

简单讲下事件列表,JavaScript中,或很多脚本语言中支持字符串做数组下标,因此可以直接用一个数组来存放事件,不支持这个特性的语言,则可以用哈希表来存放事件(大概那种数组的底层实现,也是哈希表,而哈希表的底层实现就是普通数组,只支持数字做下标的),因为数组和哈希表查找元素的时间复杂度都是O(1)。因为事件可能被频繁触发,因此事件的查找效率必须要高。同一个事件可能需要执行多个逻辑,因此事件列表的每个元素都是一个事件数组,用于注册多个回调。

有了事件系统,就可以用它来降低模块间的耦合度了。如果两个模块间需要交互,那么它们可以共同约定一个事件:拿主角的攻击来举例,主角和敌人两个模块共同约定一个事件,假设叫“HeroAttack”,在敌人模块中,注册一个事件用于监听是否触发了该事件,像这样EventSystem.instance.registerEvent("HeroAttack", enemy.reduceHP)。主角模块中,每帧更新时检测主角是否发动了攻击,如果有,则发送一次事件,像这样EventSystem.instance.sendEvent("HeroAttack", data)。如果该事件不需要了,注销即可,像这样EventSystem.instance.unregisterEvent("HeroAttack", enemy.reduceHP)
通过上述方式,两个模块间并不需要包含其它模块,也不必关心其它模块中的具体细节,它们只需共同约定一个事件,就可以产生交互,这样就达到了解耦的目的。

事件系统的具体逻辑是这样的:注册事件的方法是将回调存放到eventTable['eventName'][index]中,假设事件列表是一个数组,事件名做数组下标,数组元素又是一个数组,用来存放同一事件下的不同回调。而发送事件的方法其实就是根据eventName从事件列表中找到对应的事件,然后遍历调用eventTable[eventName]这个数组中的所有回调。这样主角每发一次HeroAttack事件,就会在发送事件那个方法的内部调用对应的enemy.reduceHP这个回调,于是就完成了两个模块间的交互。刚开始看到“发送事件”这个操作时,我以为是要一个对象发送一条消息给另一个对象,后来发现“发消息”其实就是在方法内部直接调用回调,根本不需要两个对象间进行什么交流…

使用方式的话,CocosCreator、Layabox和Egret引擎中都有,这里就不贴代码了,给下它们的文档链接,里面内容不是很多:CocosCreator监听和发射事件Layabox自定义事件Egret事件的执行流程。其中,CocosCreator讲的最简单直接,注册事件用node.on()(node就是一个节点组件),发送事件用node.emit(),注销就是node.off();Layabox举了个示例,代码多了点,注册也是用sp.on()(sp就是一个Laya.Sprite类型的实例),发送用sp.event(),注销用sp.off();Egret详细的讲了个例子,篇幅有点长,它的注册用sp.addEventListener()(sp是一个egret.Sprite类型的实例),发送用sp.dispatchEvent(),注销用sp.removeEventListener()。其实它们的用法都差不多的。

本想再用C语言写个简单示例的,偷个懒就不写了。用C实现的话,你需要一个哈希表,自己实现或找个现成的,其它就没什么难度了,原理按上面的来就行。

事件系统好像有个更官方的说法,是叫观察者模式吧。这里只是根据自己的理解来描述的,有不正确的地方,还请批评指正。?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值