8

《Unity3D高级编程之进阶主程》第四章,UI(三) - 剖析UGUI源码中的输入与事件模块

 3 years ago
source link: http://www.luzexi.com/2018/07/26/Unity3D%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B%E4%B9%8B%E8%BF%9B%E9%98%B6%E4%B8%BB%E7%A8%8B-UI3
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

《Unity3D高级编程之进阶主程》第四章,UI(三) - 剖析UGUI源码中的输入与事件模块

前面几节对 NGUI 和 UGUI 进行了比较,也阐述了一些UGUI的组件的用途和原理,不过这些都仅仅只是停留在系统的表面,对系统深层次的原理和实现方式我们并不了解,接下来我们就从 UGUI 的源码入手,逐步为揭开它的神秘面纱。

在了解整个 UGUI 源码之前,我们在此篇来从输入事件下手,对 UGUI 源码中输入事件模块进行剖析。

UGUI源码剖析

UGUI的源码是Unity3D官方公开的,这里我们来剖析的是 UGUI 在 Unity2017 中的公开源码。

ugui文件夹

上图为 UGUI 内核源码的文件夹结构图。它把UGUI分成了三块,输入事件,动画,核心渲染。

其中动画部分相对比较简单,用了tween补间动画的形式,对颜色,位置,大小做了渐进的操作。tween的原理是在启动一个协程,在协程里对元素的属性渐进式的修改,除了修改属性数值,tween还有多种曲线可以选择,比如内番曲线,外翻曲线等,一个数值从起点到终点的过程可以由曲线来控制。举个例子,数字从 0 到 100 的变化,在3秒里完成,如果是线性的话,则在第2秒时的数值,应该是

    (100 - 0) * (2f/3f) = 200f/3f = 66.666

而如果使用内番曲线就不是这个结果了,不过它们最终都会到达100,只是过程有点‘曲折’罢了,曲线也体现了动画的‘有趣’。

下面我们重点来剖析下输入事件和核心渲染这两块。

输入事件源码

输入事件源码的文件结构图如下:

ugui事件文件夹结构

图中,UGUI 把输入事件模块有四部分,事件数据模块,输入事件捕获模块,射线碰撞检测模块,事件逻辑处理及回调模块。我们把每部分的核心源码都拉出来分析一下。

事件数据模块

事件数据模块部分对整个事件系统的作用来说,它主要定义并且存储了事件发生时的位置、和事件对应的物体,事件的位移大小,触发事件的输入类型,以及事件的设备信息等。事件数据模块在逻辑上没有做过多的内容,而主要为了获取数据,提供数据服务。

它有三个类 PointerEventData、AxisEventData、BaseEventData,分别为点位事件数据类,滚轮事件数据类,事件基础数据类。PointerEventData和AxisEventData 继承自 BaseEventData,且 AxisEventData 的代码量非常少,因为它只需要提供滚轮的方向信息。即如下:

namespace UnityEngine.EventSystems
{
    public class AxisEventData : BaseEventData
    {
        //移动方向
        public Vector2 moveVector { get; set; }
        public MoveDirection moveDir { get; set; }

        public AxisEventData(EventSystem eventSystem)
            : base(eventSystem)
        {
            moveVector = Vector2.zero;
            moveDir = MoveDirection.None;
        }
    }
}

BaseEventData 定义了几个常用的接口,其子类 PointerEventData 是最常用的事件数据,我们来看看它是如何编写的,代码量并不多基本全是数据定义:


public class PointerEventData : BaseEventData
{
    public GameObject pointerEnter { get; set; }

    // 接收OnPointerDown事件的物体
    private GameObject m_PointerPress;
    // 上一下接收OnPointerDown事件的物体
    public GameObject lastPress { get; private set; }
    // 接收按下事件的无法响应处理的物体
    public GameObject rawPointerPress { get; set; }
    // 接收OnDrag事件的物体
    public GameObject pointerDrag { get; set; }

    public RaycastResult pointerCurrentRaycast { get; set; }
    public RaycastResult pointerPressRaycast { get; set; }

