GUI原理解析-AutoLayout
UGUI中提供的RectTransform本身足够灵活,可以处理许多不同类型的布局。但基本上是子节点去根据父节点进行适配。 而 自动布局系统(AutoLayout)提供了根据子节点自动调整自身元素的大小。
自动布局系统基础元素有 布局元素(LayoutElement)
与 布局控制器(LayoutGroup)
. 类结构图如下:
注意:UGUI中的Image
InputField
Text
以及 LayoutGroup
都实现了ILayoutElement
接口。所以都是为布局元素
。
工作原理
LayoutUtility :用于查询布局元素的minimum, preferred, and flexible sizes的工具类(静态类)
LayoutRebuilder: 用于管理 CanvasElement 的 layout 以及 rebuilding (重建)
1.通过在 ILayoutElement 组件上调用 CalculateLayoutInputHorizontal 来计算布局元素的最小宽度、偏好宽度和灵活宽度。此过程以自下而上的顺序执行,即子项的计算先于父项,这样父项可以在自己的计算中参考子项的信息。
2.通过在 ILayoutController 组件上调用 SetLayoutHorizontal 来计算和设置布局元素的有效宽度。此过程自上而下的顺序执行,即子项的计算晚于父项,因为子项宽度的分配需要基于父项中可用的完整宽度。在此步骤之后,布局元素的矩形变换便有了新的宽度。
3.通过在 ILayoutElement 组件上调用 CalculateLayoutInputVertical 来计算布局元素的最小高度、偏好高度和灵活高度。此过程以自下而上的顺序执行,即子项的计算先于父项,这样父项可以在自己的计算中参考子项的信息。
4.通过在 ILayoutController 组件上调用 SetLayoutVertical 来计算和设置布局元素的有效高度。此过程自上而下的顺序执行,即子项的计算晚于父项,因为子项高度的分配需要基于父项中可用的完整高度。在此步骤之后,布局元素的矩形变换便有了新的高度。
从上面可以看出,自动布局系统首先计算宽度,然后计算高度。因此,计算的高度可取决于宽度,但计算的宽度决不能取决于高度。
以上文本来自Untiy官方文档,接下来我们结合代码进行理解:
重建的过程
重建的时机
首先我们需要关注 CanvasUpdateRegistry
, 首先在这里监听 Canvas.willRenderCanvases(即将渲染) 事件,大概就等同于每帧调用。
CanvasUpdateRegistry 做的内容大致上就是维护布局重建队列于图形重建队列。等到在 willRenderCanvases
时依次调用Rebuild
方法执行重建操作。
以下是精简的代码,添加了注释,删除了各种错误判断、try…catch… 等等。
public class CanvasUpdateRegistry {
// 布局重建队列(UGUI中自定义的类型IndexedSet)
private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>();
// 图形重建队列(UGUI中自定义的类型IndexedSet)
private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>();
// 监听事件
protected CanvasUpdateRegistry() {
Canvas.willRenderCanvases += PerformUpdate;
}
private void PerformUpdate() {
// 对Layout做排序操作,排序规则是对比父节点的数量,或者叫做深度。 深度越浅排在越前
m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
// 执行Rebuild的不同阶段 Prelayout Layout PostLayout
for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++) {
for (int j = 0; j < m_LayoutRebuildQueue.Count; j++) {
var rebuild = instance.m_LayoutRebuildQueue[j];
rebuild.Rebuild((CanvasUpdate)i); // <-- 重点执行rebuild操作
}
}
for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
m_LayoutRebuildQueue[i].LayoutComplete(); // 通知布局队列中的元素重建完成
instance.m_LayoutRebuildQueue.Clear();
ClipperRegistry.instance.Cull(); // 进行裁剪
// 执行Rebuild的不同阶段 PreRender LatePreRender
for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++) {
for (var k = 0; k < instance.m_GraphicRebuildQueue.Count; k++) {
var element = instance.m_GraphicRebuildQueue[k];
element.Rebuild((CanvasUpdate)i); // <-- 重点执行rebuild操作
}
}
for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
m_GraphicRebuildQueue[i].GraphicUpdateComplete(); // 最后通知图形重建完成
instance.m_GraphicRebuildQueue.Clear();
}
// 注册到布局重建队列
private bool InternalRegisterCanvasElementForLayoutRebuild(ICanvasElement element) {
if (m_LayoutRebuildQueue.Contains(element))
return false;
return m_LayoutRebuildQueue.AddUnique(element);
}
// 注册到图形重建队列
private bool InternalRegisterCanvasElementForGraphicRebuild(ICanvasElement element) {
return m_GraphicRebuildQueue.AddUnique(element);
}
// 取消注册布局重建队列
private void InternalUnRegisterCanvasElementForLayoutRebuild(ICanvasElement element) {
element.LayoutComplete();
instance.m_LayoutRebuildQueue.Remove(element);
}
// 取消注册图形重建队列
private void InternalUnRegisterCanvasElementForGraphicRebuild(ICanvasElement element) {
element.GraphicUpdateComplete();
instance.m_GraphicRebuildQueue.Remove(element);
}
}
从代码中可以看出,如果一个UI需要被重建, 那么首先需要注册到对应的队列中。 然后等待 willRenderCanvases
事件。
需要被注册的元素需要实现 ICanvasElement
接口。UGUI中的几乎所有UI组件都继承自这个接口。
什么时候需要被重建
重建的时机是通过设置 “脏数据” 来实现的。大致就是 SetLayoutDirty
SetVerticesDirty
SetMaterialDirty
。
public abstract class Graphic : UIBehaviour, ICanvasElement {
public virtual void SetLayoutDirty() {
LayoutRebuilder.MarkLayoutForRebuild(rectTransform); // 这个是静态方法, 可以手动调用
}
public virtual void SetVerticesDirty() {
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
}
public virtual void SetMaterialDirty() {
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
}
}
当然 有时候也可以发现 在OnDisable
OnValidate
等其他必要的时候进行标记。 另外如果组件上的属性发生了变化,需要重新触发计算的话可以 LayoutRebuilder.MarkLayoutForRebuild
来进行标记。也可以使用 LayoutRebuilder.ForceRebuildLayoutImmediate
进行立即计算,但是这样比较耗费性能。
布局计算的顺序
我们知道使用 LayoutRebuilder.MarkLayoutForRebuild
来进行布局重建的标记。而 LayoutRebuilder
在内部实际上给 CanvasUpdateRegistry
传递的对象是 LayoutRebuilder
.那么在执行重建布局重建时实际上是调用了LayoutRebuilder.Rebuild
. 删减部分代码后如下
/// <summary>
/// Wrapper class for managing layout rebuilding of CanvasElement.
/// </summary>
public class LayoutRebuilder : ICanvasElement {
static ObjectPool<LayoutRebuilder> s_Rebuilders = new ObjectPool<LayoutRebuilder>(null, x => x.Clear());
private void Initialize(RectTransform controller) {
m_ToRebuild = controller; // 初始化 controller 是实际上需要被重建的对象
}
private void Clear() {
m_ToRebuild = null; // 放回对象池时清空
}
// 强制重建, 有性能问题不推荐
public static void ForceRebuildLayoutImmediate(RectTransform layoutRoot) {
var rebuilder = s_Rebuilders.Get(); // 从对象池中获取一个 LayoutRebuilder
rebuilder.Initialize(layoutRoot); // 使用 LayoutRebuilder 包装需要重建的 RectTransform
rebuilder.Rebuild(CanvasUpdate.Layout); // 执行重建操作
s_Rebuilders.Release(rebuilder); // 将 LayoutRebuilder 回收回对象池
}
// 由于注册到 CanvasUpdateRegistry 的是经过 LayoutRebuilder 包装的。 所以实际rebuild的时候是执行这里的操作
// Unity官方文档中描述的原理既对应以下代码
public void Rebuild(CanvasUpdate executing) {
switch (executing)
{
case CanvasUpdate.Layout:
// 1.通过在 ILayoutElement 组件上调用 CalculateLayoutInputHorizontal 来计算布局元素的最小宽度、偏好宽度和灵活宽度。此过程以自下而上的顺序执行,即子项的计算先于父项,这样父项可以在自己的计算中参考子项的信息。
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputHorizontal());
// 2.通过在 ILayoutController 组件上调用 SetLayoutHorizontal 来计算和设置布局元素的有效宽度。此过程自上而下的顺序执行,即子项的计算晚于父项,因为子项宽度的分配需要基于父项中可用的完整宽度。在此步骤之后,布局元素的矩形变换便有了新的宽度。
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutHorizontal());
// 3.通过在 ILayoutElement 组件上调用 CalculateLayoutInputVertical 来计算布局元素的最小高度、偏好高度和灵活高度。此过程以自下而上的顺序执行,即子项的计算先于父项,这样父项可以在自己的计算中参考子项的信息。
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputVertical());
// 4.通过在 ILayoutController 组件上调用 SetLayoutVertical 来计算和设置布局元素的有效高度。此过程自上而下的顺序执行,即子项的计算晚于父项,因为子项高度的分配需要基于父项中可用的完整高度。在此步骤之后,布局元素的矩形变换便有了新的高度。
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutVertical());
break;
}
}
private void PerformLayoutControl(RectTransform rect, UnityAction<Component> action) {
if (rect == null) return;
var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutController), components);
StripDisabledBehavioursFromList(components);
if (components.Count > 0) {
for (int i = 0; i < components.Count; i++)
if (components[i] is ILayoutSelfController)
action(components[i]);
for (int i = 0; i < components.Count; i++)
if (!(components[i] is ILayoutSelfController))
action(components[i]);
for (int i = 0; i < rect.childCount; i++)
PerformLayoutControl(rect.GetChild(i) as RectTransform, action);
}
ListPool<Component>.Release(components);
}
private void PerformLayoutCalculation(RectTransform rect, UnityAction<Component> action) {
if (rect == null) return;
var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutElement), components);
// 重点: 通过递归孩子节点一个个进行计算
if (components.Count > 0 || rect.GetComponent(typeof(ILayoutGroup))) {
for (int i = 0; i < rect.childCount; i++)
PerformLayoutCalculation(rect.GetChild(i) as RectTransform, action);
for (int i = 0; i < components.Count; i++)
action(components[i]);
}
ListPool<Component>.Release(components);
}
public static void MarkLayoutForRebuild(RectTransform rect) {
// layoutRoot 大致就是一直循环获取父节点中的ILayoutGroup,最终得到最上层的 ILayoutGroup
MarkLayoutRootForRebuild(layoutRoot); // <-- 注意: 这里只标记最顶层的 ILayoutGroup
ListPool<Component>.Release(comps);
}
private static void MarkLayoutRootForRebuild(RectTransform controller) {
if (controller == null) return;
var rebuilder = s_Rebuilders.Get();
rebuilder.Initialize(controller);
if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder))
s_Rebuilders.Release(rebuilder);
}
public void LayoutComplete() {
s_Rebuilders.Release(this); // 布局计算完成后释放(放回对象池)
}
}
布局重建过程中做了什么
从代码中可以看出 先是 ILayoutElement
计算宽度,然后 ILayoutController
再设置布局的宽度。ILayoutElement
计算高度,然后ILayoutController
再设置布局的高度。 而且在执行 PerformLayoutControl
计算时先计算所有的 ILayoutSelfController
再计算ILayoutSelfController
.
那么这两个接口具体有那些不同呢。 首先看下类图:
以实现 ILayoutSelfController
(通过计算自身的RectTransform) 或 ILayoutGroup
(通过计算子节点的RectTransform) 进行区分如下:
ILayoutSelfController: AspectRatioFitter ContentSizeFitter
ILayoutGroup: GridLayoutGroup HorizontalOrVerticalLayoutGroup ScrollRect
注意:这里虽然对接口进行了区分,但是实际上并没有需要特别实现的接口。
以 ContentSizeFitter 为例, 当执行布局重建的时候,ContentSizeFitter组件会对当前RectTransform计算宽高的函数为LayoutUtility.GetMinSize
或LayoutUtility.GetPreferredSize
。具体计算的流程就是获取当前RectTransform 上所有的 ILayoutElement 并获取到对应的尺寸。
public class ContentSizeFitter : UIBehaviour, ILayoutSelfController {
private void HandleSelfFittingAlongAxis(int axis) {
if (fitting == FitMode.MinSize)
rectTransform.SetSizeWithCurrentAnchors((RectTransform.Axis)axis, LayoutUtility.GetMinSize(m_Rect, axis));
else
rectTransform.SetSizeWithCurrentAnchors((RectTransform.Axis)axis, LayoutUtility.GetPreferredSize(m_Rect, axis));
}
public virtual void SetLayoutHorizontal() {
HandleSelfFittingAlongAxis(0);
}
public virtual void SetLayoutVertical() {
HandleSelfFittingAlongAxis(1);
}
}
以 VerticalLayoutGroup
为例, 当执行布局重建的时候通过计算子节点的尺寸加上间隔等等尺寸最终算出自身的尺寸。
另外再次回忆下在 CanvasUpdateRegistry.PerformUpdate
中先进行布局重建,这样能够确定各个节点的尺寸。然后再进行图形重建,这样就能根据尺寸进行图像的展示。
通过 LayoutRebuilder 标记需要进行布局重建的根布局节点
调用
执行Rebuild并递归对子节点进行调用
ILayoutElement
CanvasUpdateRegistry
LayoutRebuilder
设置正确值
性能优化
为了减少UI重建的频率,我们需要减少触发SetLayoutDirty
SetVerticesDirty
SetMaterialDirty
这些方法的频率。导致这些方法被调用的原因可以参考UGUI UI重建二三事(二).
为了检测优化是否有成效,我们可以直接进行断点调试。 但是这样未免也太麻烦了。这里提供一个脚本来收集相关信息。
参考文档
unity官方文档自动布局(AutoLayout)