木匣子

Web/Game/Programming/Life etc.

Cocos2d-html5 的 GUI 框架分析

本系列的第一篇,以 cocos2d-html5 为例,分析其如何实现基于事件的 GUI 框架。

Cocos2d-x 是当下非常流行的跨平台游戏引擎,其衍生版 Cocos2d-html5 是使用 Javascript 编写并运行于浏览器的 Canvas 的版本,非常符合我的口味。源码可以在这里下载。

经过简单梳理,我将源码中与 GUI 相关的几个类以及主要函数提取并绘制成类图,方便理解:

cocos2d-html5-cls-1

从这张图里可以总结出一些有趣的概念:

场景树(Tree)

GUI 之所以称为 Graphic User Interface,首先它是基于图形的。Cocos2d 作为一个游戏引擎,它本身提供了一套非常完整的图形渲染流程。所有的显示对象都继承至 CCNode ,并且 CCNode 能够嵌套管理其它 CCNode 及其子类,形成树状结构。同层结点的先后顺序决定了渲染的先后顺序。通过深度优先顺序即可以将整个场景绘制出来。本文不深入讨论渲染过程,而是主要分析 GUI 相关架构,所以略去了大部分类。

事件流(Event Flow)

树状结构的好处不仅如此。很多情况下,用户与图形交互时产生的事件(events)需要在目标结点(target)与容器结点(container)间传递。这个概念称为「事件流」。

事件流在现代 Web 技术中非常常见。事件流可以分为三个阶段。第一阶段:捕获(capture),指事件从根结点流向目标结点;第二阶段:目标(target),指事件抵达目标结点处;第三阶段:冒泡(bubbling),指事件从目标结点流向根结点。

有了冒泡机制,能够让发生在目标结点的事件传播到容器结点,从而实现一些高层次的交互效果。例如有一个单选按钮组(radio group),里面有三个单选按钮(radio button)。当鼠标点在其中一个单选按钮上的时候,该点击事件会通过冒泡传播到单选按钮组,可以在单选按钮组上获得鼠标点击事件,从而执行一些逻辑。

而捕获机制则提供了与冒泡相反的效果,可以在目标事件执行前处理一些逻辑。例如在下载按钮或第三方广告(目标结点)的容器上设置侦听器,单用户点击下载或广告的时候,执行额外的逻辑进行统计。捕获机制甚至可以拦截事件从而让目标结点无法获得事件,可以用于设置一些限制条件,不让用户与容器内的控件进行交互。

Cocos2d-html5 的 GUI 框架只实现了触模事件的冒泡,没有实现捕获。(题外话:IE-8 及其以下版本的 IE 浏览器也没有捕获功能)。但这已经足够让它实现大部分 GUI 组件和功能了。

可以看到 UIWidget 作为 Cocos2d-x 的 GUI 的基类,实现了 propagateTouchEvent() 将触模事件递归向上传递。不过对于其它事件,UIWidget 就无能为力了。

事件(Event)

事件对象由一个事件的名字(或者枚举),外加一些额外的参数用于记录事件发生时的一些变量,例如鼠标的位置,哪个键被按下,以及鼠标所在位置的目标结点等信息。此外为了支持事件流,需要记录其生命周期,用于判定是否继续传播(propagate)。

事件在任一时刻被创建,由事件管理器分发出去。必要的时候,可以手动创建事件,并直接传递给目标结点。

事件侦听器(Event Listener)

事件侦听器由事件的名字(或者枚举)以及一个回调函数组成。另外还有用于记录事件优先级的信息。事件侦听器被创建并登记到事件管理器中,用于响应指定的事件。

Cocos2d 的事件侦听器的优先级分为两类,一类是固定优先级,用一个数字表示,数字越小,优先级越高。另一类是结点优先级,为事件侦听器指定一个结点,以这个结点被绘制的顺序表示(globalZOrder),越晚绘制优先级越高。固定优先级总是比结点优先级要高。

事件管理器(Event Manager)

事件管理最重要的两个功能,一是维护事件侦听器列表;二是按一定的优先级派发事件(Dispatch Event)。

事件管理器会在场景有变动的时候更新结点优先级。

事件源(Event Source)

Cocos2d-x 使用 CCInputManager 来侦听来自引擎外的事件,读取设备参数,创建事件并通过 CCEventManager 派发到整个引擎中。

