1 | UGUI原理簡述
1.1 原理
首先得生成顯示UI用的Mesh,如圖1-1所示,一個矩形的Mesh,由4個頂點,2個三角形組成,每個頂點都包含UV坐標,如果需要調整顏色,還需要提供頂點色。就像UGUI中調節圖片和文本的顏色,其實就是設置它們的頂點色而已。
然后將網格和紋理信息送入GPU中渲染,如圖1-2所示一個最簡單的UI元素就渲染出來了。如果再繼續排列組合其他的UI元素,那么一個游戲界面就誕生了。所謂的UI其實就是用一個正交攝像機端端地看着若干個平面網格。
以上只是UI的最基本的顯示原理,雖然這樣是可以拼出來UI的,但是我們是無法應用在游戲中的。比如,DrawCall需要合並,UI的點擊操作事件、UI基礎組件等等,所以就誕生了偉大的UGUI。帶着界面打開慢和界面操作慢的兩個問題,我們開始分析UGUI的源碼來尋找一些答案。
2丨WillRenderCanvases源碼解讀
使用UGUI都知道只要添加Canvas將會打斷和之前元素DrawCall的合並,每個Canvas都會開始一個全新的DrawCall,遺憾的是UGUI並沒有公開Canvas的源碼,通過反編譯DLL我們看到了Canvas中的部分C#源碼,如下代碼所示,當Canvas需要重繪的時候會調用SendWillRenderCanvases()方法。
Canvas.cs (部分代碼):
在CanvasUpdateRegistry的構建函數中可以看到Canvas.willRenderCanvases事件添加到this.PerformUpdate()方法中,UI發生變化一般分兩種情況,一種是修改了寬高這樣會影響到頂點位置需要重建Mesh,還有一種僅僅只修改了顯示元素,這樣並不會影響頂點位置,此時unity會在代碼中區別對待。
CanvasUpdateRegistry.cs(部分代碼):
public class CanvasUpdateRegistry { //...略 protected CanvasUpdateRegistry() { //構造函數處委托函數到PerformUpdate()方法中 //每次Canvas.willRenderCanvases就會執行PerformUpdate()方法 Canvas.willRenderCanvases += PerformUpdate; } private void PerformUpdate() { //開始BeginSample() //在Profiler中看到的標志性函數Canvas.willRenderCanvases耗時就在這里了 //EndSample() UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout); CleanInvalidItems(); m_PerformingLayoutUpdate = true; //需要重建的布局元素(RectTransform發生變化),首先需要根據子對象的數量對它進行排序。 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)) 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; // 布局構建結束,開始進行Mask2D裁切(詳細內容下面會介紹) ClipperRegistry.instance.Cull(); m_PerformingGraphicUpdate = true; //需要重建的Graphics元素(Image Text RawImage 發生變化) 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)) element.Rebuild((CanvasUpdate)i);//重建UI元素 } catch (Exception e) { Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform); } } } //這里需要思考的是,有可能一個Image對象,RectTransform和Graphics同時發生了修改,它們的更新含義不同需要區分對待 //1.修改了Image的寬高,這樣Mesh的頂點會發生變化,此時該對象會加入m_LayoutRebuildQueue隊列 //2.修改了Image的Sprite,它並不會影響頂點位置信息,此時該對象會加入m_GraphicRebuildQueue隊列 //所以上面代碼在遍歷的時候會分層 //for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++) //for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++) //Rebuild的時候會把層傳進去,保證Image知道現在是要更新布局,還是只更新渲染。 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); } }
3丨UI重建觸發事件提取
如圖3-1所示,在Profiler中看到的UGUI標志性耗時函數,其實就是在PerformUpdate方法中加的檢測。
通常UGUI界面操作卡大概率都是Canvas.SendWillRenderCanvases()方法耗時,需要檢查界面是否存在多余或者無用的重建情況。由於界面很多我們無法定位到到底是哪個界面下的哪個元素引起了網格重建。通過觀察CanvasUpdateRegistry.cs源代碼,我們發現需要網格重建的元素都被緩存在這兩個對象中。
CanvasUpdateRegistry.cs(部分代碼):
public class CanvasUpdateRegistry { //...略 //保存待重建布局元素(如:RectTransform變化) private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>(); //保存待重建渲染元素(如:Image變化) private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>(); }
接着我們來看看待重建布局元素和待重建渲染元素是如何被緩存起來的。如果某個Graphic發生布局位置或者渲染變化會分別加入這兩個不同的渲染隊列,等待下一次UI的重建。
Graphic cs(部分代碼)
public abstract class Graphic: UIBehaviour,ICanvasElement { //...略 protected override void OnBeforeTransformParentChanged() { GraphicRegistry.UnregisterGraphicForCanvas(canvas, this); //布局發生變化 LayoutRebuilder.MarkLayoutForRebuild(rectTransform); //LayoutRebuilder.MarkLayoutForRebuild方法內部實現 //private static void MarkLayoutRootForRebuild(RectTransform controller) //{ // if (controller == null) // return; // var rebuilder = s_Rebuilders.Get(); // rebuilder.Initialize(controller); // 局部發生變化,會通過TryRegisterCanvasElementForLayoutRebuild()將自己加入待布局重建隊列 // if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder)) // s_Rebuilders.Release(rebuilder); //} } public virtual void SetMaterialDirty() { if (!IsActive()) return; m_MaterialDirty = true; //渲染發生變化,會通過RegisterCanvasElementForGraphicRebuild()將自己加入待渲染隊列 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); if (m_OnDirtyMaterialCallback != null) m_OnDirtyMaterialCallback(); } }
public abstract class Graphic: UIBehaviour,ICanvasElement { //...略 protected override void OnBeforeTransformParentChanged() { GraphicRegistry.UnregisterGraphicForCanvas(canvas, this); //布局發生變化 LayoutRebuilder.MarkLayoutForRebuild(rectTransform); //LayoutRebuilder.MarkLayoutForRebuild方法內部實現 //private static void MarkLayoutRootForRebuild(RectTransform controller) //{ // if (controller == null) // return; // var rebuilder = s_Rebuilders.Get(); // rebuilder.Initialize(controller); // 局部發生變化,會通過TryRegisterCanvasElementForLayoutRebuild()將自己加入待布局重建隊列 // if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder)) // s_Rebuilders.Release(rebuilder); //} } public virtual void SetMaterialDirty() { if (!IsActive()) return; m_MaterialDirty = true; //渲染發生變化,會通過RegisterCanvasElementForGraphicRebuild()將自己加入待渲染隊列 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); if (m_OnDirtyMaterialCallback != null) m_OnDirtyMaterialCallback(); } }
所以我們只需要在外面將這兩個對象撈出來遍歷一下就能知道到底是哪個界面下的哪個元素引起了網格重建。
using System.Collections.Generic; using System.Reflection; using UnityEngine; using UnityEngine.UI; public class NewBehaviourScript : MonoBehaviour { IList<ICanvasElement> m_LayoutRebuildQueue; IList<ICanvasElement> m_GraphicRebuildQueue; private void Awake() { System.Type type = typeof(CanvasUpdateRegistry); FieldInfo field = type.GetField("m_LayoutRebuildQueue", BindingFlags.NonPublic | BindingFlags.Instance); m_LayoutRebuildQueue = (IList<ICanvasElement>)field.GetValue(CanvasUpdateRegistry.instance); field = type.GetField("m_GraphicRebuildQueue", BindingFlags.NonPublic | BindingFlags.Instance); m_GraphicRebuildQueue = (IList<ICanvasElement>)field.GetValue(CanvasUpdateRegistry.instance); } private void Update() { for (int j = 0; j < m_LayoutRebuildQueue.Count; j++) { var rebuild = m_LayoutRebuildQueue[j]; if (ObjectValidForUpdate(rebuild)) { //Debug.LogFormat("{0}引起網格重建", rebuild.transform.name,); } } for (int j = 0; j < m_GraphicRebuildQueue.Count; j++) { var element = m_GraphicRebuildQueue[j]; if (ObjectValidForUpdate(element)) { Debug.LogFormat("{0}引起{1}網格重建", element.transform.name, element.transform.GetComponent<Graphic>().canvas.name); } } } private bool ObjectValidForUpdate(ICanvasElement element) { var valid = element != null; var isUnityObject = element is Object; //Here we make use of the overloaded UnityEngine.Object == null, that checks if the native object is alive. if (isUnityObject) valid = (element as Object) != null; return valid; } }
如下圖所示,當Canvas下某個元素引起了網格重建,我們可以知道具體是哪個UI元素。
4丨網格重建源碼解讀
Canvas.SendWillRenderCanvases()方法到底干了些什么?到底卡在那里?觀察代碼可以發現它需要調用每個ICanvasElement接口下的Rebuild()方法。UGUI的Image和Text組件都派生自Graphics類,並且都實現了ICanvasElement接口。
如下代碼所示,Rebuild()方法就是 UpdateGeometry(更新幾何網格)和 UpdateMaterial (更新材質),看來這和我們文章一開始講的UI繪制原理是一模一樣的。
Graphic.cs(部分代碼):
UpdateGeometry(更新幾何網格),就是確定每一個UI元素Mesh的信息,包括頂點數據、三角形數據、UV數據、頂點色數據。如下代碼所示,無論Image還是Text數據都會在OnPopulateMesh函數中進行收集,它是一個虛函數會在各自的類中實現。
頂點數據准備完畢后會調用canvasRenderer.SetMesh()方法來提交。很遺憾CanvasRenderer.cs並沒有開源,我們只能繼續反編譯看它的實現了,如下代碼所示,SetMesh()方法最終在C++中實現,畢竟由於UI的元素很多,同時參與合並頂點的信息也會很多,在C++中實現效率會更好。看到這里,我相信大家應該能明白UGUI為什么效率會被NGUI要高一些了,因為NGUI的網格Mesh合並都是在C#中完成的,而UGUI網格合並都是在C++中底層中完成的。
CanvasRenderer.cs(部分代碼)
總的來說 Profiler中看到Canvas.SendWillRenderCanvases()效率過低就是因為參數Rebuild()的元素過多,底層的代碼我們是無法修改,但是卻可以從策略上避免,文章后面我會講講我是如何避免它的。
再回到Canvas.SendWillRenderCanvases()方法,當網格需要重建時Unity底層會自行調用,在UGUI中只需要准備好需要參與Rebuild()的元素即可。
如果某個UI需要重建,首先需要將它加入“待重建隊列”,等到下一次Unity系統回調Canvas.SendWillRenderCanvases()方法時一起Rebuild()。如下代碼所示,只需要調用LayoutRebuilder.MarkLayoutForRebuild(rectTransform)方法就可以將該UI元素加入“待重建隊列”等待重建。
//Graphic.cs(部分代碼) public abstract class Graphic : UIBehaviour,ICanvasElement { //...略 //更新全部 public virtual void SetAllDirty() { //更新布局 SetLayoutDirty(); //更新頂點 SetVerticesDirty(); //更新材質 SetMaterialDirty(); } public virtual void SetLayoutDirty() { if (!IsActive()) return; //加入待布局隊列 LayoutRebuilder.MarkLayoutForRebuild(rectTransform); 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(); } }
由於元素的改變可分為布局變化、頂點變化、材質變化,所以分別提供了三個方法SetLayoutDirty();SetVerticesDirty();SetMaterialDirty();供選擇。舉個例子,在UI中如果調整了元素在Hierarchy中的父節點,如下代碼所示,在OnTransformParentChanged()方法中監聽,通過SetAllDirty();方法將該UI加入“待重建隊列”。
為什么UI發生變化一定要加入待重建隊列中呢?其實這個不難想象,一個UI界面同一幀可能有N個對象發生變化,任意一個發生變化都需要重建UI那么肯定會卡死。所以我們先把需要重建的UI加入隊列,等待一個統一的時機來合並。
Graphic.cs(部分代碼):
再比如需要修改Text文本的字體,由於字體大小的變化只會影響布局信息和頂點信息,那么就調用SetVerticesDirty();SetLayoutDirty();方法即可。
UI的網格我們都已經合並到了相同Mesh中,還需要保證貼圖、材質、Shader相同才能真正合並成一個DrawCall。UGUI開發時使用的是Sprite對象,其實Sprite對象只是在Texture上又封裝的一層數據結構,它記錄的是Sprite大小以及九宮格的區域,還有Sprite保存在哪個Atals中。如果很多界面Prefab引用了這個Sprite,它只是通過GUID進行了關聯,它並不會影響到已經在Prefab中保存的Sprite,這對后期調整圖集和DrawCall方便太多了,這也是UGUI比NGUI更方便的一點。