我在 Avalonia 里用得最多的布局控件就是 Grid,平时写界面基本离不开它。真正让我下决心把源码翻完的,是两件小事:某次两列都写了*,等宽比例看起来一模一样,实际渲染出来却一边宽一边窄;另一次在列表项里混用了Auto和*,滚动时布局肉眼可见地在跳。网上搜了一圈,大部分回答都停留在"别这么写"的层面,没人讲清楚背后的计算逻辑。于是我直接把 Grid 的源码从头到尾读了一遍,这篇文章就是我整理出来的阅读笔记。
这篇内容不会讲 XAML 的常规用法,而是聚焦在内部机制:定义对象怎么存数据、Measure 阶段怎么分配尺寸、Arrange 阶段怎么把尺寸变成坐标、哪些写法会在性能上付出代价。适合已经用过一阵子 Grid、想理解布局算法或者准备自己写自定义布局控件的读者。如果你只是照着文档摆控件,那这篇可能有点深,但读完之后你再遇到 Grid 的疑难杂症,定位问题的速度会快很多。
1. 源码地图:先搞清楚代码藏在哪些文件里
读大型框架源码最怕一头扎进文件里出不来了。我拿到 Avalonia 仓库之后,没有直接从Grid.cs的第一行开始啃,而是先按"入口 - 数据 - 算法"三层把相关文件列了出来。这套整理方式在阅读其他布局控件时也适用。
1.1 文件职责边界:谁管属性、谁管布局、谁管算法
在我翻的这版源码里,Grid 相关的类型主要分布在几个文件里,它们各司其职:
| 文件/类型 | 职责 |
|---|---|
Grid类 | 对外暴露RowDefinitions、ColumnDefinitions、附加属性,重写MeasureOverride与ArrangeOverride,并维护内部子元素数组 |
RowDefinition/ColumnDefinition | 描述单行/单列的用户配置,比如尺寸模式、最小/最大尺寸 |
GridLength | 尺寸的底层结构,区分 Pixel、Auto、Star 三种模式 |
GridCell | 布局过程中生成的内部结构体,记录子元素的行列索引和跨行跨列信息 |
GridLayout内部静态类 | 核心布局算法,MeasureOverride和ArrangeOverride的主要逻辑都搬到了这里 |
这个分工很有意思。Grid类本身非常"克制",它只做对外交互和状态缓存,真正的测量和排列计算全部下沉到了GridLayout里。GridLayout是内部静态类,不对外暴露,这说明作者希望把"布局策略"和"控件外观"彻底拆开。阅读时我会先看Grid类的两个入口方法,再跳进GridLayout,最后回头对照数据结构。
1.2 两个入口方法:顺着调用链往下走
Grid类内部真正需要关注的只有两个方法:
protected override Size MeasureOverride(Size availableSize) { // 先根据 Padding 计算出真正可用的测量尺寸 // 然后调用 GridLayout.MeasureOverride } protected override Size ArrangeOverride(Size finalSize) { // 拿到最终尺寸后调用 GridLayout.ArrangeOverride }这两个方法就是整棵布局树的上下半场。MeasureOverride是"申报"阶段,Grid问每个子元素"给你这么多空间,你最小需要多少";ArrangeOverride是"分配"阶段,Grid拿到最终尺寸后,把每个子元素放到具体的位置上。我建议你打断点调试时不要只断在Grid类里,而是断进GridLayout,那里面才是真正的战场。
顺带说一句,Avalonia 的这套设计跟 WPF 的 Grid 算法是同源的,很多方法名和策略都能在 WPF 里找到对应。如果你以前读过 WPF 的布局源码,会发现这里既熟悉又陌生,熟悉的是整体思路,陌生的是 Avalonia 下对接口和缓存机制的重新设计。
2. 数据骨架:Grid 的布局计算到底基于什么
布局算法写得再花哨,最终操作的对象也就那么几种。在进入测量细节之前,必须先把三种数据搞清楚:GridLength表示什么、RowDefinition内部缓存了哪些字段、GridCell数组是怎么生成的。
2.1 GridLength:Pixel、Auto、Star 的底层表示
XAML 里我们写Width="100"、Width="Auto"、Width="2*",到了源码里都会被解析成一个GridLength结构体:
public struct GridLength { public double Value { get; } public GridUnitType GridUnitType { get; } }GridUnitType是枚举,分为Pixel、Auto、Star三种。100就是 Pixel,Auto就是 Auto,2*就是Value=2, GridUnitType=Star。这个结构本身很简单,难点在布局时怎么把不同类型的值统一换算成最终像素值。在定义对象内部,布局算法并不直接操作GridLength,而是先把它拆解成SizeType加上几个可写的 double 字段,这样测量时才能动态写入中间结果。
2.2 DefinitionBase 里的缓存字段
RowDefinition和ColumnDefinition有一个共同基类,姑且叫DefinitionBase。这个基类里除了保存用户的Height/Width配置之外,还缓存了一整套布局中间值,我用表格列一下:
| 字段 | 含义 |
|---|---|
UserMinSize | 用户设置的MinHeight/MinWidth |
UserMaxSize | 用户设置的MaxHeight/MaxWidth |
SizeType | 当前尺寸模式,由GridLength转换而来 |
MinSize | 布局用的最小值,通常就是用户 min,没设则为 0 |
PreferredSize | 首轮测量后比较理想的最小尺寸 |
DesiredSize | 子元素测量后申报的期望尺寸 |
FinalSize | 最终分配到的尺寸 |
这些字段是理解源码的关键。我一开始看DesiredSize和FinalSize总搞混,后来这样理解:DesiredSize是子元素"想要的",FinalSize是 Grid"实际给的"。测量阶段不断更新前五个字段,排列阶段才生成FinalSize。正因为这些中间值直接挂在定义对象上,Grid 在测量时才能反复读改写而不需要额外维护一个大数组。
2.3 GridCell 数组的生成
GridCell是一个内部结构体,一个子元素对应一个 cell:
internal struct GridCell { public int RowIndex; public int ColumnIndex; public int RowSpan; public int ColumnSpan; public Layoutable? Child; }每次MeasureOverride开始时,Grid 会遍历Children,读取每个子元素的Grid.Row、Grid.Column、Grid.RowSpan、Grid.ColumnSpan附加属性,生成这个数组。这里有两个容易被忽略的细节。
第一,如果子元素的Grid.Row超出了RowDefinitions的数量,Grid 并不会报错,它会把有效行数自动扩展到能容纳该子元素及其跨度的最小值。这个行为在源码里是个明确的分支逻辑。你可以在 XAML 里不写任何RowDefinitions,直接放几个子元素并设置Grid.Row,Grid 依然能工作,靠的就是这套隐式扩展机制。
第二,GridCell数组会被缓存,并在定义集合变化时失效。这不是微优化,而是因为测量可能要跑好几轮,每一轮都要重新访问行列信息,如果每次都在循环里去解析附加属性,成本会高不少。
2.4 定义集合失效通知
在DefinitionBase和Grid之间有一条"变更通知链"。用户修改了RowDefinition.Height,定义对象会通知 Grid "我的数据变了",Grid 随后调用失效方法让整个布局重新测量。这条链路的存在解释了为什么运行期修改RowDefinitions的代价是全局重排——不是只影响那一行,而是整个 Grid 子树都要重新走一遍 Measure 和 Arrange。
搞清楚了这些数据结构,接下来的测量算法才能看明白。你要是直接跳过这块去读GridLayout,大概率会在各种 double 数组之间迷路。
3. MeasureOverride 的核心算法:三种尺寸模式是如何一步步算出结果的
这是整篇源码最精华的部分。GridLayout.MeasureOverride在执行时并不是简单地"每个子元素测一遍",它会把尺寸分配拆成多个阶段,每个阶段的目的都不一样。
3.1 没有 Star 时:一遍测完,没有回头路
如果 Grid 的所有行列都是 Pixel 或 Auto,算法是最简单的路径。它遍历所有 cell,用当前行列的约束去测量子元素,然后把每个子元素的期望尺寸记录到对应的行列上。
这里有一个关键点:测量单元格时,约束不是随便给的。Pixel 类型的列,子元素拿到的约束宽度就是该列的固定值;Auto 类型的列,子元素会拿到一个相对宽松的约束,让它充分表达期望尺寸。测量完成后,Auto 列的宽度就是所有落在该列的子元素期望宽度的最大值。
这个模式不涉及迭代,因为 Pixel 和 Auto 之间没有依赖关系。子元素在测量时不会反过来改变 Pixel 列宽,所以一遍就能算出所有结果。
3.2 有 Star 时:先测非 Star 部分,再分配剩余空间
一旦存在*行或*列,算法就进入复杂路径。Star 的含义是"抢占剩余空间",所以必须先知道剩余空间有多少。剩余空间的计算公式是:
可用空间 = 总尺寸 - 所有 Pixel 列占用的尺寸 - 所有 Auto 列测量出的期望尺寸问题在于 Auto 列的期望尺寸又依赖于测量。Measurement 阶段会先把 Star 维度的约束放开,具体做法是:在测量子元素时,如果该子元素所在的列是 Star 列,约束宽度会设为无穷大,让子元素按内容自然给出期望尺寸。这一步结束后,Grid 就拿到了所有非 Star 行列的实际占用,剩下的空间才能分给 Star 列。
3.3 Star 分配的核心逻辑:比例 + 迭代修正
很多人以为2*和3*就是把剩余空间按 2:3 直接切。直观上没错,但源码里的DistributeSize逻辑要复杂得多,因为它必须同时满足每个 Star 列的MinWidth和MaxWidth。
我拿一个具体例子走一遍。假设有三列,宽度分别是2*、3*、1*,剩余宽度是 600:
- 总和是
2 + 3 + 1 = 6,每个 star 单位对应600 / 6 = 100。 - 第一轮分配结果是 200、300、100。
- 如果第二列设置了
MaxWidth = 250,那它只能拿到 250,剩下 50 要重新分给第一列和第三列。 - 重新分配时,剩余两列的 star 比例是
2:1,第一列拿到约 33.3,第三列拿到约 16.7。 - 最终结果是约 233.3、250、116.7。
注意"重新分给第一列和第三列"这一步,它不是一个一次性计算,而是一个循环。源码会反复检查每个 Star 列是否触碰了 Min 或 Max 边界,一旦某列被"卡住",它就从分配池里剔除,剩余空间重新在未卡住的列之间进行比例分配。这个循环要一直跑到没有新的列被卡住为止。
为什么一定要用循环而不是直接算?因为一个列触碰 Max 后,重新分配给其他列的空间可能让另一个列的尺寸也超过它的 Max,于是第二个列也要被剔除,再触发下一轮重分。边界条件会级联触发,所以迭代是唯一稳妥的做法。
3.4 Auto 的二次测量:真正影响性能的部分
Auto 列有一个特性,它的期望尺寸来自子元素的测量结果,而子元素的测量结果又可能受到 Star 列宽度的影响。如果一个子元素跨了多列(ColumnSpan大于 1),且其中既有 Auto 列又有 Star 列,那么第一轮测量时这个子元素拿的是无穷宽约束,测出来的期望尺寸并不准确。等 Star 列宽度确定后,这个子元素需要重新测量一次,以获取在真实宽度下的期望尺寸。
源码里确实有一组专门处理"重新测量"的逻辑。它会标记那些"跨了 Star 列且所在行列中包含 Auto"的 cell,在 Star 分配完成后,用新的尺寸约束再测一遍,并把结果更新到 Auto 列的期望尺寸上。如果这个 Auto 列因此变宽了,可能又会影响其他列的可用空间,所以整个流程在极端情况下会形成多轮测量。
这就是为什么Auto + Star混排会变慢的根本原因:它不是做一次测量,而是可能做两轮甚至更多轮。如果你写了一个复杂的列表项,里面嵌套了好几层 Grid,每层都有这种结构,代价会被指数放大。
3.5 Infinity 约束下 Star 的退化
还有一种特殊情况值得单独说:当MeasureOverride收到的availableSize是无穷大时,比如 Grid 被放在横向滚动的 ScrollViewer 里,Star 列的计算会走一条特殊路径。
源码在分配 Star 时,如果发现可用空间是无穷大,就不会按"总宽 600 除以总 star 数"来算了,因为分母根本不存在。它会把 Star 列的尺寸变成基于内容期望值的分摊。效果就是:这个 Star 列看起来像 Auto 一样,按内容的期望尺寸收缩或撑开。很多人觉得 Grid 放进 ScrollViewer 之后行为怪异,原因就在这里,Star 在无限空间下根本不是你想当然的"占满剩余宽度"。
4. ArrangeOverride:把行高列宽变成每个子元素的坐标
Measure 阶段结束后,Grid 手上已经有了一张"尺寸表":每一行最终多高、每一列最终多宽。ArrangeOverride要做的就是把这张表变成每个子元素的Bounds。
4.1 从尺寸表到 offset 数组的累加
这一步的原理非常朴素。假设有四行,最终行高分别是[60, 100, 80, 40],那么每行的 Y 坐标起点就是累加结果:
rowOffsets[0] = 0 rowOffsets[1] = 60 rowOffsets[2] = 160 rowOffsets[3] = 240 rowOffsets[4] = 280 // 最后一个元素保存总高度列方向同理。源码里会先用一个循环生成offsets数组,再遍历所有 cell,用offsets[i]和offsets[i + span]相减得到该 cell 的宽度和高度。这一步计算的代价是 O(n),非常轻量。
4.2 单个 cell 的最终 Bounds 计算
每个 cell 的位置计算公式可以写成:
Rect bounds = new Rect( columnOffsets[cell.ColumnIndex], rowOffsets[cell.RowIndex], columnOffsets[cell.ColumnIndex + cell.ColumnSpan] - columnOffsets[cell.ColumnIndex], rowOffsets[cell.RowIndex + cell.RowSpan] - rowOffsets[cell.RowIndex] );然后调用子元素的Arrange(bounds)。不过这里有个隐藏细节:如果子元素设置了HorizontalAlignment或VerticalAlignment,Arrange 时还要根据对齐方式对尺寸做裁剪,最后真正传给子元素的矩形会比 bounds 小。这部分逻辑并不在 Grid 源码里,而是由基类负责处理,阅读时容易误以为 Grid 漏了某个对齐步骤。
4.3 跨行跨列时尺寸如何回填
跨行跨列的子元素在 Arrange 阶段非常简单,直接用累积偏移的差值就能算出完整矩形。但在 Measure 阶段,跨行跨列的期望尺寸会有一层"回填"逻辑。一个跨越两列的子元素测出 300 宽,它不会把这 300 直接塞给某一列,而是按两列当前的比例或状态进行分摊,再更新到各自的期望尺寸记录里。这归功于GridLayout里专门处理 span 的辅助逻辑,是整个测量阶段最容易看晕的部分。
我给一个理解上的抓手:如果你调试时发现某个子元素的实际宽度和你在 XAML 里写的Grid.ColumnSpan="2"对不上,先去看它跨的那两列各自的DesiredSize是否都更新了,八成是第二列没接住分摊出来的尺寸。
5. 源码读完后,我回头理解了那几个"坑"
源码的价值不在赏心悦目,而在它能解释实际现象。这里我总结几条读完源码后最深的体会,都是平时写 XAML 真正能用上的。
5.1 Auto 和 Star 混排为什么慢
前面已经讲过二次测量机制。只要布局里存在跨列且跨到 Star 列的 Auto 单元格,源码就会进入重新测量路径。这个路径涉及额外的子元素 Measure 调用,子元素越多,额外耗时越明显。
我自己的经验是:列表项级别的 Grid,尽量不要让同一行列上同时出现 Auto 和 Star,更不要让一个子元素去跨 Star 列和 Auto 列。如果确实需要,优先改成固定 Pixel 值或嵌套 Grid,把这种"测量耦合"拆开。性能收益在复杂页面上非常直观。
5.2 MinWidth / MaxWidth 会改变 Star 比例的直觉
很多人设置2*和3*时,默认它们一定会按 2:3 走。但 Min/Max 约束会强制打断这个比例,而且打断的效果是迭代扩散的。我遇到的那次"等宽列实际宽度不一样"问题,就是因为在某一列上设置了MinWidth,它把约 50 的空间从其他列抢走了。
解决办法是:优先使用固定的 Pixel 宽度加HorizontalAlignment=Stretch来表达"最小宽度约束",而不是在 Star 列上堆 MinWidth。如果确实要用,建议在纸上把迭代分配过程手算一遍,别信直觉。
5.3 运行期修改 Definition 集合要谨慎
源码里的失效通知链决定了,任何对RowDefinitions或ColumnDefinitions的增删改,都会让整个 Grid 重新测量。这意味着你单独改变一行高度时,其他所有行也会被重新计算一遍。频繁改动的话,性能损失不是这一行的,而是整个子树的。
如果你要做拖拽调整行高的功能,不要每次 mousemove 都直接改RowDefinition.Height。先修改一个本地变量,用鼠标移动的临时覆盖值去控制,等鼠标抬起后再一次性写进 Definition,否则整个拖拽过程会触发几十次全局 Measure。
5.4 Grid 放进 ScrollViewer 后的 Star 退化问题
结合前面 Infinite 约束的分支,可以解释一个经典现象:把 Grid 放进横向 ScrollViewer 后,Width="*"的列不再撑满宽度,而是按内容收缩。因为 ScrollViewer 给 Grid 的横向约束是无穷大,Star 分配逻辑找不到"剩余空间"这个分母,只能退化成按内容期望尺寸来算。
如果你想在 ScrollViewer 里保持 Grid 列的真正常规比例,一种可靠做法是给 Grid 设置一个固定的Width,或者用MinWidth兜底。读完源码之后你再看到这个现象就不会觉得是控件出 bug 了,这是设计如此。
5.5 自定义布局控件的最短借鉴清单
如果你想自己写一个自定义 Panel,Grid 源码是最完整的参考教材。我读完后的收获可以浓缩成几条原则:
- Measure 阶段一定要能重复执行,不要在 Measure 里保存不可覆盖的全局状态。
- 用数组缓存计算结果,避免每次循环重复解析附加属性。
- Star 分配需求本质是"在约束下按比例分配剩余空间",这个问题有通用解,务必处理 Min/Max 的迭代修正。
- Arrange 阶段用 offset 数组累积坐标,比频繁修改子元素的 Bounds 高效得多。
我自己后来写的一个指标看板布局控件,就是照着这套思路搭的骨架,调试效率提升了一个档次。
5.6 调试 Grid 源码时值得打断点的几个位置
最后说点实操建议。如果你也想自己跟一遍源码,我推荐在这几个位置打断点:
GridLayout.MeasureOverride入口,观察measureSize和hasStarRows/hasStarColumns的值。MeasureCell里调用子元素Measure之后,检查子元素的DesiredSize,对照约束值理解测量输入输出。DistributeSize的循环内部,每次迭代都观察哪些列被"卡住"了,这是理解 Star 迭代修正的最佳位置。ArrangeOverride里生成 offset 数组之后,随意挑一个 cell,手算它的 Bounds,和实际结果对比。
这些断点跟完一遍,你对布局控件的理解会和只看文档完全不同。源码里很多方法名看起来抽象,实际执行路径相当直观。
我个人的体会是,读 Grid 源码最大的收获并不是记住了某个算法的具体实现,而是建立了"布局代价"的意识。以后再写 XAML,我会下意识地判断某个结构在测量阶段要跑几轮、会不会触发二次测量、Star 比例会不会被 Min/Max 干扰。这些判断很难从文档里获得,只有真正读过源码,看到那些循环和分支才会理解。如果你也经常被网格布局的奇怪表现困扰,花一个下午把这条代码路径跟下来,绝对值回票价。