夜雨聆风学习资料网

ARTICLE · 1013922

LayoutUtility 源码解析:HorizontalLayoutGroup 是如何知道 Text 有多宽的?

LayoutUtility 源码解析:HorizontalLayoutGroup 是如何知道 Text 有多宽的?

前面几篇文章,我们已经逐渐建立起 Unity UI 自动布局的基本认识。我们知道了两个非常重要的角色:

ILayoutElement  ->  “我希望自己有多大”ILayoutController  ->  “我来决定最终怎么布局”

随后,我们又认识了两个常用组件:

LayoutElement  ->  主动提供 Min / Preferred / FlexibleHorizontalLayoutGroup  ->  读取 Child 的布局需求  ->  计算最终尺寸和位置

但这里其实还缺了非常关键的一环。

还是使用我们一直沿用的例子:

Canvas└── Panel(HorizontalLayoutGroup)├── TMP_Text A├── TMP_Text B└── TMP_Text C

三个 Text 分别显示:A Unity Hello Unity

当 HorizontalLayoutGroup 开启:Control Child Size ☑ Width

以后,它可以让三个 Text 根据自己的布局需求得到不同宽度。

问题来了:HorizontalLayoutGroup 到底是怎么知道 "A"、"Unity"、"Hello Unity" 分别需要多宽的?

它会去读取 rectTransform.rect.width 吗?不会。真正站在 HorizontalLayoutGroup 和 ILayoutElement 中间的,是今天的主角:LayoutUtility。

1. 先把整个数据流串起来

如果只记一张图,我建议先记住这张:

TMP_Text / LayoutElement││提供布局信息▼     ILayoutElement│▼     LayoutUtility││查询布局信息▼HorizontalLayoutGroup││计算最终尺寸和位置▼    RectTransform│▼最终 UI

这几个角色的职责完全不同。

角色
负责什么
TMP_Text / LayoutElement
提供布局数据
ILayoutElement
定义布局数据应该长什么样
LayoutUtility
查询、筛选布局数据
HorizontalLayoutGroup
根据布局数据计算最终布局
RectTransform
保存最终的位置和尺寸

可以用一句话总结:

ILayoutElement 负责“报需求”,LayoutUtility 负责“查需求”,HorizontalLayoutGroup 负责“分配空间”,RectTransform 保存最终结果。

所以,LayoutUtility 本身并不是一个布局控制器。

它更像 Unity Layout System 中的:

布局数据查询器。

2. LayoutUtility 最常见的 6 个方法

LayoutUtility 最常见的是下面这六个 API:

LayoutUtility.GetMinWidth(rect);LayoutUtility.GetPreferredWidth(rect);LayoutUtility.GetFlexibleWidth(rect);LayoutUtility.GetMinHeight(rect);LayoutUtility.GetPreferredHeight(rect);LayoutUtility.GetFlexibleHeight(rect);

正好对应我们上一篇学习过的:

Width├── Min├── Preferred└── FlexibleHeight├── Min├── Preferred└── Flexible

例如:

float width = LayoutUtility.GetPreferredWidth(rect);

它的含义不是:获取当前 RectTransform 的实际宽度。而是 查询这个 RectTransform 对应的布局元素,在 Layout System 中希望获得的 Preferred Width。

这是两个完全不同的概念。

3. RectTransform Width 和 Preferred Width,一定要分清

假设:

TMP_Text = "Hello Unity"

这个 Text 当前可能是:

RectTransform Width = 300Preferred Width = 120

为什么会同时存在两个不同的宽度?

因为它们表达的不是同一件事。

3.1 RectTransform Width

表示:

当前最终实际分配给这个 UI 的宽度。

它属于布局结果。

3.2 Preferred Width

表示:

如果空间允许,从布局角度来说,我希望有这么宽。

它属于布局需求。

所以关系应该理解成:

Preferred Width│布局输入▼HorizontalLayoutGroup│结合父节点空间等条件▼计算最终 Width│▼RectTransform Width

这也是我们前面一直强调的一句话:

RectTransform 通常是自动布局的结果,而 ILayoutElement 提供的数据才是布局输入。

理解了这一点,很多问题就不再奇怪了。

比如:

为什么勾选 Control Child Size -> Width 后,我手动修改 Width 又被改回去了?

因为这时候 RectTransform Width 已经被 LayoutGroup 接管了。

4. LayoutUtility 会自己计算文字宽度吗?