GUI 组件

UIWidget 实现大部分组件需要的功能。在类图中我将比较重要的几个函数列了出来:

setEnabled() / isEnabled()

组件可以启用或禁用,禁用的组件不再响应事件。

setTouchEnabled()

大部分 UI 组件会响应触摸事件,例如按钮、列表等。这些组件在初始化的时候会调用setTouchEnabled(true) 将自己注册到事件管理器上。而像是 UIImage,UIText 之类的静态组件一般不需要响应触摸事件,所以默认没有调用这个函数。

Cocos2d-html5 支持单点和多点触摸,但 UIWidget 只实现了单点触摸的侦听。

onTouchBegan/Moved/Ended()

这几个事件回调函数用于处理触摸事件在 UIWidget 上的逻辑。其中 onTouchBegan() 会返回一个 Boolean 表示触摸事件是否被 Claimed :

如果返回的结果是 true,事件管理器就不再继续检测下一个侦听器,TOUCH_BEGAN 事件派发结束。

如果返回的结果是 false,事件管理器会略过 TOUCH_MOVED 和 TOUCH_ENDED 事件在该结点的响应。这样做估计是为了避免在一个按钮上点击,并在另一个按钮上放开,会触发另一个按钮的事件吧。但是这也导致一些很常见的效果无法实现,例如在几个弹出菜单的按钮上滑动,只有第一个菜单会弹出来。

hitTest()

这个函数返回触摸事件是否发生在组件的覆盖面积内。

在 UIWidget 的 onTouchBegan() 函数中通过检查当前结点的状态(isVisible, isEnabled)和祖先结点的状态(isAncestorsEnabled, isAncestorsVisible)以及判定 hitTest() 来决定该结点是否捕获触摸事件。

大部分组件是矩形的,所以用默认的 hitTest() 即可。如果要实现圆形按钮,可以自己实现 hitTest() 计算触摸事件的位置是否在组件的半径内。甚至是不规则形状都可以根据需要实现。

propagateTouchEvent() / interceptTouchEvent()

这两个函数实现了事件流的冒泡机制,文末会以一个简单的例子看看它们的功能。

addTouchEventListener()

这个函数使得在业务逻辑中可以通过 UI 组件响应触摸事件。例如将回调函数与按钮绑定。不过 UIWidget 的事件管理非常鸡肋,一个结点一性只能维护一个事件侦听器。

布局(Layout)

布局是现代 GUI 中很重要的一部分。不过我暂时还没有深入研究,先略过。

一个例子(example)

考虑这样一个场景:一个按钮在一个滑动列表里面。若鼠标点击拖动滑动列表,里面的内容会跟着移动。若鼠标单击按钮,可以响应按钮事件。但如果试着用鼠标拖动按钮,会发生什么?

比较符合直觉的答案是,按钮失去焦点,而滑动列表和它里面的内容跟着鼠标移动。

来看看 Cocos2d-html5 是怎么处理这个的吧:

cocos2d-html5-sq

当移动鼠标的时候,在冒泡阶段,作为按钮的祖先,滑动列表会计算鼠标按下之后的移动距离。如果超过阈值,会使按钮(target)失去焦点。但是如果你把鼠标移回阈值内,会发现按钮又重获焦点,我觉得后面这个细节设计并不是很好。

本例子在 Cocos2d-html 的测试用例中可以运行观察:
http://www.cocos2d-x.org/html5-samples/samples/tests/index.html
CocoStudio Test > CocoStuio GUI Test > UIListViewTest_Vertical

小结

Cocos2d-html5 采用中央事件管理器来处理事件派发,场景树实现了冒泡机制,UIWidget 的 hitTest 实现了目标挑选。虽然组件本身的事件侦听有点简陋,但基本上可以满足一般需求。

补充

CCProtectedNode

UIWidget 为什么要继承 CCProtectedNode ?这里给出了一点解释:一些自定义组件可能有自己的子元素,例如一个对话框默认有三个按钮,而用户可以在对话框上添加内容——使用 CCNode.addChild(),当移除内容时,使用 CCNode.removeAllChild() 应该保证对话框上的三个按钮不被删除。这就是 CCProtectedNode 被设计出来的目的。

事实上 CCProtectedNode 的子类只有 UIWidget ,完全可以将这部分功能放到 UIWidget 上。不然有点过度设计的嫌疑。