前言
這篇文章想寫的目的也是因為我面試遇到了面試官問我關于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更新的流程來選擇不同的繪制方法,
// 來重建Graphic
element.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();
else
ProcessTouchEvents();
}
// 構造當前的輸入資料
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);
}
else
RemovePointerData(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吧,咖啡就不用請我喝了,屑屑(比心)
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/289402.html
標籤:其他
