Unity UGUI Layout 系统解析

原因

每次需要使用Unity UGUI 的Layout 系统,它总能给你一些意想不到的小问题。然后要去看源码,源码又很零散,导致每次需要重写Layout 控件都要重新理解,现将其总结出来,以便以后查阅。

更新逻辑

首先从最常用的也最容易出问题的布局三剑客开始切入,HorizontalLayoutGroup、VerticalLayoutGroup 和GridLayoutGroup。

源码中三剑客都继承自LayoutGroup。

LayoutGroup 中布局更新的重点在于SetDirty() 函数,可以看到在OnEnable()、OnDisable()、OnDidApplyAnimationProperties()、OnRectTransformDimensionsChange()、OnTransformChildrenChanged() 等多个涉及到需要更新布局的时机的函数中都有调用它。

SetDirty() 函数调用了UGUI 系统更新布局的核心类LayoutRebuilder 中的函数MarkLayoutForRebuild。

MarkLayoutForRebuild

分析上述代码可以看出:

当一个RectTransform 被标记需要更新时,系统会向祖级变换遍历ILayoutGroup 组件找到最顶层的ILayoutGroup 组件来更新布局。注意遍历的过程中,一旦发现非ILayoutGroup 就会中断。这些是为什么连续两层以上嵌套三剑客组件时,布局经常会乱的原因之一。

看下ILayoutGroup 接口,在UGUI 源码中,实现ILayoutGroup 接口的只有LayoutGroup(三剑客)和ScrollRect,同时ILayoutGroup 接口需要实现ILayoutController。源码中的注释指出,如果一个组件驱动其所有子变换的布局,那么需要实现ILayoutGroup 接口,如果驱动其自身的布局,需要实现ILayoutSelfController。其中,另一个布局时常用到的组件ContentSizeFitter 就是实现了ILayoutSelfController 接口。如果需要重写布局组件,注意实现这两个接口。

继续看LayoutRebuilder.MarkLayoutForRebuild 函数的处理流程,在函数末尾,调用了MarkLayoutRootForRebuild。在这个函数中,布局组件首先被包裹进LayoutRebuilder 实例中(从s_Rebuilders 对象池中获得的)从而变为ICanvasElement,然后通过调用CanvansUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild 函数送到真正被注册更新的地方,CanvasUpdateRegistry。

CanvasUpdateRegistry 是个单例,在单例被创建的时候,将自身的更新函数PerformUpdate 注册到了Canvas 组件预备渲染事件Canvas.willRenderCanvases 上,在PerformUpdate 中,通过调用所有注册过的ICanvasElement.Rebuild 函数完成了将所有注册过的ICanvasElement 进行重建的任务。值得一提的是,在重建之前,CanvasUpdateRegistry 还对所有注册过的ICanvasElement 按照深度(即祖级变换的层数)进行了排序,这使底层布局优先于顶层布局完成更新,从而防止布局混乱(只能防止多级布局之间有间隔层级的布局)的原理。

布局逻辑

那么回看LayoutRebuilder 中如何实现ICanvasElement.Rebuild 的,这是布局控件真正更新布局时的核心。

LayoutRebuilder_Rebuild

查看PerformLayoutCalculation 函数

PerformLayoutCalculation

从代码可以看出,更新布局控件时,系统递归地从最子级变换开始,自下而上查找实现ILayoutElement 接口的组件(注意遇到ILayoutGroup 组件时,递归不会停止),运行组件实现的ILayoutElement.CalculateLayoutInputHorizontal 方法(和ILayoutElement.CalculateLayoutInputVertical 方法)。值得注意的是,遍历算法采用的是后序遍历,即先计算子级再计算本级

接下来看PerformLayoutControl 函数

PerformLayoutControl

从代码中可以看出,更新布局控件时,系统递归地从最顶级变换开始,自上而下查找每个层级中实现ILayoutController 接口的控件,运行他们实现的ILayoutController.SetLayoutHorizontal 方法(和ILayoutController.SetLayoutVertical 方法)。值得注意的是,第一,系统优先计算了 ILayoutSelfController,即所有控制自身布局的布局组件会优先计算,然后再处理三剑客这种控制所有子级布局的组件。第二,采用了先序遍历,即先计算当前层级的,然后再计算子级的也就是说,嵌套的三剑客组件中,是从顶级开始控制布局的,所以连续嵌套的布局控件常常出现布局错误的问题。

