UGUISourceCode:UGUI 源码剖析
01 | 从Button开始解读UGUI源码
在Unity中,Button是我们最常用的组件之一了,它使用起来非常简单,例如监听一个Button点击事件。
GetComponent<Button>().onClick.AddListener(() => {
//按钮点击的处理逻辑
});
这样使用没有任何问题,但有时候我们会有疑问,为什么点击按钮onClick事件就会被触发呢?如何从回调函数中获取按钮的参数?让我们从源码中寻找答案。
查看源码前的配置
为了能够方便的查阅源码以及进行代码调试,需要重新导入UGUI包。新建Unity项目,找到Project/Packages/Unity UI,右键 Show in Explorer,将其复制到任意一个新的文件夹中(记住保存的位置,待会需要引用)。 接下来打开Window/Package Manager
找到Unity UI,将其Remove
然后点击“+”号,选择Add package form disk...,找到之前保存的UI包,进入目录后选中package.json,点击打开。
大功告成,现在我们可以查看/修改UGUI的源码了。
探究UGUI源码
Button的调用链
通过F12打开Button代码,容易发现它继承Selectable类,同时还继承了IPointerClickHandler、ISubmitHandler接口,这两个接口分别会在鼠标点击、点击提交按钮时调用它们的回调函数。
public class Button : Selectable, IPointerClickHandler, ISubmitHandler
{
[Serializable]
//定义一个点击事件
public class ButtonClickedEvent : UnityEvent {}
// 实例化一个ButtonClickedEvent的事件
[FormerlySerializedAs("onClick")]
[SerializeField]
private ButtonClickedEvent m_OnClick = new ButtonClickedEvent();
protected Button()
{}
//常用的onClick.AddListener()就是监听这个事件
public ButtonClickedEvent onClick
{
get { return m_OnClick; }
set { m_OnClick = value; }
}
//如果按钮处于活跃状态并且可交互(Interactable设置为true),则触发事件
private void Press()
{
if (!IsActive() || !IsInteractable())
return;
UISystemProfilerApi.AddMarker("Button.onClick", this);
m_OnClick.Invoke();
}
//鼠标点击时调用该函数,继承自 IPointerClickHandler 接口
public virtual void OnPointerClick(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
Press();
}
//按下“提交”键后触发(需要先选中该游戏物体),继承自 ISubmitHandler
//"提交"键可以在 Edit->Project Settings->Input->Submit 中自定义
public virtual void OnSubmit(BaseEventData eventData){...}
private IEnumerator OnFinishSubmit(){...}
}
IPointerClickHandler接口仅包含一个OnPointerClick()方法,当鼠标点击时会调用该接口的方法。而Button能触发点击事件是因为继承自IPointerClickHandler接口,并且重写了OnPointerClick方法。
那IPointerClickHandler接口的方法又是被谁调用的呢?查找引用,发现是ExecuteEvents类的Execute方法(该类相当于事件执行器,提供了许多通用的事件处理方法),并且Execute方法赋值给s_PointerClickHandler字段。
private static readonly EventFunction<IPointerClickHandler> s_PointerClickHandler = Execute;
private static void Execute(IPointerClickHandler handler, BaseEventData eventData)
{
handler.OnPointerClick(ValidateEventData<PointerEventData>(eventData));
}
为了能看的更清楚,总结一下调用关系,即Button继承自Selectable、IPointercliClickHandler、ISubmitHandler,而IPointercliClickHandler、ISubmitHandler继承自IEventSystemHandler,ExecuteEvent会在鼠标松开时通过Execute函数调用IPointercliClickHandler、ISubmitHandler接口的方法,从而触发Button的onClick事件,如下图所示
继续往上找,ExecuteEvents类中还定义了一个EventFunction的泛型委托以及该委托类型的属性,这个返回s_PointerClickHandler,要查找谁触发的点击事件,只需要找到谁调用了pointerClickHandler即可
public delegate void EventFunction<T1>(T1 handler, BaseEventData eventData);
public static EventFunction<IPointerClickHandler> pointerClickHandler
{
get { return s_PointerClickHandler; }
}
容易发现,StandaloneInputModule和TouchInputModule类对其有调用,这两个类继承自BaseInput,主要用以处理鼠标、键盘、控制器等设备的输入,EventSystem类会在Update中每帧检查可用的输入模块的状态是否发生变化,并调用TickModules()和当前输入模块(m_CurrentInputModule)的Process()函数(后面会进行讲解)。下面是StandaloneInputModule的部分代码,它继承自BaseInputModule
// 计算和处理任何鼠标按钮状态的变化
//Process函数间接对其进行调用(调用链过长,不一一展示)
protected void ProcessMousePress(MouseButtonEventData data)
{
...//省略部分代码
//鼠标按键抬起时调用(按键包括鼠标左键、中间滑轮和右键)
if (data.ReleasedThisFrame())
{
ReleaseMouse(pointerEvent, currentOverGo);
}
...
}
//满足松开鼠标的条件时调用
//currentOverGo :当前选中的游戏物体
private void ReleaseMouse(PointerEventData pointerEvent, GameObject currentOverGo)
{
...//省略部分代码
if (pointerEvent.pointerPress == pointerUpHandler && pointerEvent.eligibleForClick)
{
//执行Execute函数,传入ExecuteEvents.pointerClickHandler委托
ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerClickHandler);
}
...
}
查看ExecuteEvents.Execute的实现
上面已经查看过Execute方法,为什么现在又出来一个? 因为ExecuteEvents中有N多个重载函数
//target : 需要执行事件的游戏对象
public static bool Execute<T>(GameObject target, BaseEventData eventData, EventFunction<T> functor) where T : IEventSystemHandler
{
var internalHandlers = s_HandlerListPool.Get();
//获取target对象的事件
GetEventList<T>(target, internalHandlers);
// if (s_InternalHandlers.Count > 0)
// Debug.Log("Executinng " + typeof (T) + " on " + target);
for (var i = 0; i < internalHandlers.Count; i++)
{
T arg;
try
{
arg = (T)internalHandlers[i];
}
catch (Exception e)
{
var temp = internalHandlers[i];
Debug.LogException(new Exception(string.Format("Type {0} expected {1} received.", typeof(T).Name, temp.GetType().Name), e));
continue;
}
try
{
//执行EventFunction<T>委托,例如pointerClickHandler(arg,eventData)
functor(arg, eventData);
}
catch (Exception e)
{
Debug.LogException(e);
}
}
var handlerCount = internalHandlers.Count;
s_HandlerListPool.Release(internalHandlers);
return handlerCount > 0;
}
也就是说,EventSystem会在Update()中调用当前可用BaseInputModule的Process()方法,该方法会处理鼠标的按下、抬起等事件,当鼠标抬起时调用ReleaseMouse()方法,并最终调用Execute()方法并触发IPointerClick事件。 如下图所示(为了简洁,类图并不完整)
ReleaseMouse()是否只有鼠标左键抬起才会触发? 鼠标左、中、右键都会触发该函数,只不过Button在实现OnPointerClick()函数时忽略了鼠标中键和右键,使得只有左键能触发Button的点击事件 但现在还存在一个问题,怎么知道上述代码中事件执行目标target的值呢?探究这个问题之前,我们需要先对UGUI源码有个总体的认识,因为它涉及的知识点比较多。
事件系统整体概述
我们先看EventSystem源码在文件夹中的分类
从图中就可以看出主要包含三个子板块,分别是EvnetData、InputModules和Raycasters。
再看一个整体的类图,类图中包括了许多重要的类,如EventSystem、BaseRaycast、BaseInputModule等,它们都是继承自UIBehaviour,而UIBehaviour又是继承MonoBehaviour。(类图并不完整,只涉及部分类)
接下来对这些内容进行详细讲解。
EventSystem类 事件系统主要是基于输入(键盘、鼠标、触摸或自定义输入)向应用程序中的对象发送事件,当然这需要其他组件的配合。当你在GameObject中添加EventSystem时,你会发现它并没有太多的功能,这是因为EventSystem本身被设计成事件系统不同模块之间通信的管理者和推动者,它主要包含以下功能:
管理哪个游戏对象被认为是选中的 管理正在使用的输入模块 管理射线检测(如果需要) 根据需要更新所有输入模块
管理输入模块
下面看一下具体代码。首先是声明了BaseInputModule类型的List和变量,用来保存输入模块(Module)
//系统输入模块
private List<BaseInputModule> m_SystemInputModules = new List<BaseInputModule>();
//当前输入模块
private BaseInputModule m_CurrentInputModule;
接下来,它会在Update中处理这些模块,调用TickModules方法,更新每一个模块,并且会在满足条件的情况下调用当前模块的Process方法
protected virtual void Update()
{
//遍历m_SystemInputModules,如果其中的Module不为null,则调用UpdateModule方法
TickModules();
//遍历m_SystemInputModules判断其中的输入模块是否支持当前平台
//如果支持并且可以激活,则将其赋值给当前输入模块并Break
bool changedModule = false;
var systemInputModulesCount = m_SystemInputModules.Count;
for (var i = 0; i < systemInputModulesCount; i++)
{
var module = m_SystemInputModules[i];
if (module.IsModuleSupported() && module.ShouldActivateModule())
{
if (m_CurrentInputModule != module)
{
ChangeEventModule(module);
changedModule = true;
}
break;
}
}
//如果上面没找到符合条件的模块,则使用第一个支持当前平台的模块
if (m_CurrentInputModule == null)
{
for (var i = 0; i < systemInputModulesCount; i++)
{
var module = m_SystemInputModules[i];
if (module.IsModuleSupported())
{
ChangeEventModule(module);
changedModule = true;
break;
}
}
}
//如果当前模块没有发生变化并且当前模块不为空
if (!changedModule && m_CurrentInputModule != null)
m_CurrentInputModule.Process();
}
private void TickModules()
{
var systemInputModulesCount = m_SystemInputModules.Count;
for (var i = 0; i < systemInputModulesCount; i++)
{
if (m_SystemInputModules[i] != null)
m_SystemInputModules[i].UpdateModule();
}
}
Process()方法主要是将各种输入事件(如点击、拖拽等事件)传递给EventSystem当前选中的GameObject(即m_CurrentSelected)
管理选中的游戏对象
当场景中的游戏物体(Button、Dropdown、InputField等)被选中时,会通知之前选中的对象执行被取消(OnDeselect)事件,通知当前选中的对象执行选中(OnSelect)事件,部分代码如下
public void SetSelectedGameObject(GameObject selected, BaseEventData pointer)
{
......//省略部分代码
//通知之前被选中取消选中
ExecuteEvents.Execute(m_CurrentSelected, pointer, ExecuteEvents.deselectHandler);
m_CurrentSelected = selected;
//通知当前物体被选中
ExecuteEvents.Execute(m_CurrentSelected, pointer, ExecuteEvents.selectHandler);
m_SelectionGuard = false;
}
管理射线检测
EventSystem中,还有一个非常重要的函数RaycastAll(),主要是获取目标。它被PointerInputModule类调用,大致来说是当鼠标设备可用或触摸板被使用时调用。
public void RaycastAll(PointerEventData eventData, List<RaycastResult> raycastResults)
{
raycastResults.Clear();
//获取BaseRaycast对象
var modules = RaycasterManager.GetRaycasters();
var modulesCount = modules.Count;
for (int i = 0; i < modulesCount; ++i)
{
var module = modules[i];
if (module == null || !module.IsActive())
continue;
//调用Raycast方法,
module.Raycast(eventData, raycastResults);
}
raycastResults.Sort(s_RaycastComparer);
}
它首先获取所有的BaseRaycast对象,然后调用它的Raycast方法,用以获取屏幕某个点下的所有目标(这个方法具体功能及实现的会在Raycast模块中进行讲解),最后对得到的结果进行排序,大部分情况都是根据深度(Depth)进行排序,在一些情况下也会使用距离(Distance)、排序顺序(SortingOrder,如果是UI元素则是根据Canvas面板的Sort order值,3D物体默认是0)或者排序层级(Sorting Layer)等作为排序依据。
讲了这么一大堆,来张图总结一下。EventSystem会在Update中调用输入模块的Process方法来处理输入消息,PointerInputModule会调用EventSystem中的RaycastAll方法进行射线检测,RaycastAll又会调用BastRaycaster的Raycast方法执行具体的射线检测操作,主要是获取被选中的目标信息。
简单概括一下UML图的含义,比如实线+三角形表示继承,实线+箭头表示关联,虚线+箭头表示依赖,关联和依赖的区别主要是引用其他类作为成员变量代表的是关联关系,将其他类作为局部变量、方法参数,或者引用它的静态方法,就属于依赖关系。 InputModules 输入模块是配置和定制事件系统主逻辑的地方。 自带的输入模块有两个,一个是为独立输入(StandaloneInputModule),另一个是为触摸输入(TouchInputModule)。 StandaloneInputModule是PC、Mac&Linux上的具体实现,而TouchInputModule是IOS、Android等移动平台上的具体实现,每个模块都按照给定配置接收和分派事件。 运行EventSystem后,它会查看附加了哪些输入模块,并将事件传递给特定的模块。 内置的输入模块旨在支持常见的游戏配置,如触摸输入、控制器输入、键盘输入和鼠标输入等。
它的主要任务有三个,分别是
处理输入 管理事件状态 发送事件到场景对象 在讲Button的时候我们提到鼠标的点击事件是在BaseInputModule中触发的,除此之外,EventInterface接口中的其他事件也都是由输入模块产生的,具体触发条件如下:
当鼠标或触摸进入、退出当前对象时执行pointerEnterHandler、pointerExitHandler。 在鼠标或者触摸按下、松开时执行pointerDownHandler、pointerUpHandler。 在鼠标或触摸松开并且与按下时是同一个响应物体时执行pointerClickHandler。 在鼠标或触摸位置发生偏移(偏移值大于一个很小的常量)时执行beginDragHandler。 在鼠标或者触摸按下且当前对象可以响应拖拽事件时执行initializePotentialDrag。 对象正在被拖拽且鼠标或触摸移动时执行dragHandler。 对象正在被拖拽且鼠标或触摸松开时执行endDragHandler。 鼠标或触摸松开且对象未响应pointerClickHandler情况下,如果对象正在被拖拽,执行dropHandler。 当鼠标滚动差值大于零执行scrollHandler。 当输入模块切换到StandaloneInputModule时执行updateSelectedHandler。(不需要Input类) 当鼠标移动导致被选中的对象改变时,执行selectHandler和deselectHandler。 导航事件可用情况下,按下上下左右键,执行moveHandler,按下确认键执行submitHandler,按下取消键执行cancelHandler。 更加底层的调用还是UnityEngine.Input类,但可惜的是这部分Unity并没有开源。
每次事件系统中只能有一个输入模块处于活跃状态,并且必须与EventSystem组件处于相同的游戏对象上。
执行事件 既然InputModule主要就是处理设备输入,发送事件到场景对象,那这些事件是怎么执行的呢?在讲Button的时候,我们提到过ExecuteEvent类,其实事件的执行都是通过这个类进行的,不过也需要EventInterface接口配合。这个类中定义了许多接口,比如鼠标按下、点击、拖拽等,下图展示了部分接口的继承关系。
ExecuteEvent类中提供了一个方法让外部统一调用以执行事件
public static bool Execute<T>(GameObject target, BaseEventData eventData, EventFunction<T> functor) where T : IEventSystemHandler
{
//从对象池中取出一个IEventSystemHandler类型的元素
var internalHandlers = s_HandlerListPool.Get();
//获取指定对象(target)的事件,并保存在internalHandlers中
GetEventList<T>(target, internalHandlers);
// if (s_InternalHandlers.Count > 0)
// Debug.Log("Executinng " + typeof (T) + " on " + target);
var internalHandlersCount = internalHandlers.Count;
for (var i = 0; i < internalHandlersCount; i++)
{
T arg;
try
{
arg = (T)internalHandlers[i];
}
catch (Exception e)
{
var temp = internalHandlers[i];
Debug.LogException(new Exception(string.Format("Type {0} expected {1} received.", typeof(T).Name, temp.GetType().Name), e));
continue;
}
try
{
//执行事件
functor(arg, eventData);
}
catch (Exception e)
{
Debug.LogException(e);
}
}
var handlerCount = internalHandlers.Count;
s_HandlerListPool.Release(internalHandlers);
return handlerCount > 0;
}
这个方法之前有讲过,主要就是查找target对象上的T类型的组件列表,并遍历执行。
除此之外,还有一个GetEventHandler方法,它主要是通过冒泡的方式查找到能够处理指定事件的对象。
// 在游戏对象上冒泡指定的事件,找出哪个对象将实际接收事件。
public static GameObject GetEventHandler<T>(GameObject root) where T : IEventSystemHandler
{
if (root == null)
return null;
Transform t = root.transform;
//冒泡查找,如果物体本身不能处理输入的事件,交予parent处理
while (t != null)
{
if (CanHandleEvent<T>(t.gameObject))
return t.gameObject;
t = t.parent;
}
return null;
}
// 指定的游戏对象是否能够处理指定的事件
public static bool CanHandleEvent<T>(GameObject go) where T : IEventSystemHandler
{
var internalHandlers = s_HandlerListPool.Get();
GetEventList<T>(go, internalHandlers);
var handlerCount = internalHandlers.Count;
s_HandlerListPool.Release(internalHandlers);
return handlerCount != 0;
}
比如我们在场景中创建一个Button,那这个Button还包含了Text组件,当鼠标点击的时候会调用GetEventHandler函数,该函数的root参数其实是Text,但是会通过冒泡的方式查找到它的父物体Button,然后调用Button的点击事件。
Raycasters
事件系统需要一个方法来检测当前输入事件需要发送到哪里,这是由Raycasters提供的。 给定一个屏幕空间位置,它们将收集所有潜在目标,找出它们是否在给定位置下,然后返回离屏幕最近的对象。 系统提供了以下几种类型的Raycaster:
Graphic Raycaster: 检测UI元素 Physics 2D Raycaster: 用于2D物理元素 Physics Raycaster: 用于3D物理元素
BaseRaycaster是其他Raycaster的基类,这是是一个抽象类。在它OnEnable里将自己注册到RaycasterManager,并在OnDisable的时候从后者移除。
RaycasterManager是一个静态类,维护了一个BaseRaycaster类型的List,功能比较简单,包含获取(Get)、添加(Add)、移除(Remove)方法。
BaseRaycaster中最重要的就是Raycast方法了,它的子类都对该方法进行了重写。
Physics Raycaster
它主要用于检测3D物理元素,并且保存被射线检测到物体的数据,下面是部分代码
public override void Raycast(PointerEventData eventData, List<RaycastResult> resultAppendList)
{
//判断是否超出摄像机的远近裁剪平面的距离
if (!ComputeRayAndDistance(eventData, ref ray, ref displayIndex, ref distanceToClipPlane))
return;
//采用ReflectionMethodsCache.Singleton.raycast3DAll()来获取所有射线照射到的对象
//用反射的方式把Physics.RaycastAll()方法缓存下来,让Unity的Physics模块与UI模块,保持低耦合,没有过分依赖。
if (m_MaxRayIntersections == 0)
{
m_Hits = ReflectionMethodsCache.Singleton.raycast3DAll(ray, distanceToClipPlane, finalEventMask);
hitCount = m_Hits.Length;
}
else
{
if (m_LastMaxRayIntersections != m_MaxRayIntersections)
{
m_Hits = new RaycastHit[m_MaxRayIntersections];
m_LastMaxRayIntersections = m_MaxRayIntersections;
}
hitCount = ReflectionMethodsCache.Singleton.getRaycastNonAlloc(ray, m_Hits, distanceToClipPlane, finalEventMask);
}
//获取到被射线照射到的对象,根据距离进行排序,然后包装成RaycastResult,加入到resultAppendList中
if (hitCount != 0)
{
if (hitCount > 1)
System.Array.Sort(m_Hits, 0, hitCount, RaycastHitComparer.instance);
for (int b = 0, bmax = hitCount; b < bmax; ++b)
{
var result = new RaycastResult
{
...//为result赋值
};
resultAppendList.Add(result);
}
}
}
Physics2DRaycaster继承自PhysicsRaycaster,实现功能和方式基本一致,只不过是用于检测2D物体,这里不具体讲解
GraphicRaycast
GraphicRaycast用于检测UI元素,它依赖于Canvas,我们在场景中添加Canvas默认都会包含一个GraphicRaycast组件。它先获取鼠标坐标,将其转换为Camera的视角坐标,然后分情况计算射线的距离(hitDistance),调用Graphic的Raycast方法来获取鼠标点下方的元素,最后将满足条件的结果添加到resultAppendList中。
public override void Raycast(PointerEventData eventData, List<RaycastResult> resultAppendList)
{
if (canvas == null)
return;
//返回Canvas上的所有包含Graphic脚本并且raycastTarget=true的游戏物体
var canvasGraphics = GraphicRegistry.GetRaycastableGraphicsForCanvas(canvas);
if (canvasGraphics == null || canvasGraphics.Count == 0)
return;
int displayIndex;
//画布在ScreenSpaceOverlay模式下默认为null
var currentEventCamera = eventCamera; // Property can call Camera.main, so cache the reference
if (canvas.renderMode == RenderMode.ScreenSpaceOverlay || currentEventCamera == null)
displayIndex = canvas.targetDisplay;
else
displayIndex = currentEventCamera.targetDisplay;
//获取鼠标位置
var eventPosition = Display.RelativeMouseAt(eventData.position);
if (eventPosition != Vector3.zero)
{
int eventDisplayIndex = (int)eventPosition.z;
if (eventDisplayIndex != displayIndex)
return;
}
else
{
eventPosition = eventData.position;
}
// Convert to view space
//将鼠标点在屏幕上的坐标转换成摄像机的视角坐标,如果超出范围则return
Vector2 pos;
if (currentEventCamera == null)
{
float w = Screen.width;
float h = Screen.height;
if (displayIndex > 0 && displayIndex < Display.displays.Length)
{
w = Display.displays[displayIndex].systemWidth;
h = Display.displays[displayIndex].systemHeight;
}
pos = new Vector2(eventPosition.x / w, eventPosition.y / h);
}
else
pos = currentEventCamera.ScreenToViewportPoint(eventPosition);
// If it's outside the camera's viewport, do nothing
if (pos.x < 0f || pos.x > 1f || pos.y < 0f || pos.y > 1f)
return;
float hitDistance = float.MaxValue;
Ray ray = new Ray();
//如果currentEventCamera不为空,摄像机发射射线
if (currentEventCamera != null)
ray = currentEventCamera.ScreenPointToRay(eventPosition);
//如果当前画布不是ScreenSpaceOverlay模式并且blockingObjects != BlockingObjects.None
//计算hitDistance的值
if (canvas.renderMode != RenderMode.ScreenSpaceOverlay && blockingObjects != BlockingObjects.None)
{
float distanceToClipPlane = 100.0f;
if (currentEventCamera != null)
{
float projectionDirection = ray.direction.z;
distanceToClipPlane = Mathf.Approximately(0.0f, projectionDirection)
? Mathf.Infinity
: Mathf.Abs((currentEventCamera.farClipPlane - currentEventCamera.nearClipPlane) / projectionDirection);
}
#if PACKAGE_PHYSICS
if (blockingObjects == BlockingObjects.ThreeD || blockingObjects == BlockingObjects.All)
{
if (ReflectionMethodsCache.Singleton.raycast3D != null)
{
var hits = ReflectionMethodsCache.Singleton.raycast3DAll(ray, distanceToClipPlane, (int)m_BlockingMask);
if (hits.Length > 0)
hitDistance = hits[0].distance;
}
}
#endif
#if PACKAGE_PHYSICS2D
if (blockingObjects == BlockingObjects.TwoD || blockingObjects == BlockingObjects.All)
{
if (ReflectionMethodsCache.Singleton.raycast2D != null)
{
var hits = ReflectionMethodsCache.Singleton.getRayIntersectionAll(ray, distanceToClipPlane, (int)m_BlockingMask);
if (hits.Length > 0)
hitDistance = hits[0].distance;
}
}
#endif
}
m_RaycastResults.Clear();
//调用Raycast函数重载
Raycast(canvas, currentEventCamera, eventPosition, canvasGraphics, m_RaycastResults);
//遍历m_RaycastResults,判断Graphic的方向向量和Camera的方向向量是否相交,然后判断Graphic是否在Camera的前面,并且距离小于等于hitDistance,满足了这些条件,才会把它打包成RaycastResult添加到resultAppendList里。
int totalCount = m_RaycastResults.Count;
for (var index = 0; index < totalCount; index++)
{
var go = m_RaycastResults[index].gameObject;
bool appendGraphic = true;
if (ignoreReversedGraphics)
{
if (currentEventCamera == null)
{
// If we dont have a camera we know that we should always be facing forward
var dir = go.transform.rotation * Vector3.forward;
appendGraphic = Vector3.Dot(Vector3.forward, dir) > 0;
}
else
{
// If we have a camera compare the direction against the cameras forward.
var cameraForward = currentEventCamera.transform.rotation * Vector3.forward * currentEventCamera.nearClipPlane;
appendGraphic = Vector3.Dot(go.transform.position - currentEventCamera.transform.position - cameraForward, go.transform.forward) >= 0;
}
}
if (appendGraphic)
{
float distance = 0;
Transform trans = go.transform;
Vector3 transForward = trans.forward;
if (currentEventCamera == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
distance = 0;
else
{
// http://geomalgorithms.com/a06-_intersect-2.html
distance = (Vector3.Dot(transForward, trans.position - ray.origin) / Vector3.Dot(transForward, ray.direction));
// Check to see if the go is behind the camera.
if (distance < 0)
continue;
}
if (distance >= hitDistance)
continue;
var castResult = new RaycastResult
{
......
};
resultAppendList.Add(castResult);
}
}
上述代码中调用了Raycast函数重载,作用是向屏幕投射射线并收集屏幕下方所有挂载了Graphic脚本的游戏对象,该函数内容为:
private static void Raycast(Canvas canvas, Camera eventCamera, Vector2 pointerPosition, IList<Graphic> foundGraphics, List<Graphic> results)
{
// Necessary for the event system
//遍历场景内Graphic对象(挂载Graphic脚本的对象)
int totalCount = foundGraphics.Count;
for (int i = 0; i < totalCount; ++i)
{
Graphic graphic = foundGraphics[i];
// -1 means it hasn't been processed by the canvas, which means it isn't actually drawn
if (!graphic.raycastTarget || graphic.canvasRenderer.cull || graphic.depth == -1)
continue;
//目标点是否在矩阵中
if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera, graphic.raycastPadding))
continue;
//超出摄像机范围
if (eventCamera != null && eventCamera.WorldToScreenPoint(graphic.rectTransform.position).z > eventCamera.farClipPlane)
continue;
//调用符合条件的Graphic的Raycast方法
if (graphic.Raycast(pointerPosition, eventCamera))
{
s_SortedGraphics.Add(graphic);
}
}
s_SortedGraphics.Sort((g1, g2) => g2.depth.CompareTo(g1.depth));
totalCount = s_SortedGraphics.Count;
for (int i = 0; i < totalCount; ++i)
results.Add(s_SortedGraphics[i]);
s_SortedGraphics.Clear();
}
函数中又调用了Graphic类的Raycast函数,它主要是做两件事,一件是使用RectTransform的值过滤元素,另一件是使用Raycast函数确定射线击中的元素。RawImage、Image和Text都间接继承自Graphic。
public virtual bool Raycast(Vector2 sp, Camera eventCamera)
{
if (!isActiveAndEnabled)
return false;
//UI元素,比如Image,Button等
var t = transform;
var components = ListPool<Component>.Get();
bool ignoreParentGroups = false;
bool continueTraversal = true;
while (t != null)
{
t.GetComponents(components);
for (var i = 0; i < components.Count; i++)
{
Debug.Log(components[i].name);
var canvas = components[i] as Canvas;
if (canvas != null && canvas.overrideSorting)
continueTraversal = false;
//获取ICanvasRaycastFilter组件(Image,Mask,RectMask2D)
var filter = components[i] as ICanvasRaycastFilter;
if (filter == null)
continue;
var raycastValid = true;
//判断sp点是否在有效的范围内
var group = components[i] as CanvasGroup;
if (group != null)
{
if (ignoreParentGroups == false && group.ignoreParentGroups)
{
ignoreParentGroups = true;
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
else if (!ignoreParentGroups)
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
else
{
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
if (!raycastValid)
{
ListPool<Component>.Release(components);
return false;
}
}
//遍历它的父物体
t = continueTraversal ? t.parent : null;
}
ListPool<Component>.Release(components);
return true;
}
这里也使用了ICanvasRaycastFilter接口中的IsRaycastLocationValid函数,主要还是判断点的位置是否有效,不过这里使用了Alpha测试。Image、Mask以及RectMask2D都继承了该接口。
public virtual bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
//小于阈值(alphaHitTestMinimumThreshold)的Alpha值将导致射线事件穿透图像。
//值为1将导致只有完全不透明的像素在图像上注册相应射线事件。
if (alphaHitTestMinimumThreshold <= 0)
return true;
if (alphaHitTestMinimumThreshold > 1)
return false;
if (activeSprite == null)
return true;
Vector2 local;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local))
return false;
Rect rect = GetPixelAdjustedRect();
// Convert to have lower left corner as reference point.
local.x += rectTransform.pivot.x * rect.width;
local.y += rectTransform.pivot.y * rect.height;
local = MapCoordinate(local, rect);
// Convert local coordinates to texture space.
Rect spriteRect = activeSprite.textureRect;
float x = (spriteRect.x + local.x) / activeSprite.texture.width;
float y = (spriteRect.y + local.y) / activeSprite.texture.height;
try
{
return activeSprite.texture.GetPixelBilinear(x, y).a >= alphaHitTestMinimumThreshold;
}
catch (UnityException e)
{
Debug.LogError("Using alphaHitTestMinimumThreshold greater than 0 on Image whose sprite texture cannot be read. " + e.Message + " Also make sure to disable sprite packing for this sprite.", this);
return true;
}
}
EventData
EventData用以存储事件信息,涉及到的东西不多,不展开讲解,层级关系如下图所示
实战:为Button的点击事件添加参数
在执行Button点击事件时,有些情况下我们需要获取触发事件的Button对象信息,这时可以自己实现一个Button点击事件
/// <summary>
/// UI事件监听器(与Button等UI挂在同一个物体上):管理所有UGUI事件,提供事件参数类
/// 若想看所有相关委托 自行查看EventTrigger类
/// </summary>
public class UIEventListener : MonoBehaviour, IPointerClickHandler, IPointerDownHandler, IPointerUpHandler
{
//2.定义委托
public delegate void PointerEventHandler(PointerEventData eventData);
//3.声明事件
public event PointerEventHandler PointerClick;
public event PointerEventHandler PointerDown;
public event PointerEventHandler PointerUp;
/// <summary>
/// 通过变换组件获取事件监听器
/// </summary>
/// <param name="transform"></param>
/// <returns></returns>
public static UIEventListener GetEventListener(Transform transform)
{
UIEventListener uIEventListener = transform.GetComponent<UIEventListener>();
if (uIEventListener == null)
uIEventListener = transform.gameObject.AddComponent<UIEventListener>();
return uIEventListener;
}
//1.实现接口
public void OnPointerClick(PointerEventData eventData)
{
//表示抽象的有 抽象类 接口(多类抽象行为) 委托(一类抽象行为)
//4.引发事件
if (PointerClick != null)
PointerClick(eventData);
}
public void OnPointerDown(PointerEventData eventData)
{
PointerDown?.Invoke(eventData);
}
public void OnPointerUp(PointerEventData eventData)
{
PointerUp?.Invoke(eventData);
}
}
使用的时候,我们只需要将它挂载到Button组件上,然后在PointerClick事件中添加自己的处理函数。
总结
utton点击事件怎么触发的呢?首先是EventSystem在Update中调用当前输入模块的Process方法处理所有的鼠标事件,并且输入模块会调用RaycastAll来得到目标信息,通过冒泡的方式找到事件实际接收者并执行点击事件(这只是总体流程,中间省略很多具体步骤)。
最后来一张层级关系图
02 | Unity UI重建(Rebuild)源码分析
Unity怎么绘制UI元素的?
首先我们需要明白一个问题:Unity是怎么绘制UI元素的?
Unity中渲染的物体都是由网格(Mesh)构成的,而网格的绘制单元是图元(点、线、三角面)。在unity中添加一个Image和Text,并且将Shadings Mode设置为Wireframe模式,可以看到一个Image由四个顶点和两个三角面构成,Text也是由许多顶点和三角面构成。
绘制信息都存储在Vertexhelper类中,除了顶点外,还包括法线、UV、颜色、切线以及一些函数,下面是它的部分代码
public class VertexHelper : IDisposable
{
private List<Vector3> m_Positions;
private List<Color32> m_Colors;
private List<Vector4> m_Uv0S;
private List<Vector4> m_Uv1S;
private List<Vector4> m_Uv2S;
private List<Vector4> m_Uv3S;
private List<Vector3> m_Normals;
private List<Vector4> m_Tangents;
private List<int> m_Indices;
public void FillMesh(Mesh mesh)
{
InitializeListIfRequired();
mesh.Clear();
if (m_Positions.Count >= 65000)
throw new ArgumentException("Mesh can not have more than 65000 vertices");
mesh.SetVertices(m_Positions);
mesh.SetColors(m_Colors);
mesh.SetUVs(0, m_Uv0S);
mesh.SetUVs(1, m_Uv1S);
mesh.SetUVs(2, m_Uv2S);
mesh.SetUVs(3, m_Uv3S);
mesh.SetNormals(m_Normals);
mesh.SetTangents(m_Tangents);
mesh.SetTriangles(m_Indices, 0);
mesh.RecalculateBounds();
}
}
数据存储好了,那怎么绘制呢?
这是依靠CanvasRenderer来完成的,它听起来可能比较陌生,但实际上当我们在项目中创建的一些UI元素,比如Button、Image、Text时,都包含组件CanvasRenderer,这个类提供了许多关键绘制信息,比如被渲染物体的颜色、材质和Mesh等,主要作用就是渲染包含在Canvas中的UI对象,但是在Inspector界面中并不会展示任何属性。
下面列出了几个比较重要的属性和方法,详情见Unity Documentation: CanvasRenderer。
总结一下就是Unity会把要绘制的UI信息保存在Vertexhelper中,并且调用CanvasRenderer里面的方法进行绘制,具体的绘制时机,就是今天的重点内容了。
这是UGUI源码系列的第二篇内容,如果没有任何基础,可以先看看文章 UGUI源码入门。
UI重建(Rebuild)
UI重建分为两类,一类是布局重建(Layout Rebuild),另一类是图形重建(Graphic Rebuild)。
一个UI若要重建,必须继承自ICanvasElement接口,因为执行重建操作的时候会调用接口中的Rebuild函数。CanvasUpdateRegistry类监听了Canvas的willRenderCanvases事件,该事件会每帧调用并执行PerformUpdate函数。PerformUpdate被调用时会遍历RebuildQueue中需要进行重建的UI元素,并调用元素的Rebuild方法。
下面对这些步骤展开详细的讲解。
ICanvasElement
首先是ICanvasElement接口,重建的时候会调用它的Rebuild方法,继承它的类都会对这个函数进行重写,Unity中几乎所有的UI组件都继承自这个接口。
下面是接口中包含的方法。
public interface ICanvasElement
{
// 根据CanvasUpdate的不同阶段重建元素
void Rebuild(CanvasUpdate executing);
// 获取ICanvasElement关联的变换组件
Transform transform { get; }
// 布局重建完成的回调函数
void LayoutComplete();
// 图形重建完成的回调函数
void GraphicUpdateComplete();
// 是否被销毁
bool IsDestroyed();
}
可以看到,Rebuild函数需要提供CanvasUpdate类型的参数,它是一个枚举类型,表示Rebuild的不同阶段。
public enum CanvasUpdate
{
//布局重建前
Prelayout = 0,
//布局重建
Layout = 1,
//布局重建后
PostLayout = 2,
//渲染前(图形重建前)
PreRender = 3,
//PreRender后,渲染前
LatePreRender = 4,
//最大枚举值
MaxUpdateValue = 5
}
CanvasUpdateRegistry
一个继承自ICanvasElement接口的类如果要重建,需要将自身加入到CanvasUpdateRegistry类中的重建队列中(RebuildQueue,并不是数据结构中的队列),CanvasUpdateRegistry中包含两个索引集(IndexedSet,内部使用Dictionary和List存储数据),分别是
IndexedSet m_LayoutRebuildQueue ,布局重建队列,当UI元素的布局需要更新时将其加入队列 IndexedSet m_GraphicRebuildQueue ,图形重建队列,当UI元素的图像需要更新时将其加入队列 该类在构造函数中监听了Canvas的willRenderCanvases事件,这个事件会在渲染前进行每帧调用,函数大致包含以下步骤:
PerformUpdate函数对m_LayoutRebuildQueue中的元素进行排序,依据是父节点的多少。接下来依次将Prelayout、Layout和PostLayout作为参数传递给Rebuild进行布局重建,完成后通知布局队列中的元素重建完成。 调用ClipperRegistry的Cull函数进行裁剪。 进行图形重建,遍历m_GraphicRebuildQueue的值,分别将参数PreRender、LatePreRender作为参数传递给Rebuild函数进行图形重建。 最后通知图形重建完成。
protected CanvasUpdateRegistry()
{
Canvas.willRenderCanvases += PerformUpdate;
}
private void PerformUpdate()
{
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
//清理Queue中值为null或者被销毁的元素
CleanInvalidItems();
m_PerformingLayoutUpdate = true;
//根据父节点多少排序(层级)
m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
{
for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
{
var rebuild = instance.m_LayoutRebuildQueue[j];
try
{
if (ObjectValidForUpdate(rebuild))
//布局重建,分别传入 Prelayout 、Layout 、PostLayout参数
rebuild.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, rebuild.transform);
}
}
}
//通知布局重建完成
for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
m_LayoutRebuildQueue[i].LayoutComplete();
instance.m_LayoutRebuildQueue.Clear();
m_PerformingLayoutUpdate = false;
//执行裁剪(cull)操作
ClipperRegistry.instance.Cull();
m_PerformingGraphicUpdate = true;
for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)
{
for (var k = 0; k < instance.m_GraphicRebuildQueue.Count; k++)
{
try
{
var element = instance.m_GraphicRebuildQueue[k];
if (ObjectValidForUpdate(element))
{
//图形重建,分别传入PreRender、LatePreRender参数
element.Rebuild((CanvasUpdate)i);
}
}
catch (Exception e)
{
Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform);
}
}
}
//通知图形重建完成
for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
m_GraphicRebuildQueue[i].GraphicUpdateComplete();
instance.m_GraphicRebuildQueue.Clear();
m_PerformingGraphicUpdate = false;
UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout);
}
不论是布局重建还是图形重建,都是遍历m_LayoutRebuildQueue和m_GraphicRebuildQueue中的元素并调用其Rebuild方法,这些UI元素是怎么被添加进Queue的呢?
对于m_LayoutRebuildQueue,提供了两个公开方法向其添加内容,当元素需要进行布局重建的时候,将调用该函数将自身加入队列,m_GraphicRebuildQueue同样也提供了两个函数。
//向m_LayoutRebuildQueue中添加元素
public static void RegisterCanvasElementForLayoutRebuild(ICanvasElement element)
{
instance.InternalRegisterCanvasElementForLayoutRebuild(element);
}
//尝试向m_LayoutRebuildQueue中添加元素
//并返回执行结果(True->成功, False->失败)
public static bool TryRegisterCanvasElementForLayoutRebuild(ICanvasElement element)
{
return instance.InternalRegisterCanvasElementForLayoutRebuild(element);
}
//如果队列中不存在element元素,则添加
private bool InternalRegisterCanvasElementForLayoutRebuild(ICanvasElement element)
{
if (m_LayoutRebuildQueue.Contains(element))
return false;
return m_LayoutRebuildQueue.AddUnique(element);
}
//向m_GraphicRebuildQueue中添加元素
public static void RegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
instance.InternalRegisterCanvasElementForGraphicRebuild(element);
}
//尝试向m_GraphicRebuildQueue中添加元素
public static bool TryRegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
return instance.InternalRegisterCanvasElementForGraphicRebuild(element);
}
private bool InternalRegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
if (m_PerformingGraphicUpdate)
{
return false;
}
return m_GraphicRebuildQueue.AddUnique(element);
}
以Graphic为例,我们看一下什么时候会向重建队列中添加元素。
Graphic
以Graphic为例(Image和Text间接继承自它),看一下具体发生了什么。 首先是将自身加入重建队列,这里是通过设置“脏数据”实现的,包括布局(Layout)、材质(Material)和顶点(Vertices)三部分,设置布局为脏,将进行布局重建,设置顶点或材质为脏,则进行图形重建。布局重建会将自身加入m_LayoutRebuildQueue中,图形重建则会将自身加入m_GraphicRebuildQueue中,等待被调用。
public virtual void SetAllDirty()
{
if (m_SkipLayoutUpdate)
{
m_SkipLayoutUpdate = false;
}
else
{
SetLayoutDirty();
}
if (m_SkipMaterialUpdate)
{
m_SkipMaterialUpdate = false;
}
else
{
SetMaterialDirty();
}
SetVerticesDirty();
}
public virtual void SetLayoutDirty()
{
if (!IsActive())
return;
//将元素加入布局重建队列
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
Debug.Log("Rebuild:" + rectTransform.name);
if (m_OnDirtyLayoutCallback != null)
m_OnDirtyLayoutCallback();
}
public virtual void SetVerticesDirty()
{
if (!IsActive())
return;
m_VertsDirty = true;
//将元素加入图形重建队列
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
if (m_OnDirtyVertsCallback != null)
m_OnDirtyVertsCallback();
}
public virtual void SetMaterialDirty()
{
if (!IsActive())
return;
m_MaterialDirty = true;
//将元素加入图形重建队列
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
if (m_OnDirtyMaterialCallback != null)
m_OnDirtyMaterialCallback();
}
加入重建队列之后,CanvasUpdateRegistry就会在PerformUpdate函数中调用它的Rebuild进行重建。Graphic实现了接口ICanvasElement的Rebuild方法,在满足条件的情况下将更新元素的几何网格(UpdateGeometry)和材质(UpdateMaterial)。
public virtual void Rebuild(CanvasUpdate update)
{
if (canvasRenderer == null || canvasRenderer.cull)
return;
switch (update)
{
case CanvasUpdate.PreRender:
if (m_VertsDirty)
{
UpdateGeometry();
m_VertsDirty = false;
}
if (m_MaterialDirty)
{
UpdateMaterial();
m_MaterialDirty = false;
}
break;
}
}
UpdateGeometry函数用于确定元素的网格(Mesh)信息,这些信息包括顶点、三角面、UV、颜色等,它们将会被填充到s_VertexHelper中,并最终调用canvasRenderer.SetMesh(workerMesh)设置Mesh信息。
//调用该函数将图形的几何网格更新到CanvasRenderer上。
protected virtual void UpdateGeometry()
{
//Image、RawImage、Text会在构造函数中将其设置为false
if (useLegacyMeshGeneration)
{
DoLegacyMeshGeneration();
}
else
{
DoMeshGeneration();
}
}
private void DoMeshGeneration()
{
if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
//UI元素需要生成顶点时的回调函数,用以填充顶点缓冲区的数据
//其子类重写了这个方法
OnPopulateMesh(s_VertexHelper);
else
s_VertexHelper.Clear();
//获取当前对象是否有IMeshModifier接口,
//Text的描边和阴影都是通过它的ModifyMesh方法实现的
var components = ListPool<Component>.Get();
GetComponents(typeof(IMeshModifier), components);
for (var i = 0; i < components.Count; i++)
((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper);
ListPool<Component>.Release(components);
s_VertexHelper.FillMesh(workerMesh);
//设置渲染所需的网格信息
canvasRenderer.SetMesh(workerMesh);
}
Image
Image间接继承自Graphic,当它的Sprite发生变化时,会调用SetAllDirty函数
public Sprite sprite
{
get { return m_Sprite; }
set
{
if (m_Sprite != null)
{
if (m_Sprite != value)
{
m_SkipLayoutUpdate = m_Sprite.rect.size.Equals(value ? value.rect.size : Vector2.zero);
m_SkipMaterialUpdate = m_Sprite.texture == (value ? value.texture : null);
m_Sprite = value;
SetAllDirty();
TrackSprite();
}
}
else if (value != null)
{
m_SkipLayoutUpdate = value.rect.size == Vector2.zero;
m_SkipMaterialUpdate = value.texture == null;
m_Sprite = value;
SetAllDirty();
TrackSprite();
}
}
}
设置Sprite大小的时候也会调用
public override void SetNativeSize()
{
if (activeSprite != null)
{
float w = activeSprite.rect.width / pixelsPerUnit;
float h = activeSprite.rect.height / pixelsPerUnit;
rectTransform.anchorMax = rectTransform.anchorMin;
rectTransform.sizeDelta = new Vector2(w, h);
SetAllDirty();
}
}
即对应下图中的SetNativeSize按钮
当然修改Image其他的属性也可能会引发重建,调用的地方太多了,想要进一步了解的同学可以在源码中找到答案。
Text类似,当文本的字体、大小等属性发生变化时,也会引起重建。
总结
以下情形都将进行UI重建,因此在项目中可以针对这些情况进行优化,比如用改变UI的Scale(1->0)来代替改变UI的Enable属性,以Image的Scale代替Slider来进行百分比展示等(内容搬运自UGUI UI重建二三事(二) - 知乎 )
- Text控件 文本的内容及颜色变化、设置是否支持富文本、更改换行模式、设置字体最大最小值、变更文本使用的对齐锚点、设置是否通过几何对齐、变更字体大小、变更是否支持水平及垂直溢出、修改行间距、变更字体样式(正常、斜体.....)。
- Image控件 颜色变化、变更显示类型(Simple、Sliced、Tiled、Filled)、变更是否应保留Sprite宽高比(Image.preserveAspect属性的变更),FillCenter属性变更(是否渲染平铺或切片图像的中心)、变更填充方式(Horizontal、Vertical、Radial360....)、变更图像填充率(fillAmount)、变更图像顺逆时针填充类型(Image.fillClockwise)、变更填充过程的原点(Image.FillOrigin)。
- RawImage控件 设置Texture、变更纹理使用的UVRcet。
- Shadow效果 改变效果的距离(effectDistance)及颜色(effectColor)、变更是否使用Graphic中的Alpha透明度(useGraphicAlpha)。
- Mask控件 设置是否展示与Mask渲染区域相关的图形(showMaskGraphic),enable发生变化
- 所有继承MaskableGraphic的控件(Image、RawImage、RectMask2D、Text) 设置此图形是否允许被遮盖、enable发生变化、父节点发生变化(TransFromParentChanged)、在Hierachy面板上发生改变(HierachyChanged)。
- 所有继承自BaseMeshEffect的效果类(目前只看到Shadow及PositionAsUV1)的enable变化及应用动画属性的操作。
- 所有继承自Graphic的UI控件材质(material)发生变化。