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 元素是如何利用这些静态函数来更新布局的。