    public List<GameObject> hovered = new List<GameObject>();

    public bool eligibleForClick { get; set; }

    public int pointerId { get; set; }

    // 鼠标或触摸时的点位
    public Vector2 position { get; set; }
    // 滚轮的移速
    public Vector2 delta { get; set; }
    // 按下时的点位
    public Vector2 pressPosition { get; set; }
    
    // 为双击服务的上次点击时间
    public float clickTime { get; set; }
    // 为双击服务的点击次数
    public int clickCount { get; set; }

    public Vector2 scrollDelta { get; set; }
    public bool useDragThreshold { get; set; }
    public bool dragging { get; set; }

    public InputButton button { get; set; }
}

上述代码中为数据类的核心类 PointerEventData,它存储了大部分的事件系统逻辑需要的数据,包括按下时的位置,松开与按下的时间差,拖动的位移差,点击到的物体等等,承载了所有输入事件需要的数据。事件数据模块的意义所在便是存储数据并为逻辑部分做好准备。

事件数据模块,主要作用为在各种事件发生时,为事件逻辑做好数据工作。

输入事件捕获模块源码

缺图UGUI的时间捕获模块文件夹结构

输入事件捕获模块由四个类组成,BaseInputModule,PointerInputModule,StandaloneInputModule,TouchInputModule。

BaseInputModule 是抽象(abstract)基类,提供必须的空接口和基本变量。

PointerInputModule 继承了BaseInputModule,并且在他基础上扩展了关于点位的输入逻辑,也增加了输入的类型和状态。

StandaloneInputModule 和 TouchInputModule 又继承了 PointerInputModule,它们从父类开始延展向不同的方向。

StandaloneInputModule 向标准键盘鼠标输入方向拓展,而 TouchInputModule 向触控板输入方向拓展。

下面我们来看看他们的核心部分的代码:


/// <summary>
/// 处理所有的鼠标事件
/// </summary>
protected void ProcessMouseEvent(int id)
{
    var mouseData = GetMousePointerEventData(id);
    var leftButtonData = mouseData.GetButtonState(PointerEventData.InputButton.Left).eventData;

    // Process the first mouse button fully
    // 处理鼠标左键相关的事件
    ProcessMousePress(leftButtonData);
    ProcessMove(leftButtonData.buttonData);
    ProcessDrag(leftButtonData.buttonData);

    // Now process right / middle clicks
    // 处理鼠标右键和中建的点击事件
    ProcessMousePress(mouseData.GetButtonState(PointerEventData.InputButton.Right).eventData);
    ProcessDrag(mouseData.GetButtonState(PointerEventData.InputButton.Right).eventData.buttonData);
    ProcessMousePress(mouseData.GetButtonState(PointerEventData.InputButton.Middle).eventData);
    ProcessDrag(mouseData.GetButtonState(PointerEventData.InputButton.Middle).eventData.buttonData);

    //滚轮事件处理
    if (!Mathf.Approximately(leftButtonData.buttonData.scrollDelta.sqrMagnitude, 0.0f))
    {
        var scrollHandler = ExecuteEvents.GetEventHandler<IScrollHandler>(leftButtonData.buttonData.pointerCurrentRaycast.gameObject);
        ExecuteEvents.ExecuteHierarchy(scrollHandler, leftButtonData.buttonData, ExecuteEvents.scrollHandler);
    }
}

以上代码为 StandaloneInputModule 的主函数 ProcessMouseEvent,它从鼠标键盘输入事件上扩展了输入的逻辑,处理了鼠标的按下,移动,滚轮,拖拽的操作事件。其中比较重要的函数为 ProcessMousePress、ProcessMove、ProcessDrag 这三个函数,我们来重点看下他们处理的内容。