最后需要注意,布局更新是先更新横向布局,再更新纵向布局。是后序更新ILayoutElement 控件的横向布局属性然后先序遍历ILayoutController 控件的横向布局,接着开始以相同方式纵向更新。

现在可以回到三剑客组件中,看看他们是如何实现ILayoutElement 和ILayoutController 接口的了。

布局三剑客

HorizontalLayoutGroup 和VerticalLayoutGroup 这两个控件都是继承自HorizontalOrVerticalLayoutGroup,在CalculateLayoutInputHorizontal 中,他们将自身子级别的所有非ILayoutIgnorer 组件缓存了下来,以便后续的布局计算。

实现ILayoutElement.CalculateLayoutInputHorizontal 和ILayoutElement.CalculateLayoutInputVertical 函数时,他们都调用了CalcAlongAxis 函数。

CalcAlongAxis

87 行获取当前计算的轴向相对于布局控件本身是否是另一个轴向(以下简称正交轴向),也就是说,如果是在计算HorizontalLayoutGroup,当alongOtherAxis 为true 时,那么我们现在正在计算横向布局组件的所有子级的纵向布局。

如代码所示,在正交轴向上,算法将所有子级中min、prefered、flexible布局属性(以下统称布局属性)中的最大值分别作为自身相应的布局属性的值,而在与布局控件管理轴向相同的轴向(以下简称对应轴向)上,算法通过遍历子层级变换,获取子级控件的布局属性相加后,作为自身相应布局属性的值,注意min 和preferred 属性都加上了spacing 属性。

这其中,获取子级布局属性的关键点在GetChildSize 上。

GetChildSizes

当控件中控制子级大小选项不勾选的时候,返回的就是子级Rect 的轴向大小;当勾选控制子级大小时,可以通过在子级变换上添加实现了ILayoutElement 接口的组件来左右布局控件的行为。

ControlChildSize

在LayoutUtility 中可以看到,获取子级布局属性的值,实际上是获取子级中所有实现ILayoutElement 接口的控件中,优先级最高(当优先级相同时,返回最大值)的布局属性大小。

GetLayoutProperty
布局元素的优先级

在LayoutGroup 中,布局优先级的值被定义为0,是源码中最小的级别。自行重写布局控件的时候,也需要注意下这个值的定义。

最后值得说明的是,之前有提到过,多级连续的ILayoutElement 控件之间,更新ILayoutElement.CalculateLayoutInputXXX 的算法是后序遍历,而不连续的多组布局控件在更新时,首先会按照深度来排序,因此总能保证各级的布局属性层层依赖子级而计算正确。

接下来看ILayoutController.SetLayoutHorizontal 和ILayoutController.SetLayoutVertical,它们内部都调用了SetChildrenAlongAxis 方法,这个方法是HorizontalLayoutGroup 和VerticalLayoutGroup 更新其子级布局的核心函数。

SetChildrenAlongAxis(前半部)

133-137 行获取了布局控件上的ControlChildSize、UseChildScale、ChildForceExpand设置。

布局设置

138 行将控件的ChildAlignment 设置拆分到轴向上,其中0表示左(横向)上(纵向)、0.5表示中部、1表示右(横向)下(纵向),后续计算子级位置时,需要根据这个信息来对齐子级。

140 行获取当前计算的轴向相对于布局控件本身是否是另一个轴向(以下简称控件的正交轴向),也就是说,如果是在计算HorizontalLayoutGroup,当alongOtherAxis 为true 时,那么我们现在正在计算横向布局组件的所有子级的纵向布局。

143 行获取的是布局组件自身层级变换的大小(以下简称自身大小)减去轴向两侧的留白(虽然叫留白,但实际上可以通过设置负值变成overlap),也就是控件上Padding 设置的Left\Right 或Top\Bottom,以下简称内部大小。

