前言
这篇文章想写的目的也是因为我面试遇到了面试官问我关于UGUI原理性的问题,虽然我看过,但是并没有整理出完整的知识框架,导致描述的时候可能自己都是不清不楚的。自己说不清楚的东西,别人就等于你不会。每当学完一个东西的时候,应该会大体框架流程,具体实现细节有所了解,然后整理出来,以备日后查阅。人的记忆是有限的,如果不记下来,每次翻的都是别人的博客,这样其实是一个很不好的习惯。所以决定整理出一些自己关于UGUI的了解。以上只是我的牢骚,下面开始今天的内容。
学习方法论与要点整理
在框架之前我们需要先思考,我们要从哪里入手来看这个框架。引用我最近经常听到的一句话吧,你写的这个东西有什么用呢,他解决了什么问题。这确实是需要值得思考的问题,我们为什么要用UGUI,他人给我们提供了什么东西,为我们解决了什么问题。那么从这个思路开始,我们来梳理这个框架。
学习方法论
我们要看一个框架,会发现这个框架的代码实在是太多了,不同于我们我们平时写模块化的逻辑,点开一个脚本基本上只能看到很小部分框架业务逻辑实现,那么下面就推荐我看代码的思路。
- 用全宇宙最强编辑器生成UML图,通过观看框架结构接口定义,来实现对整个框架结构有个概览。
- 下载源码,尝试写代码对单个功能点的逻辑进行断点调试,跟着断点查看整个框架代码的执行流程
举个例子,假如你需要看Image的绘制流程,只需要写个测试代码,对Image的color进行赋值,会触发Canvas刷新,对Image进行更新,这时候就可以跟着断点走进去看到整个逻辑执行顺序
UGUI功能点
让我们分析一个UI界面所需要的要点,抛开业务逻辑,那么一个UI框架需要为我们提供的是
- UI要素的渲染
- 可控的渲染层级排序
- UI布局的自适应
- 交互事件的检测与响应
- 面向功能性的UI组件
UGUI框架解析
UGUI框架结构概览
框架UML图如下, 点击试试看能不能放大
UGUI框架结构几个核心的类如下:
- UIBehaviour 抽象类 继承MonoBehaviour,提供核心事件驱动
- Graphic 抽象类 提供绘制接口,各种Dirty方法来注册到更新列队
- EventSystem 事件中心,负责处理各种输入,光线投射,以及发送事件
- BaseInputModule 输入模块基类,负责发送各种输入事件到GameObject
- BaseRaycaster 光线投射基类
- EventInterfaces 注意这是一个脚本,他定义了所有EventSystem事件方法接口
其他功能性的组件,以及布局我就不一一列举了,具体请查看源码
渲染以及重绘流程
在unity中我们绘制一个图形需要几个基本要素,Mesh,Material,Texture,以下的几个要素都被定义在了Graphic中。这些属性都是被CanvasRenderer这个组件托管进行渲染的,我们可以再编写组件的时候对这些要素进行定义和替换,但是最后都需要赋值到CanvasRenderer中由Canvas进行渲染。
网格绘制
在unity中我们绘制一个图形需要几个基本要素,Mesh,Material,Texture,以下的几个要素都被定义在了Graphic中,那么首先让我们看一下UGUI源码的例子,看他是怎么对一个网格进行绘制的
using UnityEngine;
using UnityEngine.UI;
[ExecuteInEditMode]
// 这是一个简易的基于UGUI的Image实现
public class SimpleImage : Graphic
{// 这里是 Graphic 可重写的绘制网格的接口protected override void OnPopulateMesh(VertexHelper vh){// VertexHelper 这是UGUI一个用于构建网格的帮助类// 他可以用于填充顶点数据,设置三角面片信息,并缓存他们// 最后用这些数据填充并构建一个mesh// 构建顶点数据// 此处两个Vector2结构体的数据来源为当前Gameobject的Rectransform// 因为RectTransform的数据是基于布局自适应的Vector2 corner1 = Vector2.zero;Vector2 corner2 = Vector2.zero;corner1.x = 0f;corner1.y = 0f;corner2.x = 1f;corner2.y = 1f;corner1.x -= rectTransform.pivot.x;corner1.y -= rectTransform.pivot.y;corner2.x -= rectTransform.pivot.x;corner2.y -= rectTransform.pivot.y;corner1.x *= rectTransform.rect.width;corner1.y *= rectTransform.rect.height;corner2.x *= rectTransform.rect.width;corner2.y *= rectTransform.rect.height;vh.Clear();UIVertex vert = UIVertex.simpleVert;vert.position = new Vector2(corner1.x, corner1.y);vert.color = color;vh.AddVert(vert);vert.position = new Vector2(corner1.x, corner2.y);vert.color = color;vh.AddVert(vert);vert.position = new Vector2(corner2.x, corner2.y);vert.color = color;vh.AddVert(vert);vert.position = new Vector2(corner2.x, corner1.y);vert.color = color;vh.AddVert(vert);// 此处指定了三角面片的绘制顺序 顺时针方向// 0 (0,0) 顶点在数组中的index(x坐标,y坐标)//// 1 (0,1) > 2 (1,1) // ...........// . . .// ^ . . .// . . .// ...........// 0 (0,0) < 3 (1,1)vh.AddTriangle(0, 1, 2);vh.AddTriangle(2, 3, 0);}
}
通过以上的例子应该对网格绘制原理有所了解,并且也能通过官方例子来实现一个简易的Image绘制。
材质
在UGUI的Image中,就算我们没有给他其特定的材质,Image还是能够进行正常的渲染。Unity为我们设置了一个默认的材质定义,他的位置在Sahder面板中的UI/Default。以下静态方法被定义在Canvas组件中
public static Material GetDefaultCanvasMaterial();public static Material GetDefaultCanvasTextMaterial();public static Material GetETC1SupportedCanvasMaterial();
重绘流程
由于Canvas没有开源代码,没办法看到底层的渲染流程,只能通过调整参数来控制他的渲染逻辑,下面讲一下主要的几个控制参数。
RenderMode
- ScreenSpace-Overlay 屏幕空间并覆盖在屏幕上,也就是说,当前渲染层级永远在最上层
- ScreenSpace-Camera 屏幕空间并由Camera来控制渲染,这个多了一层Camera套娃,可以来控制渲染和层级
- WorldSpace 世界空间,这个渲染是三维空间的,可以用z轴进行渲染排序
PixelPerfect 强制于像素对齐,无特殊需要建议关闭,会影响性能
SortOrder 在ScreenSpace-Overlay模式下用于控制Canvas的渲染顺序
SortingLayer 世界空间下画布的渲染层
OrderInLayer 画布在当前渲染层下的渲染顺序
AdditionalShaderChannels 顶点数据的遮罩
那么下面我们再来说一下UGUI的重绘流程,在UGUI中渲染的核心就是Canvas组件和CanvasRenderer组件,Graphic只是为了填充渲染的必要元素,他并不直接参与绘制,那么让我们看一下他是怎么触发整个Canvas重新绘制的,这是Canvas中重绘的关键事件。
public static event WillRenderCanvases willRenderCanvases;
那么让我们思考一下,什么情况下需要触发重新绘制,就是当前Canvas下需要绘制的物体属性变更的时候,一共有以下几个点。
- 材质变更
- 贴图变更
- Mesh信息变更
- 位置变更
- 显示与隐藏
让我们看一下UGUI源码这个简单的例子。
// 设置顶点颜色public virtual Color color { get { return m_Color; } set { if (SetPropertyUtility.SetColor(ref m_Color, value)) SetVerticesDirty(); } }// 设置材质public virtual Material material{get{return (m_Material != null) ? m_Material : defaultMaterial;}set{if (m_Material == value)return;m_Material = value;SetMaterialDirty();}}// 材质变化 将自身注册到重绘列表中public virtual void SetMaterialDirty(){if (!IsActive())return;m_MaterialDirty = true;CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);if (m_OnDirtyMaterialCallback != null)m_OnDirtyMaterialCallback();}// 顶点变化 将自身注册到重绘列表中public virtual void SetVerticesDirty(){if (!IsActive())return;m_VertsDirty = true;CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);if (m_OnDirtyVertsCallback != null)m_OnDirtyVertsCallback();}
以上的方法都是在Graphic类中,一个用于一个顶点颜色,也就是顶点信息改变,一个用于设置材质,也就是材质改变,相对应的每个需要渲染的属性都有其属性设置器,我们可以看到他在set的时候调用了一个相对应的Dirty的方法,他会在方法中把自身注册进CanvasUpdateRegistry这个类中,这个类是重绘的关键,让我们看一下这个类主要实现了什么逻辑,以下列举几个关键方法
public class CanvasUpdateRegistry{// 构造方法,将PerformUpdate方法注册进Canvas即将渲染前会调用的事件protected CanvasUpdateRegistry(){Canvas.willRenderCanvases += PerformUpdate;}// 将组件注册进布局重建的列表public static void RegisterCanvasElementForLayoutRebuild(ICanvasElement element){instance.InternalRegisterCanvasElementForLayoutRebuild(element);}// 将组件注册进渲染重建的列表public static void RegisterCanvasElementForGraphicRebuild(ICanvasElement element){instance.InternalRegisterCanvasElementForGraphicRebuild(element);}// 更新布局与渲染的方法private void PerformUpdate(){// 由于篇幅限制这里只贴了更新Graphic的部分,实际上还有布局更新的部分,请自行查阅源码var graphicRebuildQueueCount = m_GraphicRebuildQueue.Count;for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++){UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStrings[i]);for (var k = 0; k < graphicRebuildQueueCount; k++){try{var element = m_GraphicRebuildQueue[k];if (ObjectValidForUpdate(element))//这个是关键方法,他根据Canvas更新的流程来选择不同的绘制方法,// 来重建Graphicelement.Rebuild((CanvasUpdate)i);}catch (Exception e){Debug.LogException(e, m_GraphicRebuildQueue[k].transform);}}UnityEngine.Profiling.Profiler.EndSample();}for (int i = 0; i < graphicRebuildQueueCount; ++i)m_GraphicRebuildQueue[i].GraphicUpdateComplete();m_GraphicRebuildQueue.Clear();m_PerformingGraphicUpdate = false;UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Render);}}
Tips:先描述一下 ICanvasElement 这个类的定义,这个类是顾名思义他就是Canvas元素,而Canvas主要负责绘制,所以他是一个所有需要带绘制属性组件的基类,这个类中定义了 Rebuild 方法,可以在这个方法对主要元素进行重新绘制,顺便贴上在 Graphic 中的ReBuild实现
// 核心的重绘方法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;}}
接着上面说,我们可以看到这个类主要做了这几件事情,在构造的时候将更新方法注册进 Canvas.willRenderCanvases 中,这个事件是会在Canvas更新前被调用,然后可以利用 RegisterCanvasElementForGraphicRebuild 这个方法将需要绘制的元素注册进待更新的队列,然后在Canvas即将渲染时会执行这个类的更新方法 PerformUpdate,这时候他会根据自定义的CanvasUpdate更新流程作为参数,调用每个element的Rebuild函数
总结:我们可以看上片段逻辑已经实现了一个绘制的基本要素和触发重绘的条件,形成了一个完整的闭环,
核心就是依赖的Canvas即将要渲染前的事件,来构造这样一段逻辑,主要是实现了观察者模式,算是一个比较高效率的实现,所以在我们做项目的UGUI优化的时候也是主要观察这个willRenderCanvases,被注册的次数,尽量用Canvas来隔开不必要的重绘次数,当然这需要和Drawcall之间进行衡量,找出性能的瓶颈点来进行优化。
事件触发以及响应
在看之前首先让我们思考,如果让我们编写一个事件系统的话,我们会怎么实现这样一个功能。
很明显,事件就是一个观察者模式,他有一个事件中心(EventSystem),以及众多的观察者(例: Button),当然我们也需要对事件进行类型区分,比如说 OnClick, OnDrag,很明显他们实现的功能职责不同,既然我们要区别这些事件类型,那么我们必然需要一个用户输入的手势数据(例:PointerEventData)用来判断区分不同的事件触发,既然已经整理清楚思路了,就让我们看一下UGUI中模块的定义。
模块定义
他主要根据文件目录分了以下几个模块,输入数据模块(EventData), 输入模块(InputModules), 射线模块(Raycaster), UI元素模块(UIElement)
事件绑定
让我们先看一段我们平时在代码中会编写的方法触发逻辑,这里主要利用了Button。
void Start(){_button = GetComponent<Button>();_button.onClick.AddListener(func);}
这个方法主要是把 func 托管给了Button这个组件,然后来进行事件的触发,那么让我们看看Button里面做了什么把。
public class Button : Selectable, IPointerClickHandler, ISubmitHandler{public class ButtonClickedEvent : UnityEvent {}private ButtonClickedEvent m_OnClick = new ButtonClickedEvent();// 方法绑定在这个事件中public ButtonClickedEvent onClick{get { return m_OnClick; }set { m_OnClick = value; }}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();}}// Click事件的接口public interface IPointerClickHandler : IEventSystemHandler{void OnPointerClick(PointerEventData eventData);}
我们可以发现组件想要触发事件主要依赖于实现UGUI的接口,然后框架会调用接口中的方法来触发组件的逻辑
能触发这种不同类型的事件的接口有很多,他们都被定义在了 EventInterfaces 这个脚本中,所有的接口都继承了 IEventSystemHandler 这个事件触发的基类。
事件检测以及触发
我们要触发事件,必然需要一个核心驱动来获取我们每帧的手势输入,我们先看一下事件触发的核心驱动部分逻辑。
// 这是挂载在场景中的核心驱动模块public class EventSystem : UIBehaviour{// 输入模块private List<BaseInputModule> m_SystemInputModules = new List<BaseInputModule>();// 当前的输入模块private BaseInputModule m_CurrentInputModule;// 事件中心列表,实际上当前场景只允许有一个private static List<EventSystem> m_EventSystems = new List<EventSystem>();// 每帧驱动输入模块的逻辑protected virtual void Update(){// 核心逻辑,截取部分,具体的查阅源码if (current != this)return;// 更新驱动输入模块TickModules();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();}}}
以上逻辑可以看到EventSystem模块主要负责和驱动相应的输入模块,具体实现要延迟到各个输入模块中,要注意的点是,输入数据会在这一帧被构造,在下一帧被执行, 那么我们以 TouchInputModule 为例,来看看他实现了什么功能。
// 这是一个触摸的输入模块public class TouchInputModule : PointerInputModule{// 定义了输入的数据private PointerEventData m_InputPointerEvent;// 每帧更新的方法,执行当前的输入事件public override void UpdateModule(){if (!eventSystem.isFocused){if (m_InputPointerEvent != null && m_InputPointerEvent.pointerDrag != null && m_InputPointerEvent.dragging)// 这是核心逻辑,这里执行了对应的数据的事件,ExecuteEvents.Execute(m_InputPointerEvent.pointerDrag, m_InputPointerEvent, ExecuteEvents.endDragHandler);m_InputPointerEvent = null;}m_LastMousePosition = m_MousePosition;m_MousePosition = input.mousePosition;}// 构造当前的输入数据public override void Process(){if (UseFakeInput())FakeTouches();elseProcessTouchEvents();}// 构造当前的输入数据private void ProcessTouchEvents(){for (int i = 0; i < input.touchCount; ++i){// 这里终于能看见调用Unity的API来通过GetTouch来获取当前的触摸点Touch touch = input.GetTouch(i);if (touch.type == TouchType.Indirect)continue;bool released;bool pressed;var pointer = GetTouchPointerEventData(touch, out pressed, out released);// 在这个方法里面会对当前的 m_InputPointerEvent 进行赋值// 这样当前帧的输入信息的构造好了ProcessTouchPress(pointer, pressed, released);if (!released){ProcessMove(pointer);ProcessDrag(pointer);}elseRemovePointerData(pointer);}}}
我们可以看到这个输入模块每帧构造当前的输入数据以及每帧驱动模块进行事件的分发,并且他调用了核心的方法 ExecuteEvents.Execute(),这个方法会负责触发对应接口的方法,让我们看一下这个方法的定义
// 这里把需要执行的方法定义在了外部public delegate void EventFunction<T1>(T1 handler, BaseEventData eventData);private static readonly EventFunction<IPointerEnterHandler> s_PointerEnterHandler = Execute;private static void Execute(IPointerEnterHandler handler, BaseEventData eventData){handler.OnPointerEnter(ValidateEventData<PointerEventData>(eventData));}public static bool Execute<T>(GameObject target, BaseEventData eventData, EventFunction<T> functor) where T : IEventSystemHandler{// 这个方法主要实现了收集gameObject上所有的 IEventSystemHandler 组件var internalHandlers = ListPool<IEventSystemHandler>.Get();GetEventList<T>(target, internalHandlers);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;ListPool<IEventSystemHandler>.Release(internalHandlers);return handlerCount > 0;}// 这个方法主要实现了收集gameObject上所有的 IEventSystemHandler 组件private static void GetEventList<T>(GameObject go, IList<IEventSystemHandler> results) where T : IEventSystemHandler{// Debug.LogWarning("GetEventList<" + typeof(T).Name + ">");if (results == null)throw new ArgumentException("Results array is null", "results");if (go == null || !go.activeInHierarchy)return;var components = ListPool<Component>.Get();go.GetComponents(components);var componentsCount = components.Count;for (var i = 0; i < componentsCount; i++){if (!ShouldSendToComponent<T>(components[i]))continue;// Debug.Log(string.Format("{2} found! On {0}.{1}", go, s_GetComponentsScratch[i].GetType(), typeof(T)));results.Add(components[i] as IEventSystemHandler);}ListPool<Component>.Release(components);// Debug.LogWarning("end GetEventList<" + typeof(T).Name + ">");}
主要需要分析的是Execute这个方法的 functor 参数,他实现的功能就是相对应需要执行的方法如OnPointerEnter,定义在了外部,通过传参的方法来执行对应的方法,
参数还有一个gameObject,这是一个必须的参数,这个gameObject在构建输入数据的时候已经被赋值,并随着输入数据进行传递,让我们来看一下UGUI是怎么获取当前需要接受这个事件的gameObject的。
// 所有输出模块的基类public abstract class BaseInputModule : UIBehaviour{// 接受事件检测的对象列表protected List<RaycastResult> m_RaycastResultCache = new List<RaycastResult>();}
public class EventSystem : UIBehaviour{// 这个方法调用了会获取所有需要检测的组件列表public void RaycastAll(PointerEventData eventData, List<RaycastResult> raycastResults){raycastResults.Clear();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;module.Raycast(eventData, raycastResults);}raycastResults.Sort(s_RaycastComparer);}}
RaycastAll方法会获取所有需要响应检测的列表,并对其进行排序,然后在构造输入数据的时候获取第一个被检测的对象,这个对象就是那个GameObject,其他具体检测逻辑请参考Canvas的渲染模式以及源码
对于Graphic对象来说,我们有两种方式控制其射线的检测逻辑,一种是继承并重写Graphic.Raycast方法,还有一种就是让子类同时继承 ICanvasRaycastFilter 接口, 实现其接口方法 IsRaycastLocationValid 来进行来实现过滤。
总结
UGUI的逻辑已经比较完善了,源框架代码也很规范,希望这篇文章可以帮助大家快速的通读UGUI的源码相关逻辑,共同进步,哦耶~
好像少了一个部分还没说就是关于自适应布局逻辑及更新这块,还有蒙板逻辑,其他部分留着下次再补充吧
布局组件这块已经更新好啦,>>>>>> 戳这里 Unity UGUI自适应布局系统详解<<<<<<
那么今天就教程就到这里结束了,如果觉得我说的有用的话就在我的>>>>>> 戳这里 github项目<<<<<<点上一个小小的star吧,咖啡就不用请我喝了,屑屑(比心)