/// <summary>
/// Process the current mouse press.
/// 处理鼠标按下事件
/// </summary>
protected void ProcessMousePress(MouseButtonEventData data)
{
    var pointerEvent = data.buttonData;
    var currentOverGo = pointerEvent.pointerCurrentRaycast.gameObject;

    // PointerDown notification
    // 按下通知
    if (data.PressedThisFrame())
    {
        pointerEvent.eligibleForClick = true;
        pointerEvent.delta = Vector2.zero;
        pointerEvent.dragging = false;
        pointerEvent.useDragThreshold = true;
        pointerEvent.pressPosition = pointerEvent.position;
        pointerEvent.pointerPressRaycast = pointerEvent.pointerCurrentRaycast;

        DeselectIfSelectionChanged(currentOverGo, pointerEvent);

        // 搜索元件中按下事件的句柄,并执行按下事件句柄
        var newPressed = ExecuteEvents.ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.pointerDownHandler);

        // didnt find a press handler... search for a click handler
        // 搜索后找不到句柄,就设置一个自己的
        if (newPressed == null)
            newPressed = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);

        // Debug.Log("Pressed: " + newPressed);

        float time = Time.unscaledTime;

        if (newPressed == pointerEvent.lastPress)
        {
            var diffTime = time - pointerEvent.clickTime;
            if (diffTime < 0.3f)
                ++pointerEvent.clickCount;
            else
                pointerEvent.clickCount = 1;

            pointerEvent.clickTime = time;
        }
        else
        {
            pointerEvent.clickCount = 1;
        }

        pointerEvent.pointerPress = newPressed;
        pointerEvent.rawPointerPress = currentOverGo;

        pointerEvent.clickTime = time;

        // Save the drag handler as well
        // 保存拖拽信息
        pointerEvent.pointerDrag = ExecuteEvents.GetEventHandler<IDragHandler>(currentOverGo);

        // 执行拖拽启动事件句柄
        if (pointerEvent.pointerDrag != null)
            ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.initializePotentialDrag);
    }

    // PointerUp notification
    // 抬起通知
    if (data.ReleasedThisFrame())
    {
        //执行抬起事件的句柄
        // Debug.Log("Executing pressup on: " + pointer.pointerPress);
        ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerUpHandler);

        // Debug.Log("KeyCode: " + pointer.eventData.keyCode);

        var pointerUpHandler = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);

        // 如果抬起时与按下时为同一个元素,那就是点击
        if (pointerEvent.pointerPress == pointerUpHandler && pointerEvent.eligibleForClick)
        {
            ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerClickHandler);
        }
        // 否则也可能是拖拽的释放
        else if (pointerEvent.pointerDrag != null && pointerEvent.dragging)
        {
            ExecuteEvents.ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.dropHandler);
        }

        pointerEvent.eligibleForClick = false;
        pointerEvent.pointerPress = null;
        pointerEvent.rawPointerPress = null;

        // 如果正在拖拽则抬起事件等于拖拽结束事件
        if (pointerEvent.pointerDrag != null && pointerEvent.dragging)
            ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.endDragHandler);

        pointerEvent.dragging = false;
        pointerEvent.pointerDrag = null;
        
        // 如果当前接收事件的物体和事件的刚开始的物体不一致,则对两个物体做进和出的事件处理
        if (currentOverGo != pointerEvent.pointerEnter)
        {
            HandlePointerExitAndEnter(pointerEvent, null);
            HandlePointerExitAndEnter(pointerEvent, currentOverGo);
        }
    }
}

上面展示了 ProcessMousePress 处理鼠标按下事件的代码,虽然比较多但并不复杂,我在代码上做了详尽的注解。其实它不仅仅处理的是按下的操作,也同时处理鼠标抬起的操作,以及处理了拖拽启动和拖拽抬起与结束的事件。在调用处理相关句柄的前后,事件数据都会被保存在 pointerEvent 中,然后被传递给业务层中设置的输入事件句柄。

我们再来看看 ProcessDrag 拖拽处理函数:


