关于UGUI底层的小知识---上 (转雨松momo)
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更方便的一点。