UGUI源碼剖析(第九章):布局的實現——LayoutGroup的算法與實踐
在前一章中,我們剖析了LayoutRebuilder是如何調度布局重建的。現在,我們將深入到布局核心,去看看那些具體的組件——LayoutGroup系列組件是如何響應指令,并執行其各自獨特的、充滿數學細節的布局算法的。這將是一次深入到UGUI自動布局系統“應用層”源碼的分析旅程。
1. LayoutGroup:所有布局組的“抽象基石”
LayoutGroup是一個抽象基類,它為所有具體的布局組(Horizontal, Vertical, Grid)提供了共享的基礎設施和核心邏輯。
1.1 核心數據成員與屬性
- [SerializeField] protected RectOffset m_Padding:定義了布局組內容區域與其RectTransform邊界之間的內邊距。
- [SerializeField] protected TextAnchor m_ChildAlignment:定義了當子元素未占滿全部分配空間時,它們在容器內的對齊方式。
- protected DrivenRectTransformTracker m_Tracker:這是至關重要的一個成員。每一個LayoutGroup都擁有一個自己的DrivenRectTransformTracker實例,用于記錄和管理所有被它所控制的子RectTransform的屬性。當LayoutGroup被禁用時,它會調用m_Tracker.Clear(),將被驅動的屬性**“釋放”**,將控制權還給用戶。
- private List m_RectChildren:一個用于緩存有效子元素的列表。這個列表在每次布局計算開始時被重新填充,是所有后續算法的操作對象。
1.2 核心方法:CalculateLayoutInputHorizontal() (第一階段的入口)
這個方法雖然名為Horizontal,但它實際上是所有LayoutGroup第一階段布局計算的通用入口。
// LayoutGroup.cs
public virtual void CalculateLayoutInputHorizontal()
{m_RectChildren.Clear();var toIgnoreList = ListPool<Component>.Get(); // 使用對象池避免GCfor (int i = 0; i < rectTransform.childCount; i++){var rect = rectTransform.GetChild(i) as RectTransform;if (rect == null || !rect.gameObject.activeInHierarchy)continue;// 查找子對象上所有實現ILayoutIgnorer的組件rect.GetComponents(typeof(ILayoutIgnorer), toIgnoreList);if (toIgnoreList.Count == 0){m_RectChildren.Add(rect); // 如果沒有忽略器,直接添加continue;}// 如果有,則遍歷檢查ignoreLayout屬性for (int j = 0; j < toIgnoreList.Count; j++){var ignorer = (ILayoutIgnorer)toIgnoreList[j];if (!ignorer.ignoreLayout){m_RectChildren.Add(rect); // 只要有一個忽略器不要求忽略,就添加break;}}}ListPool<Component>.Release(toIgnoreList);m_Tracker.Clear(); // 在每次計算開始前,清空之前的驅動記錄
}
- 子元素篩選:這個方法的核心職責,是準備好本次布局計算所需要處理的、所有有效的子元素列表 m_RectChildren。它會遍歷所有子Transform,并排除掉那些inactive的、或者被ILayoutIgnorer組件(如LayoutElement的ignoreLayout屬性)標記為應忽略的子對象。
- 驅動器重置:m_Tracker.Clear()這一行至關重要。它確保了在每次布局重建開始時,LayoutGroup都放棄了對子元素的所有舊的控制權,準備根據新的計算結果,建立新的驅動關系。
1.3 核心方法:SetDirty()
當LayoutGroup的任何屬性(如padding, spacing)發生變化時,都會調用SetDirty()。
// LayoutGroup.cs
protected void SetDirty()
{if (!IsActive())return;if (!CanvasUpdateRegistry.IsRebuildingLayout())LayoutRebuilder.MarkLayoutForRebuild(rectTransform);elseStartCoroutine(DelayedSetDirty(rectTransform));
}IEnumerator DelayedSetDirty(RectTransform rectTransform)
{yield return null;LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}
- 防止循環重建:if (!CanvasUpdateRegistry.IsRebuildingLayout())這個判斷是防止無限循環重建的關鍵。如果當前已經處于一個布局重建的流程中,再次立即調用MarkLayoutForRebuild可能會導致循環依賴。
- 延遲重建:為了解決上述問題,當檢測到已經在重建循環中時,它會啟動一個協程DelayedSetDirty,將本次重建請求,延遲到下一幀執行。這是一個非常精巧的設計,保證了布局系統的穩定性。
2. HorizontalOrVerticalLayoutGroup:線性布局的算法核心
這個抽象基類實現了線性布局(水平或垂直)最核心的計算和應用邏輯。
2.1 CalcAlongAxis:自下而上計算聚合尺寸
這是布局計算階段的核心算法。
// HorizontalOrVerticalLayoutGroup.cs
protected void CalcAlongAxis(int axis, bool isVertical)
{// ...bool alongOtherAxis = (isVertical ^ (axis == 1));// ...for (int i = 0; i < rectChildren.Count; i++){// ... 獲取子元素的min, preferred, flexible尺寸 ...if (alongOtherAxis) // 計算交叉軸{totalMin = Mathf.Max(min + combinedPadding, totalMin);// ...}else // 計算主軸{totalMin += min + spacing;// ...}}// ...SetLayoutInputForAxis(totalMin, totalPreferred, totalFlexible, axis);
}
- alongOtherAxis的精妙判斷:isVertical ^ (axis == 1)這個**異或(XOR)**運算,是一種非常高效的邏輯判斷。
- Horizontal (isVertical=false):
- 計算水平軸(axis=0)時, false ^ false = false -> !alongOtherAxis -> 主軸邏輯。
- 計算垂直軸(axis=1)時, false ^ true = true -> alongOtherAxis -> 交叉軸邏輯。
- Vertical (isVertical=true):
- 計算水平軸(axis=0)時, true ^ false = true -> alongOtherAxis -> 交叉軸邏輯。
- 計算垂直軸(axis=1)時, true ^ true = false -> !alongOtherAxis -> 主軸邏輯。
- Horizontal (isVertical=false):
- 算法核心:
- 主軸尺寸:一個線性布局在主軸上所需的總尺寸,等于所有子元素的尺寸之和,加上它們之間的間距(spacing)之和。
- 交叉軸尺寸:一個線性布局在交叉軸上所需的總尺寸,取決于所有子元素中,尺寸最大的那一個。
2.2 SetChildrenAlongAxis:自上而下應用位置和尺寸
這是布局應用階段的核心算法,其邏輯非常復雜,可以分解為幾步:
-
計算總空間和剩余空間:
float size = rectTransform.rect.size[axis]; // 獲取父容器的可用空間 float surplusSpace = size - GetTotalPreferredSize(axis); // 剩余空間 = 可用空間 - 所有子元素首選尺寸之和
-
計算彈性空間分配系數:
float itemFlexibleMultiplier = 0; if (surplusSpace > 0) {if (GetTotalFlexibleSize(axis) > 0)itemFlexibleMultiplier = surplusSpace / GetTotalFlexibleSize(axis); }
這計算出了每一個flexible單位可以分配到多少像素的額外空間。
-
計算最小/首選尺寸間的插值系數:
float minMaxLerp = 0; if (GetTotalMinSize(axis) != GetTotalPreferredSize(axis))minMaxLerp = Mathf.Clamp01((size - GetTotalMinSize(axis)) / (GetTotalPreferredSize(axis) - GetTotalMinSize(axis)));
如果父容器的可用空間size不足以滿足所有子元素的preferredSize,但又大于minSize,這個minMaxLerp系數就決定了子元素最終尺寸在min和preferred之間的“壓縮”程度。
-
遍歷并設置子元素:
for (...) {// ...// 核心公式:計算子元素最終尺寸float childSize = Mathf.Lerp(min, preferred, minMaxLerp);childSize += flexible * itemFlexibleMultiplier;if (controlSize){// 如果LayoutGroup控制尺寸,則應用計算出的childSizeSetChildAlongAxisWithScale(child, axis, pos, childSize, scaleFactor);}else{// 如果不控制尺寸,則只設置位置,并考慮對齊float offsetInCell = (childSize - child.sizeDelta[axis]) * alignmentOnAxis;SetChildAlongAxisWithScale(child, axis, pos + offsetInCell, scaleFactor);}pos += childSize * scaleFactor + spacing; // 更新下一個元素的起始位置 }
最終尺寸公式:childSize的計算是整個算法的精華。它首先在min和preferred之間進行插值(處理空間不足的情況),然后再疊加上根據flexible權重分配到的額外空間(處理空間富余的情況)。
SetChildAlongAxisWithScale: 這個輔助方法,最終會調用m_Tracker.Add(…)來記錄LayoutGroup正在驅動子元素的哪些RectTransform屬性,并將計算出的pos和size應用到子元素的anchoredPosition和sizeDelta上。
3. GridLayoutGroup:二維網格的布局算法
GridLayoutGroup的算法更為獨立,它不使用HorizontalOrVerticalLayoutGroup的基類方法。
3.1 尺寸計算階段 (CalculateLayoutInput…)
其核心是根據約束(Constraint)模式,來推算出網格的行列數,進而計算出整個組的總尺寸。
- FixedColumnCount: 列數固定,總寬度固定。總高度則取決于總行數(總元素數 / 列數)。
- Flexible: 在靈活模式下,它會嘗試根據當前父容器的可用寬度,來計算出每行能放下的單元格數量,再由此推算出總行數,并以此來計算總的首選高度。
3.2 布局應用階段 (SetLayoutVertical)
GridLayoutGroup巧妙地將所有位置和尺寸的設置,都放在了SetLayoutVertical這一個階段。
// GridLayoutGroup.cs
public override void SetLayoutHorizontal()
{// 在水平布局階段,只設置所有子元素的尺寸為固定的cellSizefor (int i = 0; i < rectChildrenCount; i++){// ...rect.sizeDelta = cellSize;}
}public override void SetLayoutVertical()
{// 在垂直布局階段,此時所有子元素的尺寸都已確定// 1. 根據約束和可用空間,計算出最終的行列數 (cellCountX, cellCountY)// ...// 2. 循環遍歷所有子元素for (int i = 0; i < rectChildrenCount; i++){// 3. 根據startAxis和索引i,通過取模(%)和整除(/)運算,計算出該元素的二維網格坐標(positionX, positionY)// ...// 4. 根據網格坐標、cellSize和spacing,計算出最終的本地位置// ...// 5. 調用SetChildAlongAxis,將位置和尺寸應用到子元素SetChildAlongAxis(rectChildren[i], 0, ...);SetChildAlongAxis(rectChildren[i], 1, ...);}
}
這種“先在Horizontal階段統一尺寸,再在Vertical階段統一位置”的策略,完美地契合了UGUI的兩遍式布局管線。它確保了在計算最終位置時,所有子元素的尺寸都已經是一個已知的、固定的值,從而大大簡化了布局算法的復雜性。
總結:
通過對LayoutGroup系列組件的源碼級分析,我們得以一窺UGUI自動布局系統強大功能背后的算法實現。
- Horizontal/VerticalLayoutGroup 的核心,是一套基于主軸/交叉軸概念的、分別進行累加和取最大值的聚合算法,并通過一個精密的插值與彈性分配公式,來最終確定每一個子元素的位置和尺寸。
- GridLayoutGroup 則通過約束模式,預先計算出網格的維度,然后在水平布局階段統一設置尺寸,在垂直布局階段再根據行列坐標,統一設置位置。
理解了這些組件在CalculateLayoutInput和SetLayout這兩個核心階段的不同算法和行為,不僅能幫助我們更精確地使用它們,更能讓我們在面對布局相關的性能問題時,清晰地知道其背后高昂的遍歷、查詢和計算代價究竟從何而來,從而做出更明智的優化決策。