最近去了一趟上海,回来更新健康信息,emmm,7天单人公寓隔离套餐😀。趁着远程办公这段时间,看了看cocos2dx4.0源码比较感兴趣的一些模块,这篇唠唠cocos2dx中的事件分发。ps:顺便一提,是哪个小天才想到的在公寓2楼开KTV。啊,失眠,失眠是我的养料。
问题导读
事件
在应用程序中,由于玩家的输入或者程序内部某个处理逻辑完成,需要等待其他模块针对该行为进行一些响应操作的时候,可以定义一个事件。与一般的直接调用相比,事件可以不用依赖事件响应者的实现,而是预先定义一组事件类型,事件的响应者甚至可以在运行时动态的添加或删除。
订阅者模式
订阅者模式将事件的触发者和响应者分开,事件的触发者只是向一个公共的事件分发器发送一个事件消息,而事件的响应者向事件分发器订阅一个类型的消息来响应事件。如果某个类型的事件没有任何订阅者,则该事件什么也不会发生。
基本元素
- 事件监听器:负责接收事件,并执行预定义的事件处理函数
- 事件分发器:负责发起通知
- 事件对象:记录事件的相关信息
相关文件
列一下涉及到的一些文件
事件机制的工作流程
- 模块B向事件分发器注册一个订阅者listenerb,表明自己需要处理typeA类型的事件消息,listenerb中带有处理事件的一个回调方法地址func
- 模块A在事件发生的时候,会向事件分发器发出类型为typeA的事件消息通知,并传入一些事件参数args
- 事件分发器在接收到事件消息之后,从订阅者列表中查找订阅者listenerb应该响应哪些事件,则触发listenerb的回调函数func,并传入事件参数args
事件系统的特点
- 事件系统使得系统或中间件可以提前预定义一些事件
- 解耦,使得模块之间更加独立
- 一个事件可以对应多个订阅者,多个订阅者可以对一个事件源进行响应,以执行不同职能上的逻辑处理
- 对于性能要求非常高的部分,不适合用事件分发,因为事件分发会做一些查询、排序等操作,会影响实时性能
订阅者
在cocos2dx中,一个订阅者是一个EventListener的子类。每个EventListener由一个回调函数、一个订阅者类型type以及一个listenerID组成。事件分发器能够根据事件的类型找到对应的listenerID,进而找到所有处理该事件的订阅者。
点击查看代码
1 | class CC_DLL EventListener : public Ref |
事件分发器EventDispatcher能根据事件的类型找到对应的listenerID,进而找到所有处理该事件的订阅者。
这里有两种类型:
- type:用来区分EventListener类型
- listenerID:对应一个事件源,可以根据一个事件源的类型找到一个对应的listenerID
type只有7种类型,而listenerID根据自定义的事件类型在数量上更多:
EventListener::Type | listenerID | 描述 |
---|---|---|
TOUCH_ONE_BY_ONE | __cc_touch_one_by_one | 单点触摸 |
TOUCH_ALL_AT_ONCE | __cc_touch_all_at_once | 多点触摸 |
KEYBOARD | __cc_keyboard | 键盘事件 |
MOUSE | __cc_mouse | 鼠标事件 |
ACCELERATION | __cc_acceleration | 重力加速度事件 |
FOCUS | __cc_focus_event | 焦点事件 |
GAME_CONTROLLER | __cc_controller | 游戏手柄事件 |
CUSTOM | eventName | 自定义事件 |
事件类型
一个事件用一个Event的子类描述,它也是事件分发到订阅者时事件源传递给订阅者的参数。Event的子类由一个类型Event::Type和一些事件数据组成。
1 | class CC_DLL Event : public Ref |
Event::Type可以用来查找listenerID,从而将事件分发到正确的订阅者进行处理。Event::Type与listenerID的对应关系如下表所示:
EventListener::Type | listenerID | 描述 |
---|---|---|
TOUCH | cc_touch_one_by_one、cc_touch_all_at_once | 触摸事件对应两个listenerID,触摸事件被特殊处理 |
KEYBOARD | __cc_keyboard | 按键事件 |
MOUSE | __cc_mouse | 鼠标事件 |
ACCELERATION | __cc_acceleration | 重力加速度事件 |
FOCUS | __cc_focus_event | 焦点事件 |
GAME_CONTROLLER | __cc_controller | 游戏手柄事件 |
CUSTOM | ->getEventName() | 自定义事件,以EventName参数作为listenerID |
注册与管理订阅者
EventDispatcher提供了一些注册和管理订阅者的接口,对订阅者的管理大概可以分为3组:
- 注册
- 删除
- 修改
注册订阅者
使用如下方法来注册一个订阅者:
1 | void addEventListenerWithSceneGraphPriority(EventListener* listener, Node* node); |
其中node参数或fixedPriority参数用来决定对同一个事件源的多个订阅者应该按照怎样的顺序分发事件。EventDispatcher分发事件的顺序是:priority<0, scene grapgh(priority="0)," priority>00,>。具体展开为:
- 首先分发优先级小于0的订阅者,按照优先级从小到大的顺序分发
- 然后分发所有与Node元素关联的订阅者,按照关联的Node在UI场景中的层级从前往后分发
- 最后分发优先级大于0的订阅者,按照优先级从小到大的顺序分发
这里需要注意的是:所有与Node关联的订阅者优先级都被设置为0,而开发者无法注册一个优先级为0的订阅者。
删除订阅者
当一个订阅者不再需要接受事件通知,以及该订阅者被销毁的时候,开发者需要向EventDispatcher删除该订阅者,否则将导致订阅者的指针为空,导致野指针操作。
EventDispatcher提供了一组方法用于删除一个或多个订阅者:
1 | void removeEventListener(EventListener* listener); |
removeEventListenersForType
会删除所有类型为listenerType的订阅者,当移除type为EventListener::Type::CUSTOM的订阅者时,会移除所有自定义事件的订阅者- 可以使用
removeCustomEventListeners
单独删除某一类自定义事件类型的订阅者 - 对于与一个Node元素关联的订阅者,它们会在该Node元素被移除的时候自动删除与该Node关联的所有订阅者
修改订阅者
对于场景图优先级
当Node元素的onEnter方法和onExit方法被调用时,它将恢复和暂停所有的动画、计时更新,以及所有与之关联的事件订阅者。对于事件订阅者它使用以下两种方法来关闭和开启订阅者是否接受事件通知:
1 | void pauseEventListenersForTarget(Node* target, bool recursive = false); |
对于固定值优先级
想要开启和关闭使用一个使用优先级定义的订阅者,需要使用setEnable方法:
1 | class CC_DLL EventListener : public Ref |
可以通过setPriority方法来修改订阅者的优先级:
1 | void setPriority(EventListener* listener, int fixedPriority); |
但是,无法动态判断一个订阅者是场景图优先级还是固定值优先级,因为getAssociatedNode是protected方法。
事件的分发
Event定义了一个事件类型,以及处理该事件相关的一些数据,EventDispatcher能够根据Event的类型找到与之匹配的订阅者进行事件的分发。所以,应用程序只需要构造一个适当的Event类
1 | void dispatchEvent(Event* event); |
对于自定义事件,也可以直接传递一个事件名称及一个数据对象,由EventDispatcher帮助构造一个EventCustom对象。
1 | void dispatchCustomEvent(const std::string &eventName, void *optionalUserData = nullptr); |
EventCustom的定义如下:
1 | class CC_DLL EventCustom : public Event |
分发的过程
先看一眼dispatchEvent函数,简要分析一下事件分发的过程。
点击查看代码
1 | void EventDispatcher::dispatchEvent(Event* event) |
- 对_dirtyNodes中的node关联的所有监听器的ID置脏标记
- 记录当前嵌套的深度
- 如果是触摸事件,调用触摸专用的分发函数
- 对当前事件类型对应的listenerID订阅者进行排序
- 函数指针pfnDispatchEventToListeners根据事件ID是否是鼠标类型指向不同的函数
- 执行回调函数对事件进行处理
- 收尾处理
订阅者的排序
订阅者的优先级或者相关联Node的层级可能会随时发生变化,为了保证:
- 事件分发能够按正确的顺序进行
- 尽量避免频繁的重新排序带来分发的性能问题
EventDispatcher采取了一种策略来对订阅者进行排序:
- 在变动时标记,将相应的订阅者保存到一个dirty表
- 在分发前重新排序
- 只对当前事件类型对应的listenerID订阅者进行排序
下表列出影响订阅者重新排序的一些操作:
操作 | Node订阅者 | Priority订阅者 | 描述 |
---|---|---|---|
setLocalZOrder | + | 修改Node的相对层级 | |
setGlobalZOrder | + | 修改Node的全局层级 | |
setPriority | + | 修改Priority订阅者的优先级 | |
forceAddEventListener | + | + | 注册订阅者 |
removeEventListener | + | + | 删除订阅者 |
EventDispatcher只对当前正处理的事件类型对应的订阅者进行排序,具体的sortEventListeners函数代码如下:
1 | void EventDispatcher::sortEventListeners(const EventListener::ListenerID& listenerID) |
sortEventListenersOfFixedPriority
函数就不用细说,就是一个stable_sort。这里展开说一下sortEventListenersOfSceneGraphPriority
函数。
sortEventListenersOfSceneGraphPriority函数
1 | void EventDispatcher::sortEventListenersOfSceneGraphPriority(const EventListener::ListenerID& listenerID, Node* rootNode) |
同固定优先级listener的排序一样,需先获取容器。不同之处在于sceneGraphListeners容器里的监听器优先级都为0,排序需要按照node的顺序。
- 使用_nodePriorityIndex容器记录node的优先级
visitTarget
函数将计算好的node和node优先级存储在_nodePriorityMap- 对sceneGraphListeners进行排序,排序依照每个监听器关联的node在_nodePriorityMap的优先级大小,node优先级大,监听器排序在前
visitTarget函数
简要的说,将计算好的node和node优先级存储在_nodePriorityMap
点击查看代码
1 | void EventDispatcher::visitTarget(Node* node, bool isRootNode) |
- 对node的子节点进行稳定排序,排序后子节点按LocalZOrder从小到大排列
- 对children进行中序遍历,遍历到的node的globalZOrder和node存入_globalZOrderNodeMap中,此时map中的每个node容器中node都是按LocalZOrder从小到大排列
- 对于场景节点,获取场景中所有节点globalZOrder,并对globalZOrder从小到大排序
- 按globalZOrder从小到大的顺序遍历_globalZOrderNodeMap,获取每个node,相同globalZOrder则按先后顺序(LocalZOrder从小到大)遍历
- 按遍历的顺序,将node依次添加到_nodePriorityMap。优先级按node的顺序依次+1。即,越晚绘制的node优先级越高
可以看出,这里的排序是有一定的性能消耗的,所以cocos2dx只对那些被用作关联订阅者的Node的绘制顺序发生变更时才进行标记以重新排序,这也包括Node的某一级子元素被用作关联订阅者的情形。
嵌套事件
事件的分发是可以嵌套的,即在一个嵌套事件触发另外一个事件。
- cocos2dx使用一个_inDispatch来保存当前嵌套的深度,其值为0时表示没有事件在分发
- 每次调用分发事件时,dispatchEvent函数使用一个自动变量DispatchGuard来记录当前分发的深度,DispatchGuard会对_inDispatch执行+1,并在变量生命周期结束减1
- 在一个嵌套事件中执行一个相同的事件可能会导致死循环
1 | class DispatchGuard |
在事件分发中修改订阅者
- 在当前嵌套深度内,任何导致对订阅者优先级的更改不会影响到后面订阅者的分发顺序,因为对订阅者的重新排序是在dispatchEvent中开始执行分发之前执行的
- 对优先级的修改将在下一个嵌套深度内生效,因为新的事件分发会重新对订阅者排序
- 任何对后续订阅者的修改,使得其为不可处理事件时,将会立即生效,这包括通过
setEnable()
、setPaused()
以及setRegistered()
方法修改订阅者,参见如下分发事件的方法
1 | void EventDispatcher::dispatchEventToListeners(EventListenerVector* listeners, const std::function<bool(EventListener*)>& onEvent) |
- 在分发过程中移除一个订阅者并不会直接从订阅者列表移除,而是将其标记为
setRegistered(false)
,使得该订阅者在分发过程中不再生效,而在分发结束时将其移除 - 在分发过程中添加一个订阅者,不会立即生效,而是添加到一个临时的_toAddListeners数组中,在所有事件分发结束后才加入订阅者列表
- 当所有嵌套的事件分发结束,以及_inDispatch变为0时,EventDispatcher开始更新事件分发过程中对订阅者列表的修改:
- 移除那些registered被标记为false的订阅者
- 删除_listenerMap中Vector为空的元素
- 添加_toAddListeners容器的订阅者到_listenerMap
- 添加所有待添加容器_toAddedListeners里的监听器
- 删除待删除容器_toRemovedListeners里的监听器
停止分发事件
在一个事件分发的过程中,优先级较高的订阅者可以选择让事件停止继续传播,这可以通过调用Event的StopPropagation()
来实现。
1 | class CC_DLL Event : public Ref |
事件与Node
除了前文中对Node的一些操作会影响到EventDispatcher对订阅者的分发外,Node类还从另外两个方面影响着事件分发。
暂停与恢复
Node元素除了用来显示场景,还用来执行3种逻辑相关的操作:动画(Action)、更新回调(Schedule)和事件(Event)。在一些状况下,需要让一个Node元素停止处理这些操作,即使它仍然显示在场景中。
cocos2dx提供了两种方法用来暂停与恢复这些操作的执行,分别是pause()
方法和resume()
方法。
1 | void Node::resume() |
- 可以在任何时间阻止或允许任何与该Node相关联的订阅者接受事件通知,但是这会同时影响更新回调和动画的执行
- 如果仅仅只是想控制与事件相关的逻辑,则可以直接调用
resumeEventListenersForTarget()
和pauseEventListenersForTarget()
方法 - 在Node元素内部,当
onEnter()
和onExit()
方法被调用时,会分别调用resume()
和pause()
方法
删除订阅者
Node元素在被释放的时候会自动移除所有与该Node关联的订阅者,除非需要提前移除所有的订阅者,否则可以不用管理订阅者的移除。
1 | Node::~Node() |