Unity 补完计划(四):UGUI-5:LayoutRebuilder
前言
在 CanvasUpdateRegistry 中,我们介绍了 UGUI 系统是如何通过 CanvasUpdateRegistry 来控制 UI 元素的更新的,其中提到了 “Canvas 的更新可以分为布局与渲染两个阶段”。在监听到 Canvas.willRenderCanvases 事件后,CanvasUpdateRegistry 会遍历所有需要更新的 UI 组件,并根据 CanvasUpdate 所代表的阶段来调用对应的 Rebuild 方法。
接下来就让我们来看看 Layout 阶段是如何更新的。
ILayoutElement
在 UnityEngine.UI\UI\Core\Layout\ILayoutElement.cs 中定义了一些 LayoutSystem 所使用到的接口,包括 ILayoutElement、ILayoutController、ILayoutGroup 等。它们分别定义了 LayoutElements 及 LayoutControllers 的基本属性与方法。
ILayoutElement
ILayoutElement 定义了一个可以被 LayoutController 控制的基本布局单元,相关代码如下:
1 | public interface ILayoutElement |
ILayoutElement 定义了 CalculateLayoutInputHorizontal、CalculateLayoutInputVertical 两个方法,用于计算水平与垂直方向的布局输入;在 LayoutSystem 访问一个 LayoutElement 的属性之前,会先调用这两个函数来确保 LayoutElement 的属性是最新的。
此外就是一些布局相关的属性的,如 minWidth、preferredWidth、flexibleWidth 等,在梳理完 LayoutSystem 的使用 后,想必大家已经对这些属性熟稔于心。
值得注意的是 layoutPriority 属性,它定义了 LayoutElement 的布局优先级,当一个 gameObject 上有多个脚本均实现了 ILayoutElement 接口时,LayoutSystem 会根据 layoutPriority 的值来决定调用的顺序。当 layoutPriority 值为负数时,LayoutSystem 会忽略该 LayoutElement。
ILayoutController
ILayoutController 用于定义可以控制 LayoutElement 的布局组件,根据是修改自身的布局还是子物体的布局,ILayoutController 又派生出了 ILayoutGroup 与 ILayoutSelfController 两个接口。
1 | public interface ILayoutController |
ILayoutController 定义了 SetLayoutHorizontal、SetLayoutVertical 两个方法,用于设置水平与垂直方向的布局,在 HorizontalLayoutGroup、VerticalLayoutGroup、GridLayoutGroup 等布局组件中,这两个方法会被重写以实现不同的布局方式。
ILayoutGroup 继承自 ILayoutController,用于定义可以控制子物体布局的布局组件,如 HorizontalLayoutGroup、VerticalLayoutGroup、GridLayoutGroup 。
ILayoutSelfController 也继承自 ILayoutController,用于定义可以控制自身布局的布局组件,如 ContentSizeFitter、AspectRatioFitter 。
值得注意的是,派生出的两个接口并没有增加任何新的方法,只是为了更好地区分布局组件的功能。LayoutSystem 会根据继承的接口来决定布局的更新顺序,在 LayoutRebuilder 中会有所体现。
ILayoutIgnorer
ILayoutIgnorer 用于定义可以忽略布局的组件,如果一个 gameObject 上实现了 ILayoutIgnorer 接口并且 ignoreLayout 为 true,那么 LayoutSystem 会忽略该 gameObject 的布局。
1 | public interface ILayoutIgnorer |
LayoutRebuilder
LayoutRebuilder 是一个包装类,用于管理 UI 中需要重新布局的元素。当一个 UI 元素需要重新布局时,会调用 LayoutRebuilder.MarkLayoutForRebuild 方法,将该元素的 RectTransform 包装为 LayoutRebuilder 对象,并添加到 CanvasUpdateRegistry.m_LayoutRebuildQueue 队列中,等待 Canvas 刷新时调用 LayoutRebuilder.Rebuild 方法。

ICanvasElement
在 CanvasUpdateRegistry 中,我们提到了一个接口: ICanvasElement,所有想在 Canvas 刷新时一同更新的 UI 元素都需要实现该接口。显然地,由于 LayoutRebuilder 的主要作用就是在 Canvas 刷新时更新 UI 元素的布局,因此 LayoutRebuilder 也实现了 ICanvasElement 接口。
1 | public interface ICanvasElement |
首先来看一下 LayoutRebuilder 都是如何实现接口中的函数的:
1 | public class LayoutRebuilder : ICanvasElement |
可以看出,LayoutRebuilder 中维护了一个 RectTransform 类型的对象 m_ToRebuild,该对象就是需要重新布局的 UI 元素。transform 属性也会返回该对象。
其次,由于 LayoutRebuilder 只负责布局的更新,不涉及渲染的操作。因此,在 Rebuild 函数中,LayoutRebuilder 只处理了 CanvasUpdate.Layout 阶段的更新,且 GraphicUpdateComplete 函数也是空的实现。
Rebuild
我们来详细分析一下 Rebuild 函数的实现:
1 | public void Rebuild(CanvasUpdate executing) |
Rebuild 函数将布局更新过程分为两个阶段,分别进行水平与垂直方向的布局更新。在每一个更新阶段都会先递归地将 m_ToRebuild 及其子物体作为 ILayoutElement 处理,计算自身的布局数据;再递归地将 m_ToRebuild 及其子物体作为 ILayoutController 处理,控制子物体的布局。
PerformLayoutCalculation
PerformLayoutCalculation 函数用于递归地计算 ILayoutElement 的布局数据,其实现如下:
1 | private void PerformLayoutCalculation(RectTransform rect, UnityAction<Component> action) |
UnityAction<Component> action 的参数可以不太好理解,这里简单解释一下:
-
UnityAction是一个泛型委托,它可以接受一个参数并返回void;1
public delegate void UnityAction<T0>(T0 arg0);
这里
T0被指定为Component类型,因此action可以接受一个Component类型的参数。 -
在
Rebuild函数中,均以匿名函数的形式传入action;1
2
3
4
5
6
7e => (e as ILayoutElement).CalculateLayoutInputHorizontal()
// 等同于
void Action(Component e)
{
(e as ILayoutElement).CalculateLayoutInputHorizontal();
}
回到函数中,首先会获取 rect 上所有实现了 ILayoutElement 接口的组件,并将其存入 components 列表中。接着会调用 StripDisabledBehavioursFromList 函数移除 components 列表中所有被禁用的组件。
1 | static void StripDisabledBehavioursFromList(List<Component> components) |
该函数的实现也很简单,这里不再赘述。接着就是递归调用的部分了:
1 | if (components.Count > 0 || rect.GetComponent(typeof(ILayoutGroup))) |
如果 rect 上有实现了 ILayoutElement 接口的组件或者有实现了 ILayoutGroup 接口的组件,那么都会进行布局的计算。
前者比较好理解,实现了
ILayoutElement接口的组件确实需要计算布局;而后者就有点费解了,ILayoutGroup接口是用于更新子物体布局的,为什么这里也要计算父物体的布局呢?
这里笔者的猜测是MinSize等的计算,在 LayoutSystem 的使用 中我们做过这样的实验:在改变子物体的MinSize等时,父物体的MinSize会随之改变,表现为所有子物体的MinSize之和外加Padding等。因此,这里也需要计算父物体的布局。
需要注意的是这里的计算顺序,计算布局时的顺序是自底向上的,即先计算子物体的布局再计算父物体的布局,这是因为父物体的布局数据依赖于子物体的布局数据。
因此这里会先递归地计算子物体的布局,再计算父物体自身的布局。最后会释放 components 列表。
PerformLayoutControl
PerformLayoutControl 函数用于递归地设置 ILayoutController 下子元素的布局,其实现如下:
1 | private void PerformLayoutControl(RectTransform rect, UnityAction<Component> action) |
有两点需要注意:
-
设置布局时的顺序是自顶向下的,即先设置父物体的布局再设置子物体的布局,这是因为子物体的布局受父物体的布局的控制,在嵌套的情况下会呈现出叠加的效果。
-
在每一次递归时会先调用实现了
ILayoutSelfController接口的组件以更新自身的布局,再调用其余的组件以更新子物体的布局。 -
函数总体的判断条件是
components.Count > 0,与PerformLayoutCalculation函数判断条件的前半部分相同,这里统一解释一下:我们可能会好奇,如果一个父物体中没有实现
ILayoutController接口的,而子物体中有实现ILayoutController接口的组件,按照这个逻辑岂不是所有物体均不会更新布局?其实不然,从注释中也可见一斑;事实上每个实现了ILayoutController接口的组件都会被独立的注册进CanvasUpdateRegistry中,因此对于一个gameObject而言,只需要考虑以自身为根的 UI 元素布局更新即可。至于子物体,则会单独地以它自身为根进行布局更新。
MarkLayoutForRebuild
MarkLayoutForRebuild 是一个静态函数,用于标记一个 RectTransform 需要重新布局,其实现如下:
1 | public static void MarkLayoutForRebuild(RectTransform rect) |
先来看最为核心的一段代码:
1 | var comps = ListPool<Component>.Get(); |
这段代码的作用是找到一个有效的 layoutRoot,即一个实现了 ILayoutGroup 接口且激活的 gameObject。在这个过程中,会递归地向上查找,直到找到一个有效的 layoutRoot 或者到达根节点。
有一点需要注意:layoutRoot 保证了在到达 rect 的路径上,每个 gameObject 都实现了 ILayoutGroup 接口且激活。
1 | if (layoutRoot == rect && !ValidController(layoutRoot, comps)) |
当 layoutRoot 与 rect 相同时,需要再次检查 layoutRoot 是否是一个有效的 ILayoutGroup,如果无效则直接释放 comps 并返回。
1 | private static bool ValidController(RectTransform layoutRoot, List<Component> comps) |
ValidController 函数用于检查一个 layoutRoot 是否是一个有效的 ILayoutController,主要逻辑就是检查 layoutRoot 上是否有实现了 ILayoutController 接口且激活的组件。
回到 MarkLayoutForRebuild 函数,当找到一个有效的 layoutRoot 后,会调用 MarkLayoutRootForRebuild 函数:
1 | private static void MarkLayoutRootForRebuild(RectTransform controller) |
MarkLayoutRootForRebuild 函数会将传入的 controller 包装为一个 LayoutRebuilder 对象,并将其注册进 CanvasUpdateRegistry.m_LayoutRebuildQueue 中,等待 Canvas 刷新时调用 Rebuild 函数,从而更新布局。
其他
1 | private int m_CachedHashFromTransform; |
除了 UI 元素的 RectTransform 对象外,LayoutRebuilder 还维护了一个 m_CachedHashFromTransform 变量,用于记录 RectTransform 的 hashcode。主要用于唯一标识一个 LayoutRebuilder 对象以及作为一些容器的 key。
1 | static LayoutRebuilder() |
最后,LayoutRebuilder 在静态构造函数中注册了 RectTransform.reapplyDrivenProperties 事件,当 RectTransform 的 drivenProperties 发生变化时,会调用 ReapplyDrivenProperties 函数,从而重新标记该 RectTransform 需要重新布局。
这也就解释了为什么我们在 Unity 中修改 RectTransform 的属性时,UI 元素会自动更新布局。
1 | public static void ForceRebuildLayoutImmediate(RectTransform layoutRoot) |
ForceRebuildLayoutImmediate 函数用于立即强制更新一个 RectTransform 的布局,这个函数中直接调用了 Rebuild 函数,不需要等待 Canvas 刷新。一般情况下,我们不应该使用这个函数,而是应该通过 MarkLayoutForRebuild 函数来标记一个 RectTransform 需要重新布局。
总结
本文我们主要分析了 LayoutRebuilder 的实现,LayoutRebuilder 是一个用于管理 UI 元素布局更新的包装类,主要负责将一个 RectTransform 包装为 LayoutRebuilder 对象,并在 Canvas 刷新时调用 Rebuild 函数,从而更新布局。
在 Rebuild 函数中,LayoutRebuilder 将布局更新过程分为两个阶段,分别进行水平与垂直方向的布局更新。在每一个更新阶段都会先计算 ILayoutElement 的布局数据,再设置 ILayoutController 下子元素的布局。需要注意计算过程与设置过程顺序的区别:计算布局时的顺序是自底向上的,而设置布局时的顺序是自顶向下的。
LayoutRebuilder 向外提供了 MarkLayoutForRebuild 函数用于标记一个 RectTransform 需要重新布局,以及 ForceRebuildLayoutImmediate 函数用于立即强制更新一个 RectTransform 的布局。在之后的文章中我们将会看到各种 UI 元素是如何利用这些静态函数来更新布局的。



