Unity的事件系统提供了多种使用方式,又和物理碰撞结合在一起,所以同样使用Unity事件处理,就能写出各种各样的风格。很多项目还会自己对事件在进行一次封装,有的还会使用第三方插件。无论是手势插件还是UI插件,都是要建立在事件系统之上的,这些插件都会各自针对事件进行封装。所以,混乱,未知,冲突在所难免。
本文针对Unity2017的版本,对事件系统进行梳理和解读,然后对EventSystem的使用和最佳实践给出一套方案。
首当其冲的就是MonoBehavior上的事件回调,可以参看MonoBehaviour文档。这是一系列的OnMouse开头的回调函数。
OnMouseDown
OnMouseDrag
OnMouseEnter
OnMouseExit
OnMouseOver
OnMouseUp
这个处理方式有以下几个特点:
MonoBehavior所在的GameObject需要有Collider碰撞组件,并且Physics.queriesHitTriggers设置为True,这个在Edit -> Physics Settings -> Physics or Physics2D中设置。
或者MonoBehavior所在的GameObject存在GUIElement。
OnMouse处理函数可以是协程。
GameObject所有MonoBehavior实现OnMouse的函数都会调用。
Collider或GUIElement的层级顺序,会遮挡事件的传递。
按照官方的解释,这是GUI事件的一部分,参看EventFunctions。设计的初衷也是为了GUI服务的。参看ExecutionOrder最后的unity执行流程图,会发现OnMouse事件是一个独立的Input Event。
可以看到,OnMouse事件在,Physics事件之后,Update之前,记住这个顺序,后面会用到。并且,这是引擎本身回调的,就引擎使用而言可以看成是,消息驱动。至于引擎的实现,可是轮询也可以是消息驱动。
public class ExampleClass : MonoBehaviour
{
public void Update()
{
if (Input.GetButtonDown("Fire1"))
{
Debug.Log(Input.mousePosition);
}
}
}
这是官方的例子,Input拥有各种输入设备的数据信息。每一帧不断的检测,查看有没有需要处理的输入信息,利用GameObject本身的层级顺序来控制Update的调用顺序,从而控制了Input的处理顺序。
Input的信息由引擎自己设置的,明显Unity需要实现不同平台的事件处理,然后对Input进行设置。另外有一个InputManager面板用来配置Input相关属性的,在Edit -> Physics Settings -> Input中。
由前面的执行流程图可知,OnMouse事件会在Update之前调用,当然我们也可以在OnMouse中使用Input,这样就变成了消息驱动,而不是轮询了。但这样的缺点是,事件必须由touch或pointer碰撞触发,比如键盘或控制器按钮的事件就没有办法捕获了。
最常见的是在UGUI中,用来进行UI的事件处理和分发。但看其命名,就知道这并不是一个仅仅针对UI的事件系统。参看文档介绍,EventSystem,可以看到:
The Event System is a way of sending events to objects in the application based on input, be it keyboard, mouse, touch, or custom input. The Event System consists of a few components that work together to send events.
EventSystem基于Input,可以对键盘,鼠标,触摸,以及自定义输入进行处理。EventSystem本身是一个管理控制器,核心功能依赖InputModule和Raycaster模块。
用来处理Input数据,管理事件状态,和发送事件给GameObject。
这是一个可替换模块,比如引擎自带了,StandaloneInputModule和TouchInputModule,也可以自定义。
用来捕获哪些GameObject需要执行事件处理。一共有3个种类。
通常,canvas只用了Graphic Raycaster,用来处理UI的事件。所以只要是继承Graphic对象都会自动获得EventSystem事件监听。但官方文档有这样的说明:
If you have a 2d / 3d Raycaster configured in your scene it is easily possible to have non UI elements receive messages from the Input Module. Simply attach a script that implements one of the event interfaces.
也就是说,场景如果添加了2d / 3d Raycaster的射线检测,那么EventSystem也会检测相应的物理元素。(后面会详细介绍这种混合的使用模式)
这是EventSystem默认支持的事件处理回调,当然也可以自定义,就需要扩展自己的Input Module来实现。这里需要强调几点:
IMoveHandler,ISubmitHandler 这样的回调事件可以接受键盘输入,可以在InputManager面板里配置自定义的值,不然就会使用默认值。
键盘事件需要Selectable对象,比如button就是继承自Selectable。所以当button被选中的时候,就会响应键盘事件,比如回车和上下左右方向键,还有空格键。这时候,在button所在GameObject绑定一个实现了ISubmitHandler或IMoveHandler接口的脚本,也会同时触发。
另外,如果我想使用Collider来触发这个键盘事件,就需要使用一个Selectable对象。Collider与Selectable放在一起,并且挂载一个实现了实现了ISubmitHandler或IMoveHandler接口的脚本,当Collider被选中的时候,就可以触发键盘事件了。
这是EventSystem的消息传递系统,UGUI就是使用了这个机制来发送事件消息的。文档写的比较清楚,我们可以自定义自己消息传递。值得注意的有两句话:
The new UI system uses a messaging system designed to replace SendMessage. The messaging system is generic and designed for use not just by the UI system but also by general game code.
这个消息系统是用来替换SendMessage,实际项目估计也很少会用SendMessage,因为效率不高。另外,这是一个通用的消息系统,不仅仅是针对UI的,而是通用的机制。
不过,我仍然觉得这种搜索GameObject查找接口类型调用的方式,没有Action直接订阅调用来的高效。
如果EventSystem仅仅用来处理UI事件的时候,就会与我们自己手动的射线检测产生冲突,Physics.Raycast(ray, out hit)
,原因是显而易见的,因为PhysicsGraphic只会过滤Graphic对象并且有自己的Raycast调用。我们自己手动的Raycast就会穿透过去。
那为什么我们需要自己调用Raycast呢 ?其原因在于,我们使用了Collider碰撞检测,UI系统并不会处理。这时候,我们就需要使用EventSystem的IsPointerOverGameObject()方法来判断,有没有选中了UI元素。具体的解决方案参看我的上一篇文章。
但现在我们知道EventSystem也是可以处理Physics元素的,那么我们就可以放弃手动Raycast,转而让EventSystem统一处理。
首先,我们看一个官方文档的说明 Raycasters。
If multiple Raycasters are used then they will all have casting happen against them and the results will be sorted based on distance to the elements.
当多个Raycaster被使用的时候,结果会按照元素之间的距离排序,然后事件就会按照这个顺序被传递。
在相机上添加Physics2DRaycaster,我这里只需要对Physics2D检测,如果是3D就用Physics3DRaycaster。Physics Raycaster 依赖一个相机,如果没有会自动添加。我挂载在相机上,射线检测就会依赖这个相机。
这里我用在GameCamera上面,当然也可以放在UICamera上面,Physics Raycaster挂载在哪个相机上面,射线就依赖这个相机的Culling Mask。
另外需要注意的是,Physics Raycaster所在的相机层级,也就是Depth,会影响到事件传递的顺序。比如,UI Camera层级高于Game Camera,就会永远先出发UI上的事件。同样,OnMouse事件会默认依赖Main Camera的层级。
给需要碰撞检测的GameObject,添加Collider和EventSystem的事件处理回调接口。注意GameObject的Layer也要与Camera和Raycaster一致,才能正确被检测到。
事件接口实现脚本(图中的Test)需要Collider,事件才能正确回调,并且GameObject和相机的距离决定了Collider的层级,也就是事件阻挡关系。
这样一来,EventSystem的SupportEvents的接口全部被应用到了Physics上面。也就不再需要自己手动去调用射线去检测Physics碰撞了。那么,还隐含着一个事情就是,EventSystem的IsPointerOverGameObject()就无法在判断对UI的点击了。因为现在点击到Physics也会让这个函数返回True。
OnMouse 会先于 EventSystem 触发。因为EventSystem的源码显示,其在Update中去轮询检测处理Input的输入。而OnMouse事件先于Update调用。
OnMouse脚本需要在同一个GameObject上挂载Collider才能检测。EventSystem的脚本会根据子节点的Collider来触发(平行节点不行)。
Rigidbody有个特点,会把子节点所有的Collider统一检测和处理。也就是说,OnMouse脚本与RigidBody在一起就可以检测所有的子节点Collider,而不再需要同级的Collider。而EventSystem的脚本则不依赖于Rigidbody,都可以检测子节点的Collider。
OnMouse依赖于Tag为MainCamera相机的Culling Mask来过滤射线。EventSystem则是依赖挂载Physics Raycaster的相机。
另外,当在有Collider的子节点都挂载OnMouse或EventSystem事件的时候,只会触发一次事件。但在同一个GameObject上挂载多个脚本,就会触发多次。
奇怪的是Unity好像比较推荐消息轮询的方式,就是在Update里面每一帧去检测Input的变化,来处理事件。从引擎的实现方式来看,完全可以采用消息驱动,来暴露API。因为不同的平台肯定都会提供,事件的回调函数。平台自身的事件有些是启动线程轮询的,有些是从底层操作系统拿到的事件回调。当然,消息驱动往往回调函数会在独立的线程里,不在渲染线程就无法调用渲染的API。
不过Unity引擎完全可以提供一组事件的回调,就像OnMouse事件一样。但Input的设计就已经是基于轮询的事件查询机制了。我们可以看到在EventSystem的源码实现里,也是在Update里去轮询Input Module的状态。
protected virtual void Update()
{
// ...
TickModules();
// ....
if (!changedModule && m_CurrentInputModule != null)
m_CurrentInputModule.Process();
}
轮询需要每一帧都去检测判断Input的状态,如果这样的检测散落在代码的各处是非常不好的。难道Unity的本意就是实现一个轮询的插件,在用消息驱动去分发事件 ?于是EventSystem就出现了。
EventSystem的设计和功能,就能够统一所有的事件处理。其提供的事件回调接口也很丰富,基本可以满足各种需求。基于这些接口手势检测也很容易实现。也会受益于未来Unity的优化和改进。
「用起来」