ARTICLE · 1013922
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"↓读取字体↓计算每个字符↓算出 WidthLayoutUtility 根本不关心 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 = 0LayoutElement:
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 = 100Element 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↓高优先级覆盖低优先级↓同优先级取较大值↓returnUnity 官方 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)↓查询 ILayoutElementHorizontalLayoutGroup 的源码本身很薄,真正的 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 = 100HorizontalLayoutGroup 开始处理水平方向。
对于 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 使用↓计算最终布局↓RectTransform14. 给 TMP_Text B 添加 LayoutElement,再跑一次
现在给 TMP_Text B 添加 LayoutElement。设置:
☑ Preferred Width = 300假设:
TMP_TextPreferred Width = 50Priority = 0LayoutElement:
Preferred Width = 300Priority = 1LayoutUtility 查询 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 反而更直接。
可以这样区分:
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 个核心点:
- LayoutUtility 是布局数据查询工具,不负责真正布局。
- 它查询的是同一个 GameObject 上实现
ILayoutElement的组件,而不是直接读取 RectTransform Width。 - 布局属性为负值通常代表“这个属性没有提供”,查询时会被忽略。
- 多个有效 ILayoutElement 同时存在时,优先使用更高
layoutPriority;同一最高优先级下取更大的有效值。 - LayoutElement 并不是修改 TMP_Text 的布局数据,而是向 Layout System 提供另一份布局数据。
最后再记住一条最重要的数据链:
TMP_Text / LayoutElement↓ ILayoutElement↓ LayoutUtility↓查询布局需求↓HorizontalLayoutGroup↓计算最终布局↓ RectTransform如果这条链能够在脑子里自然跑通:
那么:
HorizontalLayoutGroup 是如何知道 Text 有多宽的?
答案已经非常明确了:它自己根本不知道文字怎么计算宽度。
它只是通过 LayoutUtility 向 ILayoutElement 询问 “你希望自己有多宽?”。
这正是 Unity UI Layout System 解耦得非常漂亮的地方。