protected virtual void ProcessDrag(PointerEventData pointerEvent)
{
    bool moving = pointerEvent.IsPointerMoving();

    // 如果已经在移动,且还没开始拖拽启动事件,则调用拖拽启动句柄,并设置拖拽中标记为true
    if (moving && pointerEvent.pointerDrag != null
        && !pointerEvent.dragging
        && ShouldStartDrag(pointerEvent.pressPosition, pointerEvent.position, eventSystem.pixelDragThreshold, pointerEvent.useDragThreshold))
    {
        ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.beginDragHandler);
        pointerEvent.dragging = true;
    }

    // 拖拽时的句柄处理
    if (pointerEvent.dragging && moving && pointerEvent.pointerDrag != null)
    {
        // 如果按下的物体和拖拽的物体不是同一个则视为抬起拖拽,并清除前面按下时的标记
        if (pointerEvent.pointerPress != pointerEvent.pointerDrag)
        {
            ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerUpHandler);

            pointerEvent.eligibleForClick = false;
            pointerEvent.pointerPress = null;
            pointerEvent.rawPointerPress = null;
        }

        // 执行拖拽中句柄
        ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.dragHandler);
    }
}

上面展示了 ProcessDrag 拖拽句柄处理函数,与ProcessMousePress类似对拖拽事件逻辑做了判断,包括拖拽开始事件处理,判断结束拖拽事件,以及拖拽句柄的调用。

ProcessMove 则相对简单点,每帧都会直接调用处理句柄。


protected virtual void ProcessMove(PointerEventData pointerEvent)
{
    var targetGO = pointerEvent.pointerCurrentRaycast.gameObject;
    HandlePointerExitAndEnter(pointerEvent, targetGO);
}

除了鼠标事件外,我们再来看看触屏事件的处理方式,即 TouchInputModule 的核心函数。如下:


/// <summary>
/// Process all touch events.
/// 处理所有触屏事件
/// </summary>
private void ProcessTouchEvents()
{
    for (int i = 0; i < Input.touchCount; ++i)
    {
        Touch input = Input.GetTouch(i);

        bool released;
        bool pressed;
        var pointer = GetTouchPointerEventData(input, out pressed, out released);

        ProcessTouchPress(pointer, pressed, released);

        if (!released)
        {
            ProcessMove(pointer);
            ProcessDrag(pointer);
        }
        else
            RemovePointerData(pointer);
    }
}

从代码中我们看到 ProcessMove 和 ProcessDrag 与前面鼠标事件处理时一样的,只是按下的时间处理不同,而且它对每个触点都做了相同的操作处理。其实 ProcessTouchPress 和鼠标按下处理函数 ProcessMousePress 非常相似,可以说基本上一模一样,只是传入时的数据类型不同而已,由于篇幅有限这里不再重复展示长串代码。

这里大量用到了 ExecuteEvents.ExecuteHierarchy,ExecuteEvents.Execute 之类的静态函数来执行句柄,它是怎么工作的呢,其实很简单:


private static readonly List<Transform> s_InternalTransformList = new List<Transform>(30);

public static GameObject ExecuteHierarchy<T>(GameObject root, BaseEventData eventData, EventFunction<T> callbackFunction) where T : IEventSystemHandler
{
    // 获取物体的所有父节点,包括它自己
    GetEventChain(root, s_InternalTransformList);

    for (var i = 0; i < s_InternalTransformList.Count; i++)
    {
        var transform = s_InternalTransformList[i];
        // 对每个父节点包括自己依次执行句柄响应
        if (Execute(transform.gameObject, eventData, callbackFunction))
            return transform.gameObject;
    }
    return null;
}

解释下上述代码,对所有父节点都调用句柄函数。也就是说,当前节点的事件会通知给它上面的父节点。

到这里我们基本清楚事件处理的基本逻辑了,下面我们来看看碰撞测试模块是如何运作的射线碰撞检测模块源码

射线碰撞检测模块主要工作是从摄像机的屏幕位置上,做射线碰撞检测并获取碰撞结果,把结果返回给事件处理逻辑类,交由事件处理模块处理事件。

射线碰撞检测模块主要为3个类,分别作用于 2D射线碰撞检测,3D射线碰撞检测,GraphicRaycaster图形射线碰撞测试。

2D、3D射线碰撞测试相对比较简单,用射线的形式做碰撞测试,区别在2D碰撞结果里预留了2D的层级次序以便在后面的碰撞结果排序时,以这个层级次序为依据做排序,而3D的碰撞检测结果则是以距离大小为依据排序的。