144 行,布局控件开始设置每个子级的布局。

148 行,使用了与CalcAlongAxis 中相同的函数GetChildSize 来获取子级控件的布局属性。

151 行,获取了子级控件需要的(当前计算轴向上的)空间,以下简称需求空间。当ControlChildSize 布局设置设为true 时,在确保需求空间不会低于子级的Min 属性的前提下,使用布局控件的内部大小、布局控件自身大小(将padding 设置为负值时内部大小可以大于自身大小)和子级的prefered 属性中的较小值,此外当子级的flexible 布局属性设置了大于0的值或者布局控件的ChildForceExpand 设置设为了true,那么需求空间就是布局控件内部大小和布局控件自身大小中的较小值;当ControlChildSize 布局设置设为false 时,因为子级的Min 属性和Preferred 属性都被设置为子级的自身大小,这个值为子级的自身大小和布局控件内部大小中的较小值(子级自身大小可以大于布局控件的内部或自身大小)。

152 行,使用的函数名称是GetStartOffset ,返回的是子级(相对于布局控件的左\上)开始计算位置的偏移值。这里是布局控件的对齐逻辑发生的地方。下图是计算ChildAlignment 设置为RightXXX 时的VerticalLayoutGroup 的正交轴向GetStartOffset 的示例图。

GetStartOffset 的示例图1

下图是子级flexible 属性设置大于0的值或者布局控件的ChildForceExpand 设置为true 时,GetStartOffset 计算示例图。

GetStartOffset 的示例图2

153 行之后,根据上述计算的数据,开始设置每个子级的位置(及大小)。注意155行和160行调用的是两个不同的重载函数

SetChildAlongAxisWithScale

当布局控件上的ControlChildSize 设置为ture 时,SetChildAlongAxisWithScale 将子级的大小设置为了151 中计算的需求空间大小,这里是子级prefered 布局属性设置和布局控件ControlChildSize、ChildForceExpand 等设置生效的地方

随后根据StartOffset、子级的RectTransform 设置计算并设置了子级在布局控件中正交轴向上的锚定位置。

SetChildrenAlongAxis 的后半部分是用来设置布局组件的所有子级,在其所管理的轴向上(以下简称对应轴向)的布局的。

SetChildrenAlongAxis(后半部)

168行,算法获取了控件对应轴向上的冗余空间,即布局控件自身大小减去布局控件本身的preferred 布局属性(布局控件本身的属性在前述的CalcAlongAxis 函数中已经完成了计算)。

173行,当存在冗余空间,并且子级没有设置flexible 属性的控件时,需要在对应轴向上对齐子级,这里是布局控件对齐逻辑生效的地方。

175行,当存在冗余空间,并且子级中有设置flexible 属性的控件时,将冗余空间除以子级总体的flexible 属性之和(也就是布局控件自身的flexible 属性)获得乘积因子,将这个乘积因子乘以单个子级的flexible 属性就能获得该子级分得的冗余空间,注意,当冗余空间大于总Flexible 时,多出冗余的部分也根据这个乘积因子分配给了所有的子级,所以没有173行的对齐操作。

178-180行,当子级总Min 布局属性不等于总Preferred 布局属性时,布局组件尽可能将自身空间分配给子级,通过计算布局控件自身大小减去子级总体Min 属性大小获得的可额外分配值,和子级总体Prefered 属性大小减去子级总体Min 属性大小获得的尝试额外分配值之间的比值,可以获得铺满布局控件自身大小时实际的额外分配比,这个比值可以作为给子级分配空间时,在子级的min 和preferred 属性之间插值的因子。180 行的截取操作指出,当子级总体的Min 属性大于布局控件的自身大小时,不分配额外空间,当子级总体Preferred 属性大于布局控件的自身大小时,按照插值因子为每个子级分配额外空间,当子级总体Preferred 属性小于布局控件的自身大小时,按照子级的Preferred 属性分配空间。

182-201行,是布局所有子级的逻辑。189-190 行,为每个子级计算了需要分配的大小。191-199行,与上述正交轴向的计算流程相同,设置了子级的锚定位置和大小。200行,更新下一个子级的计算初始位置。