不会。这是理解 LayoutUtility 最容易出现的误区。

调用:

LayoutUtility.GetPreferredWidth(textRect);

并不是:

LayoutUtility↓读取 "Hello Unity"↓读取字体↓计算每个字符↓算出 Width

LayoutUtility 根本不关心 Text 是怎么计算尺寸的。

真正了解文字的是TMP_Text,TMP_Text 自己可以计算:

Preferred WidthPreferred Height

例如:

A↓Preferred Width ≈ 15Unity↓Preferred Width ≈ 50Hello Unity↓Preferred Width ≈ 100

数值这里只是方便理解,真正结果会受到字体、字号、字符间距等因素影响。

关键不是具体多少。

关键是:TMP_Text 自己知道自己需要多大。

Unity 的 TextMeshPro API 本身就暴露了计算后的 preferredWidth。

于是:

TMP_Text│提供 preferredWidth▼ILayoutElement│▼LayoutUtility

所以 LayoutUtility 只需要问:“谁能提供布局数据?”

而不需要问:“你这个文字到底怎么算出来的?”

这就是接口解耦的价值。

5. 为什么不直接 GetComponent<ILayoutElement>()?

现在继续往下思考。

既然 TMP_Text 可以提供 ILayoutElement。

那么 LayoutUtility 为什么不直接:rect.GetComponent<ILayoutElement>();,然后拿preferredWidth不就行了吗?

因为 一个 GameObject 上可能同时存在多个 ILayoutElement。

例如我们的 TMP_Text B 本身已经能提供布局信息。

然后我们又给它添加 LayoutElement,Hierarchy 没变,但是组件实际上变成:

TMP_Text B│├── RectTransform├── TMP_Text│└── ILayoutElement│└── LayoutElement└── ILayoutElement

现在同一个节点出现了两个布局数据提供者。假设:

TMP_TextPreferred Width = 50Layout Priority = 0

另外:

LayoutElementPreferred Width = 300Layout Priority = 1

那么 LayoutUtility.GetPreferredWidth(rect); 到底应该返回 50 还是 300 呢。这才是 LayoutUtility 真正需要解决的问题。

6. LayoutUtility 真正做的事情是什么?

我们可以先把源码放到一边。从需求角度理解,它需要完成:

RectTransform↓找到所有 ILayoutElement↓检查这个属性有没有提供值↓比较 Layout Priority↓确定应该使用谁的数据↓返回最终结果

例如:

TMP_TextPriority = 0Preferred = 50LayoutElementPriority = 1Preferred = 300

最终:

LayoutUtility↓发现 LayoutElement Priority 更高↓Preferred Width = 300

所以我们以前经常说:

LayoutElement 可以“覆盖” TMP_Text 的 Preferred Width。

但现在应该把这句话说得更准确一点:

LayoutElement 并没有修改 TMP_Text 的 Preferred Width,而是向 Layout System 提供了另一份优先级更高的布局数据。

这是两个完全不同的概念。

7. Layout Priority 到底怎么工作的?

现在前面一篇文章中的 Layout Priority,终于真正有意义了。

假设:

TMP_TextPreferred Width = 50Priority = 0

LayoutElement:

Preferred Width = 300Priority = 1

查询:

LayoutUtility.GetPreferredWidth(rect);

结果是300,因为 1 > 0。高 Priority 的有效数据会替代低 Priority 数据。

Unity 官方对 LayoutUtility.GetPreferredWidth() 的描述也是:会查询同一 GameObject 上所有实现 ILayoutElement 的组件,优先采用具有该属性值且 layoutPriority 更高的组件。

所以 Priority 可以理解成:“如果大家都对这个属性有意见,到底谁说了算?”

8. Priority 一样怎么办?

这里是很多 LayoutUtility 介绍容易漏掉的地方。

假设现在:

Element APriority = 1Preferred Width = 100

Element B:

Priority = 1Preferred Width = 200

两者:

Priority 相同

怎么办?Unity 不会随机选一个。对于 Min / Preferred / Flexible 这类正常尺寸查询:

同一最高 Priority↓存在多个有效值↓采用较大的值

所以 max(100, 200) = 200。最终:Preferred Width = 200。

官方 API 文档明确说明了这条规则:多个组件在同一最高优先级下都提供该属性时,会采用其中最大值。

所以可以把核心规则记成:

先看 Priority↓Priority 高的优先如果 Priority 相同↓取更大的有效值

9. 为什么 Inspector 左边那个 Checkbox 很重要?

现在我们终于也可以从源码角度解释:

LayoutElement☐ Preferred Width

为什么没有效果。LayoutElement 内部的这些属性:

Min WidthPreferred WidthFlexible Width

没有启用时,通常会使用 -1。表示 我不提供这个布局属性。

注意。-1 的意思不是 Preferred Width = -1 像素,而是这个属性无效请忽略我。

LayoutUtility 查询时会判断:

if (prop < 0)continue;

也就是:

小于 0↓没提供这个属性↓直接跳过

Unity 当前 uGUI 源码依然保留这个核心规则:布局属性为负数时,会在查询时被忽略。

所以 ☐ Preferred Width 真正表示:我不参与 Preferred Width 的决策。

而 ☑ Preferred Width = 0 表示:我明确告诉布局系统:我的 Preferred Width 是 0。

两者完全不同。

10. 现在来看 LayoutUtility 的核心源码思想

源码最关键的方法,可以浓缩成:

GetLayoutProperty(...)

为了容易理解,我们先看一个简化版:

publicstaticfloatGetLayoutProperty(    RectTransform rect,    Func<ILayoutElement, float> property,float defaultValue){float currentValue = defaultValue;int maxPriority = int.MinValue;var elements = rect.GetComponents<ILayoutElement>();foreach (var element in elements)    {floatvalue = property(element);if (value < 0)continue;if (element.layoutPriority > maxPriority)        {            maxPriority = element.layoutPriority;            currentValue = value;        }elseif (element.layoutPriority == maxPriority)        {            currentValue =                Mathf.Max(currentValue, value);        }    }return currentValue;}

这段代码不要求背。

真正需要看懂的是:

GetComponents<ILayoutElement>()↓找到所有布局元素↓property(element)↓获取当前要查询的属性↓value < 0 ?↓无效就忽略↓比较 layoutPriority↓高优先级覆盖低优先级↓同优先级取较大值↓return

Unity 官方 uGUI 当前实现也是这个核心结构:先收集节点上的 ILayoutElement,忽略未启用 Behaviour 和负值属性,再按 layoutPriority 以及同优先级比较规则选出最终值。

这就是:LayoutUtility 的核心。其实并不神秘。

11. GetPreferredWidth 还有一个容易忽略的细节

假设:

Min Width = 200Preferred Width = 100

你会发现这组数据本身就很奇怪。

它相当于:

我最少必须要 200但是我理想只想要 100

逻辑冲突。

因此 Layout System 会保证 Preferred 至少不会比 Min 还小。也就是说从心智模型来看:

Min↓最低要求Preferred↓在 Min 基础上的理想要求Flexible↓Preferred 满足后继续分配剩余空间

所以:

Min → Preferred → Flexible

并不是三个完全独立的字段。

它们组成的是一整套:

尺寸分配模型。

这也是为什么上一篇专门先讲 LayoutElement,再进入 LayoutUtility。

否则直接读源码,很容易只看到几个 float,却不知道它们为什么这么设计。

12. HorizontalLayoutGroup 真的直接调用 GetPreferredWidth 吗?

这里我们再严谨一点。

文章标题是:

HorizontalLayoutGroup 是如何知道 Text 有多宽的?

为了方便理解,我们一直使用:

LayoutUtility.GetPreferredWidth(rect);

但实际 HorizontalLayoutGroup 的主要布局逻辑,在它的父类 HorizontalOrVerticalLayoutGroup里面。核心代码会查询:

LayoutUtility.GetMinSize(child, axis);LayoutUtility.GetPreferredSize(child, axis);LayoutUtility.GetFlexibleSize(child, axis);

当:

axis = 0

表示:

HorizontalWidth

于是:

GetPreferredSize(child, 0)

内部最终对应到:

GetPreferredWidth(child)

所以真实调用链可以理解成:

HorizontalLayoutGroup↓HorizontalOrVerticalLayoutGroup↓GetPreferredSize(child, axis = 0)↓LayoutUtility.GetPreferredWidth(child)↓查询 ILayoutElement

HorizontalLayoutGroup 的源码本身很薄,真正的 Min / Preferred / Flexible 查询和尺寸分配逻辑主要在父类 HorizontalOrVerticalLayoutGroup 中。

这一点理解之后,再读下一阶段源码会舒服很多。

