前言

CanvasUpdateRegistry 中,我们介绍了 UGUI 系统是如何通过 CanvasUpdateRegistry 来控制 UI 元素的更新的,其中提到了 “Canvas 的更新可以分为布局与渲染两个阶段”。在监听到 Canvas.willRenderCanvases 事件后,CanvasUpdateRegistry 会遍历所有需要更新的 UI 组件,并根据 CanvasUpdate 所代表的阶段来调用对应的 Rebuild 方法。

接下来就让我们来看看 Layout 阶段是如何更新的。

ILayoutElement

UnityEngine.UI\UI\Core\Layout\ILayoutElement.cs 中定义了一些 LayoutSystem 所使用到的接口,包括 ILayoutElementILayoutControllerILayoutGroup 等。它们分别定义了 LayoutElements 及 LayoutControllers 的基本属性与方法。

ILayoutElement

ILayoutElement 定义了一个可以被 LayoutController 控制的基本布局单元,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface ILayoutElement
{

void CalculateLayoutInputHorizontal();

void CalculateLayoutInputVertical();

float minWidth { get; }

float preferredWidth { get; }

float flexibleWidth { get; }

float minHeight { get; }

float preferredHeight { get; }

float flexibleHeight { get; }

int layoutPriority { get; }
}

ILayoutElement 定义了 CalculateLayoutInputHorizontalCalculateLayoutInputVertical 两个方法,用于计算水平与垂直方向的布局输入;在 LayoutSystem 访问一个 LayoutElement 的属性之前,会先调用这两个函数来确保 LayoutElement 的属性是最新的。

此外就是一些布局相关的属性的,如 minWidthpreferredWidthflexibleWidth 等,在梳理完 LayoutSystem 的使用 后,想必大家已经对这些属性熟稔于心。

值得注意的是 layoutPriority 属性,它定义了 LayoutElement 的布局优先级,当一个 gameObject 上有多个脚本均实现了 ILayoutElement 接口时,LayoutSystem 会根据 layoutPriority 的值来决定调用的顺序。当 layoutPriority 值为负数时,LayoutSystem 会忽略该 LayoutElement

ILayoutController

ILayoutController 用于定义可以控制 LayoutElement 的布局组件,根据是修改自身的布局还是子物体的布局,ILayoutController 又派生出了 ILayoutGroupILayoutSelfController 两个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface ILayoutController
{
/// <summary>
/// Callback invoked by the auto layout system which handles horizontal aspects of the layout.
/// </summary>
void SetLayoutHorizontal();

/// <summary>
/// Callback invoked by the auto layout system which handles vertical aspects of the layout.
/// </summary>
void SetLayoutVertical();
}

public interface ILayoutGroup : ILayoutController
{
}

public interface ILayoutSelfController : ILayoutController
{
}

ILayoutController 定义了 SetLayoutHorizontalSetLayoutVertical 两个方法,用于设置水平与垂直方向的布局,在 HorizontalLayoutGroupVerticalLayoutGroupGridLayoutGroup 等布局组件中,这两个方法会被重写以实现不同的布局方式。

ILayoutGroup 继承自 ILayoutController,用于定义可以控制子物体布局的布局组件,如 HorizontalLayoutGroupVerticalLayoutGroupGridLayoutGroup

ILayoutSelfController 也继承自 ILayoutController,用于定义可以控制自身布局的布局组件,如 ContentSizeFitterAspectRatioFitter

值得注意的是,派生出的两个接口并没有增加任何新的方法,只是为了更好地区分布局组件的功能。LayoutSystem 会根据继承的接口来决定布局的更新顺序,在 LayoutRebuilder 中会有所体现。

ILayoutIgnorer

ILayoutIgnorer 用于定义可以忽略布局的组件,如果一个 gameObject 上实现了 ILayoutIgnorer 接口并且 ignoreLayouttrue,那么 LayoutSystem 会忽略该 gameObject 的布局。

1
2
3
4
public interface ILayoutIgnorer
{
bool ignoreLayout { get; }
}

LayoutRebuilder

LayoutRebuilder 是一个包装类,用于管理 UI 中需要重新布局的元素。当一个 UI 元素需要重新布局时,会调用 LayoutRebuilder.MarkLayoutForRebuild 方法,将该元素的 RectTransform 包装为 LayoutRebuilder 对象,并添加到 CanvasUpdateRegistry.m_LayoutRebuildQueue 队列中,等待 Canvas 刷新时调用 LayoutRebuilder.Rebuild 方法。

image.png

ICanvasElement

CanvasUpdateRegistry 中,我们提到了一个接口: ICanvasElement,所有想在 Canvas 刷新时一同更新的 UI 元素都需要实现该接口。显然地,由于 LayoutRebuilder 的主要作用就是在 Canvas 刷新时更新 UI 元素的布局,因此 LayoutRebuilder 也实现了 ICanvasElement 接口。

1
2
3
4
5
6
7
8
9
10
11
12
public interface ICanvasElement
{
void Rebuild(CanvasUpdate executing);

Transform transform { get; }

void LayoutComplete();

void GraphicUpdateComplete();

bool IsDestroyed();
}

首先来看一下 LayoutRebuilder 都是如何实现接口中的函数的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class LayoutRebuilder : ICanvasElement
{
// ...

private RectTransform m_ToRebuild;

public Transform transform { get { return m_ToRebuild; }}

public void Rebuild(CanvasUpdate executing)
{
switch (executing)
{
case CanvasUpdate.Layout:
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputHorizontal());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutHorizontal());
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputVertical());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutVertical());
break;
}
}

public void LayoutComplete()
{
s_Rebuilders.Release(this);
}

public void GraphicUpdateComplete()
{}

public bool IsDestroyed()
{
return m_ToRebuild == null;
}

// ...
}

可以看出,LayoutRebuilder 中维护了一个 RectTransform 类型的对象 m_ToRebuild,该对象就是需要重新布局的 UI 元素。transform 属性也会返回该对象。

其次,由于 LayoutRebuilder 只负责布局的更新,不涉及渲染的操作。因此,在 Rebuild 函数中,LayoutRebuilder 只处理了 CanvasUpdate.Layout 阶段的更新,且 GraphicUpdateComplete 函数也是空的实现。

Rebuild

我们来详细分析一下 Rebuild 函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
public void Rebuild(CanvasUpdate executing)
{
switch (executing)
{
case CanvasUpdate.Layout:
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputHorizontal());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutHorizontal());
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputVertical());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutVertical());
break;
}
}

Rebuild 函数将布局更新过程分为两个阶段,分别进行水平与垂直方向的布局更新。在每一个更新阶段都会先递归地将 m_ToRebuild 及其子物体作为 ILayoutElement 处理,计算自身的布局数据;再递归地将 m_ToRebuild 及其子物体作为 ILayoutController 处理,控制子物体的布局。

PerformLayoutCalculation

PerformLayoutCalculation 函数用于递归地计算 ILayoutElement 的布局数据,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void PerformLayoutCalculation(RectTransform rect, UnityAction<Component> action)
{
if (rect == null)
return;

var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutElement), components);
StripDisabledBehavioursFromList(components);

// If there are no controllers on this rect we can skip this entire sub-tree
// We don't need to consider controllers on children deeper in the sub-tree either,
// since they will be their own roots.
if (components.Count > 0 || rect.GetComponent(typeof(ILayoutGroup)))
{
// Layout calculations needs to executed bottom up with children being done before their parents,
// because the parent calculated sizes rely on the sizes of the children.

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);
}

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
    7
    e => (e as ILayoutElement).CalculateLayoutInputHorizontal()

    // 等同于
    void Action(Component e)
    {
    (e as ILayoutElement).CalculateLayoutInputHorizontal();
    }

回到函数中,首先会获取 rect 上所有实现了 ILayoutElement 接口的组件,并将其存入 components 列表中。接着会调用 StripDisabledBehavioursFromList 函数移除 components 列表中所有被禁用的组件。

1
2
3
4
static void StripDisabledBehavioursFromList(List<Component> components)
{
components.RemoveAll(e => e is Behaviour && !((Behaviour)e).isActiveAndEnabled);
}

该函数的实现也很简单,这里不再赘述。接着就是递归调用的部分了:

1
2
3
4
5
6
7
8
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]);
}

如果 rect 上有实现了 ILayoutElement 接口的组件或者有实现了 ILayoutGroup 接口的组件,那么都会进行布局的计算。

前者比较好理解,实现了 ILayoutElement 接口的组件确实需要计算布局;而后者就有点费解了,ILayoutGroup 接口是用于更新子物体布局的,为什么这里也要计算父物体的布局呢?
这里笔者的猜测是 MinSize 等的计算,在 LayoutSystem 的使用 中我们做过这样的实验:在改变子物体的 MinSize 等时,父物体的 MinSize 会随之改变,表现为所有子物体的 MinSize 之和外加 Padding 等。因此,这里也需要计算父物体的布局。

需要注意的是这里的计算顺序,计算布局时的顺序是自底向上的,即先计算子物体的布局再计算父物体的布局,这是因为父物体的布局数据依赖于子物体的布局数据。

因此这里会先递归地计算子物体的布局,再计算父物体自身的布局。最后会释放 components 列表。

PerformLayoutControl

PerformLayoutControl 函数用于递归地设置 ILayoutController 下子元素的布局,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 there are no controllers on this rect we can skip this entire sub-tree
// We don't need to consider controllers on children deeper in the sub-tree either,
// since they will be their own roots.
if (components.Count > 0)
{
// Layout control needs to executed top down with parents being done before their children,
// because the children rely on the sizes of the parents.

// First call layout controllers that may change their own RectTransform
for (int i = 0; i < components.Count; i++)
if (components[i] is ILayoutSelfController)
action(components[i]);

// Then call the remaining, such as layout groups that change their children, taking their own RectTransform size into account.
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);
}

有两点需要注意:

  1. 设置布局时的顺序是自顶向下的,即先设置父物体的布局再设置子物体的布局,这是因为子物体的布局受父物体的布局的控制,在嵌套的情况下会呈现出叠加的效果。

  2. 在每一次递归时会先调用实现了 ILayoutSelfController 接口的组件以更新自身的布局,再调用其余的组件以更新子物体的布局。

  3. 函数总体的判断条件是 components.Count > 0,与 PerformLayoutCalculation 函数判断条件的前半部分相同,这里统一解释一下:

    我们可能会好奇,如果一个父物体中没有实现 ILayoutController 接口的,而子物体中有实现 ILayoutController 接口的组件,按照这个逻辑岂不是所有物体均不会更新布局?其实不然,从注释中也可见一斑;事实上每个实现了 ILayoutController 接口的组件都会被独立的注册进 CanvasUpdateRegistry 中,因此对于一个 gameObject 而言,只需要考虑以自身为根的 UI 元素布局更新即可。至于子物体,则会单独地以它自身为根进行布局更新。

MarkLayoutForRebuild

MarkLayoutForRebuild 是一个静态函数,用于标记一个 RectTransform 需要重新布局,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public static void MarkLayoutForRebuild(RectTransform rect)
{
if (rect == null || rect.gameObject == null)
return;

var comps = ListPool<Component>.Get();
bool validLayoutGroup = true;
RectTransform layoutRoot = rect;
var parent = layoutRoot.parent as RectTransform;
while (validLayoutGroup && !(parent == null || parent.gameObject == null))
{
validLayoutGroup = false;
parent.GetComponents(typeof(ILayoutGroup), comps);

for (int i = 0; i < comps.Count; ++i)
{
var cur = comps[i];
if (cur != null && cur is Behaviour && ((Behaviour)cur).isActiveAndEnabled)
{
validLayoutGroup = true;
layoutRoot = parent;
break;
}
}

parent = parent.parent as RectTransform;
}

// We know the layout root is valid if it's not the same as the rect,
// since we checked that above. But if they're the same we still need to check.
if (layoutRoot == rect && !ValidController(layoutRoot, comps))
{
ListPool<Component>.Release(comps);
return;
}

MarkLayoutRootForRebuild(layoutRoot);
ListPool<Component>.Release(comps);
}

先来看最为核心的一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var comps = ListPool<Component>.Get();
bool validLayoutGroup = true;
RectTransform layoutRoot = rect;
var parent = layoutRoot.parent as RectTransform;
while (validLayoutGroup && !(parent == null || parent.gameObject == null))
{
validLayoutGroup = false;
parent.GetComponents(typeof(ILayoutGroup), comps);

for (int i = 0; i < comps.Count; ++i)
{
var cur = comps[i];
if (cur != null && cur is Behaviour && ((Behaviour)cur).isActiveAndEnabled)
{
validLayoutGroup = true;
layoutRoot = parent;
break;
}
}

parent = parent.parent as RectTransform;
}

这段代码的作用是找到一个有效的 layoutRoot,即一个实现了 ILayoutGroup 接口且激活的 gameObject。在这个过程中,会递归地向上查找,直到找到一个有效的 layoutRoot 或者到达根节点。

有一点需要注意:layoutRoot 保证了在到达 rect 的路径上,每个 gameObject 都实现了 ILayoutGroup 接口且激活。

1
2
3
4
5
if (layoutRoot == rect && !ValidController(layoutRoot, comps))
{
ListPool<Component>.Release(comps);
return;
}

layoutRootrect 相同时,需要再次检查 layoutRoot 是否是一个有效的 ILayoutGroup,如果无效则直接释放 comps 并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static bool ValidController(RectTransform layoutRoot, List<Component> comps)
{
if (layoutRoot == null || layoutRoot.gameObject == null)
return false;

layoutRoot.GetComponents(typeof(ILayoutController), comps);
for (int i = 0; i < comps.Count; ++i)
{
var cur = comps[i];
if (cur != null && cur is Behaviour && ((Behaviour)cur).isActiveAndEnabled)
{
return true;
}
}

return false;
}

ValidController 函数用于检查一个 layoutRoot 是否是一个有效的 ILayoutController,主要逻辑就是检查 layoutRoot 上是否有实现了 ILayoutController 接口且激活的组件。

回到 MarkLayoutForRebuild 函数,当找到一个有效的 layoutRoot 后,会调用 MarkLayoutRootForRebuild 函数:

1
2
3
4
5
6
7
8
9
10
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);
}

MarkLayoutRootForRebuild 函数会将传入的 controller 包装为一个 LayoutRebuilder 对象,并将其注册进 CanvasUpdateRegistry.m_LayoutRebuildQueue 中,等待 Canvas 刷新时调用 Rebuild 函数,从而更新布局。

其他

1
2
3
4
5
6
7
private int m_CachedHashFromTransform;

private void Initialize(RectTransform controller)
{
m_ToRebuild = controller;
m_CachedHashFromTransform = controller.GetHashCode();
}

除了 UI 元素的 RectTransform 对象外,LayoutRebuilder 还维护了一个 m_CachedHashFromTransform 变量,用于记录 RectTransformhashcode。主要用于唯一标识一个 LayoutRebuilder 对象以及作为一些容器的 key

1
2
3
4
5
6
7
8
9
static LayoutRebuilder()
{
RectTransform.reapplyDrivenProperties += ReapplyDrivenProperties;
}

static void ReapplyDrivenProperties(RectTransform driven)
{
MarkLayoutForRebuild(driven);
}

最后,LayoutRebuilder 在静态构造函数中注册了 RectTransform.reapplyDrivenProperties 事件,当 RectTransformdrivenProperties 发生变化时,会调用 ReapplyDrivenProperties 函数,从而重新标记该 RectTransform 需要重新布局。

这也就解释了为什么我们在 Unity 中修改 RectTransform 的属性时,UI 元素会自动更新布局。

1
2
3
4
5
6
7
public static void ForceRebuildLayoutImmediate(RectTransform layoutRoot)
{
var rebuilder = s_Rebuilders.Get();
rebuilder.Initialize(layoutRoot);
rebuilder.Rebuild(CanvasUpdate.Layout);
s_Rebuilders.Release(rebuilder);
}

ForceRebuildLayoutImmediate 函数用于立即强制更新一个 RectTransform 的布局,这个函数中直接调用了 Rebuild 函数,不需要等待 Canvas 刷新。一般情况下,我们不应该使用这个函数,而是应该通过 MarkLayoutForRebuild 函数来标记一个 RectTransform 需要重新布局。

总结

本文我们主要分析了 LayoutRebuilder 的实现,LayoutRebuilder 是一个用于管理 UI 元素布局更新的包装类,主要负责将一个 RectTransform 包装为 LayoutRebuilder 对象,并在 Canvas 刷新时调用 Rebuild 函数,从而更新布局。

Rebuild 函数中,LayoutRebuilder 将布局更新过程分为两个阶段,分别进行水平与垂直方向的布局更新。在每一个更新阶段都会先计算 ILayoutElement 的布局数据,再设置 ILayoutController 下子元素的布局。需要注意计算过程与设置过程顺序的区别:计算布局时的顺序是自底向上的,而设置布局时的顺序是自顶向下的

LayoutRebuilder 向外提供了 MarkLayoutForRebuild 函数用于标记一个 RectTransform 需要重新布局,以及 ForceRebuildLayoutImmediate 函数用于立即强制更新一个 RectTransform 的布局。在之后的文章中我们将会看到各种 UI 元素是如何利用这些静态函数来更新布局的。