至此,HorizontalLayoutGroup 和VerticalLayoutGroup 就完成了布局更新任务。

接下来看下三剑客中的最后一剑GridLayoutGroup 是如何实现ILayoutElement 和ILayoutGroup 接口的。

和前述两个布局控件相同,在CalculateLayoutInputHorizontal 中首先获取了需要控制布局的所有子级(即没有挂载任何实现了ILayoutIgnorer 接口的控件的子级)。

GridLayoutGroup 的 CalculateLayoutInputHorizontal

145-157行 根据GridLayoutGroup 中Constraint 布局设置计算出横轴向最低排布数量和期望排布数量。

GridLayoutGroup 布局设置

前两种设置的计算比较好理解,当Constraint 设置为Flexible 时,期望的排布数量被设置为了子级数量的平方根,意味着期望在方形区域排布所有的子级。

159-162行 则根据最低排布数量和期望排布数量来计算控件自身的布局属性,注意控件的flexible 属性被设置为了-1。

在CalculateLayoutInputVertical 函数中,算法也是根据Constraint 设置来计算控件的布局行数。

GridLayoutGroup 的 CalculateLayoutInputVertical

181-184行 可以看见,当Constraint 设置为Flexible 时,算法会准确计算出控件需要布局的行数。

187-188行 可以看到,GridLayoutGroup 组件纵向的Min 和Prefered 布局属性都被设置为相同数值,同时Flexible 属性设置为了-1。

可以看到GridLayoutGroup 无视所有子级上的ILayoutElement 控件提供的布局属性,而统一使用CellSize 属性作为每个子级的布局属性大小。

然后来看SetLayoutHorizontal 和SetLayoutVertical 中,GridLayoutGroup 是如何排布子级布局的。

源码中,这两个函数都是调用了SetCellsAlongAxis 函数。

SetCellsAlongAxis(前半部)

217-234行 查看代码并且结合函数开头的注释可知,计算GridLayoutGroup 子级的横向布局时(SetLayoutHorizontal 时)仅仅设置了子级的大小,并且注意到,子级的大小被强制设置为GridLayoutGroup 组件上的CellSize 设置的大小

241-266行 根据GripLayoutGroup 组件上的Constraint 设置和两个轴向上的自身大小,计算出组件轴向上可以容纳的子级行列数,注意当Constraint 设置为Flexible 的时候,GridLayoutGroup 是按照尝试将自身大小铺满的期望去计算行列数目

SetCellsAlongAxis(后半部)

268-269行 计算出控件在横轴向和纵轴向排布的起始方向,这里是GridLayoutGroup 控件上StartCorner 设置生效的地方。

271-283行 很好理解,之前计算的行列数是布局控件两个轴向上可以排布的数量,在这里结合GridLayoutGroup 控件上的StartAxis 设置(排布方向,即元素按行排列还是按列排列),计算出实际上会排列的行列数。

285-288行 根据实际上排的的行列数计算出排列这些子级实际需要用掉的空间。

290-292行 根据需求空间和自身大小计算出开始排布子级的横向及纵向偏移,这里是ChildAlignment 设置也就是对齐设置生效的敌方。

294-315行 使用上述计算的初始排布位置、实际排布数量等数据,设置了每个子级的实际位置,比较容易看懂。

以上,便是GridLayoutGroup 全部的布局逻辑。对比HorizontalLayoutGroup 和VerticalLayoutGroup 这两个布局控件,GridLayoutGroup 完全无视了子级的布局属性,如同他的名字“网格布局组”,它只适合排布大小差异不大的子级。 


局限

虽然自带的几种布局控件功能已经很强大,但他们因为自身的设计或者前述的更新逻辑的制约,也存在一些局限,现将实际使用中遇到的问题以及解决方案罗列出来,方便以后查阅。

第一个最常见的,连续多级的三剑客布局组件与ContentSizeFitter 合用时的问题:Unity UGUI 解决多级布局混乱问题

你可能感兴趣的:(Unity UGUI Layout 系统解析)