13. 用 TMP_Text A / B / C 完整跑一次

回到我们的例子:

Canvas└── Panel(HorizontalLayoutGroup)├── TMP_Text A├── TMP_Text B└── TMP_Text C

文本:

AUnityHello Unity

假设为了方便理解,它们提供的:

Preferred Width

分别是:

A           = 15Unity       = 50Hello Unity = 100

HorizontalLayoutGroup 开始处理水平方向。

对于 A:

LayoutUtility.GetPreferredSize(A, 0);

得到:

15

对于 B:

LayoutUtility.GetPreferredSize(B, 0);

得到:

50

对于 C:

LayoutUtility.GetPreferredSize(C, 0);

得到:

100

于是 LayoutGroup 手里就有了:

A -> 15Unity -> 50Hello Unity -> 100

接下来它还会结合:

Panel 可用宽度PaddingSpacingMinPreferredFlexibleControl Child SizeChild Force Expand

最终决定三个 Child:

真正的 Width真正的 X Position

这时候才会修改RectTransform,所以真正的数据流是:

TMP_Text↓计算自己的布局需求↓ILayoutElement↓LayoutUtility 查询↓HorizontalLayoutGroup 使用↓计算最终布局↓RectTransform

14. 给 TMP_Text B 添加 LayoutElement,再跑一次

现在给 TMP_Text B 添加 LayoutElement。设置:

☑ Preferred Width = 300

假设:

TMP_TextPreferred Width = 50Priority = 0

LayoutElement:

Preferred Width = 300Priority = 1

LayoutUtility 查询 B:

LayoutUtility.GetPreferredWidth(B);

这时候返回300,而不是50。于是布局需求从:

A       Unity       Hello Unity15       50             100

变成:

A             Unity             Hello Unity15             300                  100

如果:

Control Child Size -> Width

已经开启,那么 HorizontalLayoutGroup 会进一步根据这些布局信息计算最终 Width。

于是我们可能看到:

A    [                 Unity                 ]    Hello Unity

注意这里。真正变宽的是 B 的 RectTransform 布局空间,不是Unity 这几个字符突然变宽了。

这又把我们前面的概念串回来了:

LayoutElement 改变的是布局需求,而不是文字本身。

15. LayoutUtility 为什么不读取 RectTransform.width?

现在应该能回答这个问题了。

如果它读取:

rect.rect.width

会发生一个死循环式的设计问题:

LayoutGroup↓需要计算 Child Width但是↓LayoutUtility 又读取 Child 当前 Width然后↓用旧 Width 决定新 Width

那么自动布局系统就很难表达:

“我现在虽然只有 100 宽,但理想情况下我需要 300。”

所以 RectTransform 负责当前结果,而ILayoutElement 负责尺寸需求。

数据与结果分开以后:

需求↓计算↓结果

整个系统才能稳定工作。

Unity 对 ILayoutElement 的接口说明甚至明确提醒:水平方向的布局输入属性不应该依赖自身 RectTransform 的水平布局结果,否则行为可能变得不确定。

这其实就是在防止:

输入依赖输出。

16. 那 Vertical 为什么可以依赖 Width?

这里还有一个非常漂亮的设计。

Unity 对 Horizontal 和 Vertical 并不是完全对称处理。

例如:TMP_Text 的 Height 往往会受到 Width 影响。

同一段文字:

Hello Unity Layout System

如果 Width:

300

可能:

一行

如果 Width:

100

可能:

Hello UnityLayoutSystem

于是 Height 变大。

所以 Unity Layout 的思想是:

先处理 Horizontal↓Width 基本确定↓再处理 Vertical↓Height 可以依赖 Width

这也是为什么 ILayoutElement 的说明中允许垂直布局数据依赖已经确定的水平信息。

这一步非常重要。

因为下一篇讲:

LayoutRebuilder

的时候,我们就会看到:

Calculate Horizontal↓Calculate Vertical↓Set Horizontal↓Set Vertical

这个顺序并不是随便写的。

17. 我们自己什么时候会使用 LayoutUtility?

学源码最终还是要回到项目。

LayoutUtility 并不是 Unity 内部专属工具。

我们自己的组件一样可以使用。

例如之前讨论过的:

Image A└── TMP_Text B

需求:

文本变长,背景跟着变宽。

我们以前设计过UISizeAdapter。如果想要的是:

目标当前实际尺寸

可以使用:

target.rect.size

但如果想要的是:

目标在 Layout System 中的理想尺寸

则可以使用:

float preferredWidth = LayoutUtility.GetPreferredWidth(target);float preferredHeight = LayoutUtility.GetPreferredHeight(target);

例如:

float width = LayoutUtility.GetPreferredWidth(target) + paddingLeft + paddingRight;

然后设置背景。这时候我们的 UISizeAdapter 就不只是监听 RectTransform,而是开始真正利用Unity Layout System提供的数据。

18. 什么时候不应该用 LayoutUtility?

LayoutUtility 查询的是 Layout Data,不是 任何 GameObject 的真实 Bounds。

所以如果你的目标没有任何 ILayoutElement,或者你真正关心的是 当前实际 RectTransform 尺寸。那就没必要硬用 LayoutUtility.GetPreferredWidth(),此时 rect.rect.size 反而更直接。

可以这样区分:

我想知道什么
更适合的数据
UI 当前实际多大
rect.rect.size
布局最小需要多大
LayoutUtility.GetMinWidth/Height
布局理想需要多大
LayoutUtility.GetPreferredWidth/Height
剩余空间如何扩展
LayoutUtility.GetFlexibleWidth/Height

不要看到 LayoutUtility 就认为它是 “RectTransform 尺寸万能查询器。” 它不是。

19. 从架构角度看,为什么要有 LayoutUtility?

现在从源码跳出来,再看这个设计。

如果没有LayoutUtility,HorizontalLayoutGroup 就得自己写:

查找所有 ILayoutElement↓过滤失效组件↓判断属性有没有提供↓比较 Layout Priority↓处理相同 Priority↓得到最终值

VerticalLayoutGroup:

再写一次。

ContentSizeFitter:

再写一次。

我们自己的 Layout:

再写一次。

这会非常重复。

于是 Unity 把:

“怎么获得一个 UI 的最终布局数据”

抽出来:

              LayoutUtility▲┌───────────┼───────────┐│││Horizontal       Vertical  ContentSizeFitterLayoutGroup    LayoutGroup

各个 Layout Controller 不再关心,数据到底来自 TMP、Image 还是 LayoutElement?

只需要问 LayoutUtility.GetPreferredWidth(rect); 就可以了。

这就是非常典型的: 把“数据提供”“数据查询”“布局执行”拆开。

20. 现在重新看整个 Unity Layout System

经过前面的文章,现在已经可以把布局数据这一条线完整画出来:

                 UI 内容│┌───────────┴───────────┐▼▼    TMP_Text               LayoutElement││└───────────┬───────────┘▼              ILayoutElement│▼              LayoutUtility│┌──────────┼──────────┐▼▼▼        Min     Preferred   Flexible│││└──────────┼──────────┘▼         HorizontalLayoutGroup│▼布局算法计算│▼              RectTransform│▼最终 UI

这条链回答的是:

布局数据从哪里来,以及 LayoutGroup 怎么拿到这些数据。

但是还有另一个问题没有解决:

这些东西什么时候开始重新计算?

这就是下一篇文章要解决的问题了。

21. 小结

这一篇真正需要记住的,不是 LayoutUtility 有多少个 API,而是下面 5 个核心点:

  1. LayoutUtility 是布局数据查询工具,不负责真正布局。
  2. 它查询的是同一个 GameObject 上实现 ILayoutElement 的组件,而不是直接读取 RectTransform Width。
  3. 布局属性为负值通常代表“这个属性没有提供”,查询时会被忽略。
  4. 多个有效 ILayoutElement 同时存在时,优先使用更高 layoutPriority;同一最高优先级下取更大的有效值。
  5. LayoutElement 并不是修改 TMP_Text 的布局数据,而是向 Layout System 提供另一份布局数据。

最后再记住一条最重要的数据链:

TMP_Text / LayoutElement↓     ILayoutElement↓     LayoutUtility↓查询布局需求↓HorizontalLayoutGroup↓计算最终布局↓    RectTransform

如果这条链能够在脑子里自然跑通:

那么:

HorizontalLayoutGroup 是如何知道 Text 有多宽的?

答案已经非常明确了:它自己根本不知道文字怎么计算宽度。

它只是通过 LayoutUtility 向 ILayoutElement 询问 “你希望自己有多宽?”。

这正是 Unity UI Layout System 解耦得非常漂亮的地方。

相关学习资料