redhat9i 发表于 2021-12-29 18:24

[UGUI源码二]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。


下面对这些步骤展开详细的讲解。
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<ICanvasElement> m_LayoutRebuildQueue ,布局重建队列,当UI元素的布局需要更新时将其加入队列
[*]IndexedSet<ICanvasElement> 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;
            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.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;
                if (ObjectValidForUpdate(element))
                {
                  //图形重建,分别传入PreRender、LatePreRender参数
                  element.Rebuild((CanvasUpdate)i);
                }
            }
            catch (Exception e)
            {
                Debug.LogException(e, instance.m_GraphicRebuildQueue.transform);
            }
      }
    }

    //通知图形重建完成
    for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
      m_GraphicRebuildQueue.GraphicUpdateComplete();

    instance.m_GraphicRebuildQueue.Clear();
    m_PerformingGraphicUpdate = false;
    UISystemProfilerApi.EndSample<span class="p">(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.MarkLayutForRebuild(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);
<span class="err">
    for (var i = 0; i < components.Count; i++)
      ((IMeshModifier)components).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重建二三事(二) - 知乎 )
<ul><li data-pid="5qoK5baI">Text控件 文本的内容及颜色变化、设置是否支持富文本、更改换行模式、设置字体最大最小值、变更文本使用的对齐锚点、设置是否通过几何对齐、变更字体大小、变更是否支持水平及垂直溢出、修改行间距、变更字体样式(正常、斜体.....)。
页: [1]
查看完整版本: [UGUI源码二]Unity UI重建(Rebuild)源码分析