GraphicRaycaster 为UGUI元素点位检测的类,它被放在了 Core 渲染块里。它主要针对 ScreenSpaceOverlay 模式下输入点位做碰撞检测,因为这个模式下的检测并不依赖于射线碰撞,而是遍历所有可点击的UGUI元素来检测比较,从而判断是该响应哪个UI元素。因此 GraphicRaycaster 是比较特殊的。

我们来着重看下 GraphicRaycaster 的核心源码如下:


/// <summary>
/// Perform a raycast into the screen and collect all graphics underneath it.
/// </summary>
[NonSerialized] static readonly List<Graphic> s_SortedGraphics = new List<Graphic>();
private static void Raycast(Canvas canvas, Camera eventCamera, Vector2 pointerPosition, List<Graphic> results)
{
    // Debug.Log("ttt" + pointerPoision + ":::" + camera);
    // Necessary for the event system
    var foundGraphics = GraphicRegistry.GetGraphicsForCanvas(canvas);
    for (int i = 0; i < foundGraphics.Count; ++i)
    {
        Graphic graphic = foundGraphics[i];

        // -1 means it hasn't been processed by the canvas, which means it isn't actually drawn
        if (graphic.depth == -1 || !graphic.raycastTarget)
            continue;

        if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera))
            continue;

        if (graphic.Raycast(pointerPosition, eventCamera))
        {
            s_SortedGraphics.Add(graphic);
        }
    }

    s_SortedGraphics.Sort((g1, g2) => g2.depth.CompareTo(g1.depth));
    //      StringBuilder cast = new StringBuilder();
    for (int i = 0; i < s_SortedGraphics.Count; ++i)
        results.Add(s_SortedGraphics[i]);
    //      Debug.Log (cast.ToString());

    s_SortedGraphics.Clear();
}

上述代码中,GraphicRaycaster 对每个可以点击的元素(raycastTarget是否为true,并且 depth 不为-1,为可点击元素)进行计算,判断点位是否落在该元素上。再通过 depth 变量排序,判断最先该落在哪个元素上,从而确定哪个元素响应输入事件。

所有检测碰撞的结果数据结构为 RaycastResult 类,它承载了所有碰撞检测结果的依据,包括了距离,世界点位,屏幕点位,2D层级次序,碰撞物体等,为后面事件处理提供了数据上的依据。

事件逻辑处理模块

事件主逻辑处理模块,主要的逻辑都集中在 EventSystem 类中,其余的类都是对它起辅助作用的。

EventInterfaces,EventTrigger,EventTriggerType 定义了事件回调函数,ExecuteEvents 编写了所有执行事件的回调接口。

EventSystem 主逻辑里只有300行代码基本上都在处理由射线碰撞检测后引起的各类事件。判断事件是否成立,成立则发起事件回调,不成立则继续轮询检查,等待事件的发生。

EventSystem 是事件处理模块中唯一继承 MonoBehavior 并且有在 Update 帧循环中做轮询的。也就是说,所有UI事件的发生都是通过 EventSystem 轮询监测到的并且实施的。EventSystem 通过调用输入事件检测模块,检测碰撞模块,来形成自己主逻辑部分。因此可以说 EventSystem 是主逻辑类,是整个事件模块的入口。

架构者在设计时将整个事件层各自的职能拆分的很清楚,使得我们看源代码时也并没有那么难。输入监测由输入事件捕捉模块完成,碰撞检测由碰撞检测模块完成,事件的数据类都有各自的定义,EventSystem 主要作用是把这些模块拼装起来成为主逻辑块。

UGUI源码地址

July 26, 2018 · 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

版权申明

本文为博主原创文章,未经允许不得转载:

《Unity3D高级编程之进阶主程》第四章,UI(三) - 剖析UGUI源码中的输入与事件模块

Copyright attention

Please don't reprint without authorize.

qrcode_for_gzh.jpg

微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

QQ交流群: 777859752 (高级程序书友会)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK