UI(三) - 剖析UGUI源码中的输入与事件模块
创始人
2024-06-03 13:51:53
0

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

UGUI源码剖析

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

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

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

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

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

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

输入事件源码

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

图中,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 hovered = new List();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 向触控板输入方向拓展。

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


/// 
/// 处理所有的鼠标事件
/// 
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(leftButtonData.buttonData.pointerCurrentRaycast.gameObject);ExecuteEvents.ExecuteHierarchy(scrollHandler, leftButtonData.buttonData, ExecuteEvents.scrollHandler);}
}

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


/// 
/// Process the current mouse press.
/// 处理鼠标按下事件
/// 
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(currentOverGo);// Debug.Log("Pressed: " + newPressed);float time = Time.unscaledTime;if (newPressed == pointerEvent.lastPress){var diffTime = time - pointerEvent.clickTime;if (diffTime < 0.3f)++pointerEvent.clickCount;elsepointerEvent.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(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(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();// 如果已经在移动,且还没开始拖拽启动事件,则调用拖拽启动句柄,并设置拖拽中标记为trueif (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 的核心函数。如下:


/// 
/// Process all touch events.
/// 处理所有触屏事件
/// 
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);}elseRemovePointerData(pointer);}
}

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

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


private static readonly List s_InternalTransformList = new List(30);public static GameObject ExecuteHierarchy(GameObject root, BaseEventData eventData, EventFunction 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 的核心源码如下:


/// 
/// Perform a raycast into the screen and collect all graphics underneath it.
/// 
[NonSerialized] static readonly List s_SortedGraphics = new List();
private static void Raycast(Canvas canvas, Camera eventCamera, Vector2 pointerPosition, List results)
{// Debug.Log("ttt" + pointerPoision + ":::" + camera);// Necessary for the event systemvar 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 drawnif (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源码地址

相关内容

热门资讯

122.(leaflet篇)l... 听老人家说:多看美女会长寿 地图之家总目录(订阅之前建议先查看该博客) 文章末尾处提供保证可运行...
育碧GDC2018程序化大世界... 1.传统手动绘制森林的问题 采用手动绘制的方法的话,每次迭代地形都要手动再绘制森林。这...
育碧GDC2018程序化大世界... 1.传统手动绘制森林的问题 采用手动绘制的方法的话,每次迭代地形都要手动再绘制森林。这...
Vue使用pdf-lib为文件... 之前也写过两篇预览pdf的,但是没有加水印,这是链接:Vu...
PyQt5数据库开发1 4.1... 文章目录 前言 步骤/方法 1 使用windows身份登录 2 启用混合登录模式 3 允许远程连接服...
Android studio ... 解决 Android studio 出现“The emulator process for AVD ...
Linux基础命令大全(上) ♥️作者:小刘在C站 ♥️个人主页:小刘主页 ♥️每天分享云计算网络运维...
再谈解决“因为文件包含病毒或潜... 前面出了一篇博文专门来解决“因为文件包含病毒或潜在的垃圾软件”的问题,其中第二种方法有...
南京邮电大学通达学院2023c... 题目展示 一.问题描述 实验题目1 定义一个学生类,其中包括如下内容: (1)私有数据成员 ①年龄 ...
PageObject 六大原则 PageObject六大原则: 1.封装服务的方法 2.不要暴露页面的细节 3.通过r...
【Linux网络编程】01:S... Socket多进程 OVERVIEWSocket多进程1.Server2.Client3.bug&...
数据结构刷题(二十五):122... 1.122. 买卖股票的最佳时机 II思路:贪心。把利润分解为每天为单位的维度,然后收...
浏览器事件循环 事件循环 浏览器的进程模型 何为进程? 程序运行需要有它自己专属的内存空间࿰...
8个免费图片/照片压缩工具帮您... 继续查看一些最好的图像压缩工具,以提升用户体验和存储空间以及网站使用支持。 无数图像压...
计算机二级Python备考(2... 目录  一、选择题 1.在Python语言中: 2.知识点 二、基本操作题 1. j...
端电压 相电压 线电压 记得刚接触矢量控制的时候,拿到板子,就赶紧去测各种波形,结...
如何使用Python检测和识别... 车牌检测与识别技术用途广泛,可以用于道路系统、无票停车场、车辆门禁等。这项技术结合了计...
带环链表详解 目录 一、什么是环形链表 二、判断是否为环形链表 2.1 具体题目 2.2 具体思路 2.3 思路的...
【C语言进阶:刨根究底字符串函... 本节重点内容: 深入理解strcpy函数的使用学会strcpy函数的模拟实现⚡strc...
Django web开发(一)... 文章目录前端开发1.快速开发网站2.标签2.1 编码2.2 title2.3 标题2.4 div和s...