.NET MAUI 跨平台布局系统深度解析:Layout、Measure/Arrange 与平台适配原理
【免费下载链接】maui.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.项目地址: https://gitcode.com/GitHub_Trending/ma/maui
本篇文章以 docs/design/layout.md 为骨架,结合
src/Core/src(MAUI.Core)与src/Controls(MAUI.Controls)的源码实现,系统性讲解 .NET MAUI 的跨平台布局架构:从LayoutAlignment、Dimension、Z-Index、Padding/Margin 等基础概念,到ILayout/ILayoutManager的扩展机制,再到 Grid、AbsoluteLayout、StackLayout 等内置布局的具体行为,以及 Measure/Arrange 全流程与 AndroidMeasureSpec的平台细节,最后给出从 Xamarin.Forms 迁移时的行为差异清单。读完本文,你将理解 MAUI 布局引擎“一次编写、三端一致”的底层工作原理,并具备自定义布局管理器、排查布局问题与迁移遗留代码的实战能力。
文档背景与适用范围
该设计文档阐述了 .NET MAUI 跨平台布局系统的工作原理,主体内容集中在 MAUI.Core 层(绝大多数布局逻辑实现于此),并包含少量为兼容 Xamarin.Forms 而在 MAUI.Controls 层保留的例外与定制。文档自述为 work in progress(进行中),因此本文以当前仓库快照中的源码为准进行印证与扩充。文中所引代码均可在仓库src/目录下直接查阅。
基础概念
LayoutAlignment:视图在容器中的定位
LayoutAlignment控制一个 View 在容器中的定位方式,共有四个取值:Fill、Start、Center、End。在 Controls SDK 中,Fill是所有 View 的默认值。
从源码看,该枚举定义在 src/Core/src/Primitives/LayoutAlignment.cs,值得注意的是注释中明确说明:我们不直接使用 MAUI.Controls 的 LayoutAlignment,因为它带有 Flags 特性,而这并不是我们想要的——这是 Core 层独立维护该枚举的原因。
LayoutAlignment在 Core 层主要由ComputeFrame()扩展方法处理(见 src/Core/src/Layouts/LayoutExtensions.cs)。当一个 View 被放入某个 Rect 中进行 Arrange 时,会调用ComputeFrame()来计算自身的 Frame,Frame 始终是相对于容器的坐标。
为了方便从 Xamarin.Forms 迁移,Controls 层仍在使用LayoutOptions结构,其中包含已废弃的 “AndExpand” 标志。Core 布局系统会忽略这些 “AndExpand” 标志,仅将LayoutOptions中的 Fill/Start/Center/End 值翻译为LayoutAlignment枚举。
注意:Controls 层仍然在 StackLayout(不包括VerticalStackLayout 或 HorizontalStackLayout)上支持 “AndExpand”,做法是在运行时把 StackLayout 转换成单列或单行的 Grid——因为这本来就是 Forms 中 “AndExpand” 真正在做的事。
Center
View 沿对应轴在容器内居中。
Start / End
View 对齐到容器沿该轴的起始边或结束边。垂直方向:Start是顶部边缘,End是底部边缘。水平方向:当容器FlowDirection为 LTR 时,Start是左边缘、End是右边缘;当容器为 RTL 时二者反转。
不过 Core 布局代码并不关心这一点——它总是按 LTR 计算 Frame。翻转工作全部由底层平台在 LayoutHandler 与平台布局视图(如 LayoutViewGroup、LayoutPanel)中完成,因此 Core 布局引擎无需关心实际的 FlowDirection。
Fill
- 如果 View 沿该轴没有显式尺寸,
Fill会让 Frame 在容器方向上等于容器大小(减去 margin)。此时DesiredSize无关紧要——Fill会把 View 拉伸/收缩到容器大小。 - 如果 View 沿该轴有显式尺寸(即
Width或Height被设置为Dimension.Unset之外的值),ComputeFrame()就会遇到冲突:用户既要求 Fill 又要求显式尺寸。此时显式尺寸优先,Frame 采用显式尺寸;若该尺寸小于可用空间,则 Frame 居中放置。
对应到源码,这一“显式尺寸优先、退化为居中”的逻辑正是 LayoutExtensions.cs 中AlignHorizontal/AlignVertical所做的:当对齐方式为Fill且IsExplicitSet(view.Width)为真(或 MaximumWidth 非无穷)时,把 alignment 视为Center处理。
例如给定如下布局:
<VerticalStackLayout WidthRequest="400"> <Label Text="Hello" WidthRequest="100" HorizontalOptions="Fill"/> </VerticalStackLayout>Label 将是 100 单位宽并水平居中。这一规则同样适用于沿该轴的 Maximum/Minimum 尺寸,例如:
<VerticalStackLayout WidthRequest="400"> <Label Text="Hello" MaximumWidthRequest="50" HorizontalOptions="Fill"/> </VerticalStackLayout>Label 同样水平居中,且宽度被限制为 50 单位。
Dimension:尺寸常量与“未设置”语义
Core 的布局代码依赖 src/Core/src/Primitives/Dimension.cs 中定义的一组常量,以避免在处理视图宽高时出现魔法数字:
public const double Minimum = 0; public const double Unset = double.NaN; public const double Maximum = double.PositiveInfinity; public static bool IsExplicitSet(double value) => !double.IsNaN(value); public static bool IsMaximumSet(double value) => !double.IsPositiveInfinity(value); public static bool IsMinimumSet(double value) => !double.IsNaN(value); public static double ResolveMinimum(double value) => IsMinimumSet(value) ? value : Minimum;- 默认情况下,Core 布局中视图的宽/高是
Dimension.Unset(即double.NaN),表示视图应取底层平台在给定其他约束(容器大小等)下所期望的尺寸。 - 若要为
IView指定显式的用户宽/高,IView.Width或IView.Height需要返回一个实际数字(介于Dimension.Minimum与Dimension.Maximum之间),可用Dimension.IsExplicitSet()辅助方法检查。 - 按定义,Core 布局系统中的任何 IView 都有最小宽/高(未另行指定时为零);是否设置了最大宽/高可用
Dimension.IsMaximumSet()检查。 - Controls 层为了兼容 Forms 的魔法数字,存在一些值上的“脱节”:例如 View 可以设置
WidthRequest为 -1,最终会映射为Dimension.Unset。
Z-Index:基于子视图顺序的实现
Z-Index 作为 Core 布局概念,并不显式映射到任何平台的 z-index 属性,而是依赖各平台上布局后备控件中子控件的绘制顺序。
例如在 Android 的 ViewGroup 中,若没有其他 Elevation 设置,子控件按加入顺序绘制:第一个子控件在最“底层”,之后重叠的子控件依次叠加在上方。Windows 与 iOS 的做法类似。
因此在 Core 的 LayoutHandler 实现中,ILayout中的视图在加入平台布局控件时会按ZIndex排序(同时保持它们在集合中的原始顺序)。随着子控件的增删或ZIndex被修改,这一顺序会被持续维护。由于集合中靠后的控件绘制在靠前控件之上,这就在不显式映射任何属性的情况下实现了 z-index 语义。
平台本身确实存在类似 z-index 的概念,但它们的语义差异过大,简单映射会带来跨平台布局中不希望出现的副作用(尤其是 Android 最接近的属性Elevation同时会影响视图的阴影,且会导致 Button 这类利用Elevation做“按下”等状态效果的控件出问题)。
示例 1:一个 Grid 有 3 个子元素,均为ZIndex = 0:
Label1 { ZIndex = 0 } Label2 { ZIndex = 0 } Label3 { ZIndex = 0 }LayoutHandler 会创建 LayoutViewGroup,子元素顺序为 {Label1, Label2, Label3}。
示例 2:一个 Grid 有 3 个子元素,其中 Label2 的 ZIndex 更高:
Label1 { ZIndex = 0 } Label2 { ZIndex = 10 } Label3 { ZIndex = 0 }LayoutHandler 会创建 LayoutViewGroup,子元素顺序为 {Label1, Label3, Label2}——Label2 因 z-index 最高被推到顶层,而 Label1 与 Label3 之间的相对顺序保持不变。
Padding:视图内部的空间
Padding 是视图内部、围绕内容的空间。任何实现IPadding的类型都可以有 padding。某些情况下 padding 由平台提供(将 IPadding 的 thickness 值映射到平台 padding 属性);对于跨平台布局,padding 由布局系统提供。
如果布局系统中请求了 padding,系统会尽最大能力提供——即使这意味着把控件压缩到最小尺寸(甚至为零),内容也会被压缩以让出 padding 空间。
Margin:视图外部的空间
Margin 是视图外部的空间,完全由布局系统处理。与 padding 一样,一旦请求了 margin 就一定会被满足,即便这会把平台视图本身压缩到消失。
Margins 会被计入视图测量后报告的DesiredSize。这一点在源码中体现为 LayoutExtensions.cs 的ComputeDesiredSize():测量前先从约束中减去 margin(widthConstraint -= margin.HorizontalThickness),测量后再把 margin 加回到返回的 Size 中。
Visibility:三值可见性模型
在 MAUI.Core 中,Visibility有三个取值:
Visible:可见Hidden:占据空间,但不可见Collapsed:不占据空间,也不可见
MAUI.Core 的内置布局完整理解并支持这三个值。
在 MAUI.Controls 中,出于向后兼容,可见性通过IsVisible属性被限制为两个值:IsVisible == true映射为Visibility.Visible,IsVisible == false映射为Visibility.Collapsed。
MAUI.Core 中各个控件的映射实现应该都能正确处理Visibility.Hidden。因此,虽然 MAUI.Controls 没有Visibility.Hidden的概念,其他 SDK 以及 MAUI.Controls 中自定义的 handler/控件都可以按需使用它。
Layout 与 ContentView 的区别
- Layout是一个视图列表,携带如何将这些视图排列在某个 Frame 内的规则与属性。Layout 的例子包括 Grid、AbsoluteLayout、StackLayout、VerticalStackLayout、HorizontalStackLayout。
- ContentView负责显示单个视图:接收输入视图(
Content属性),显示输出视图(PresentedContent属性)。输入视图与输出视图可以是同一个,也可能经过某种转换(例如使用模板)。ContentView 的例子包括 Page 和 ScrollView。
注意:由于一些向后兼容的约束,MAUI.Controls 中部分实现的基类关系容易让人困惑。例如 ScrollView并不是Layout,却继承自 Layout 类。
Measure / Arrange:跨平台测量与排布
MAUI.Core 的跨平台 measure/arrange 过程“寄生”在各平台的布局流程之上。每当平台要求某个后备控件(如 ContentViewGroup、LayoutViewGroup、LayoutPanel 等)进行测量或排布时,后备控件都会调用其CrossPlatformMeasure()或CrossPlatformArrange()方法。
- CrossPlatformMeasure:在 Layout 中负责对每个子视图调用
IView.Measure();在 ContentView 中负责对PresentedContent视图调用IView.Measure()。 - CrossPlatformArrange:在 Layout 中负责对每个子视图调用
IView.Arrange();在 ContentView 中负责对PresentedContent视图调用IView.Arrange()。
平台后备控件
Layout 的后备控件
所有布局在每个平台都由一个统一的后备控件承载:
| 平台 | 控件 |
|---|---|
| iOS | LayoutView |
| Android | LayoutViewGroup |
| WinUI | LayoutPanel |
(如果你熟悉 Xamarin.Forms 的布局方式,这些后备控件与各平台的DefaultRenderer实现类似。)
布局通过LayoutHandler映射到这些控件。LayoutHandler 负责:把每个子视图的平台控件加入后备控件、保持合适的 Z-Index 顺序、映射CrossPlatformMeasure()与CrossPlatformArrange()方法。
当后备控件(如 LayoutViewGroup)被测量(响应平台的 measure/layout pass)时,它会对其跨平台 IView 调用CrossPlatformMeasure();被排布时调用CrossPlatformArrange()。这正是控制权在平台布局系统与 MAUI.Core 布局系统之间交接的关键点之一。Android 侧的LayoutViewGroup定义见 src/Core/src/Platform/Android/LayoutViewGroup.cs,它实现了ICrossPlatformLayoutBacking接口。
后备控件还负责其布局的边界裁剪(bounds clipping),并在支持的平台上处理安全区(Safe Area)。
注意:目前仅 iOS 支持 Safe Area。文档明确表示 Android应当支持但尚未实现。
ContentView 的后备控件
所有内容视图在每个平台也由统一控件承载:
| 平台 | 控件 |
|---|---|
| iOS | ContentView |
| Android | ContentViewGroup |
| WinUI | ContentPanel |
ContentView 通过ContentViewHandler映射到这些控件。ContentViewHandler 负责:设置后备控件、更新平台内容、映射CrossPlatformArrange()与CrossPlatformMeasure()方法。
与布局一样,ContentView 后备控件同样处理边界裁剪与安全区。
布局实现
ILayout / ILayoutManager
ILayout接口(定义于 src/Core/src/Core/ILayout.cs)由几个接口组合而成:它是IView(本身是视图)、IContainer(即一组其他 IView 的列表)、IPadding(围绕内容提供 padding)、ISafeAreaView(感知宿主平台的安全区,可约束在安全区内或忽略边界)。
它还提供ClipsToBounds属性,决定其子视图能否显示在边界之外,还是被裁剪在边缘处。
📝 在 MAUI.Controls 中,
ClipsToBounds的值由 Layout 的IsClippedToBounds属性提供。为了便于从 Xamarin.Forms 迁移,IsClippedToBounds默认为false,因此 MAUI.Controls 中的布局默认不裁剪。
ILayout还提供CrossPlatformMeasure()与CrossPlatformArrange()方法,由平台后备控件(如 LayoutViewGroup)调用以完成跨平台布局工作。
从技术上讲,直接在ILayout实现中完成所有跨平台布局逻辑是可行的,某些 SDK 可能也会选择这么做。但按惯例,这项工作被委托给ILayoutManager的实现。
ILayoutManager(定义于 src/Core/src/Layouts/ILayoutManager.cs)只定义了两个方法:
public interface ILayoutManager { Size Measure(double widthConstraint, double heightConstraint); Size ArrangeChildren(Rect bounds); }按惯例,ILayout实现可以调用ILayoutManager.Measure()来完成CrossPlatformMeasure()的工作,调用ILayoutManager.ArrangeChildren()来完成CrossPlatformArrange()的工作。所有真正的跨平台布局逻辑都包含在ILayoutManager实现中——这使得实现可以被混合/匹配/复用,也让其他 SDK 实现者可以在自己的布局类型中复用这些逻辑。
在 MAUI.Controls 中,抽象类型Layout负责把跨平台测量与排布调用委托给 layout manager,并包含一个可覆写的预定义方法CreateLayoutManager(),派生布局用它来指定自己想要的 layout manager;同时它还包含查找ILayoutManagerFactory服务实现的逻辑。Layout处理了添加/移除子视图的大部分工作,并在集合变化时提供多个虚方法用于额外的簿记;它还包含将这些变化通知给 ILayoutHandler 实现的逻辑,以便 LayoutHandler 做添加/移除平台控件、按 z-index 排序等工作。
自定义布局的两条路径
内置布局都有预定义的 layout manager 处理其布局。如果用户想为布局定义自定义布局逻辑,有两条路径:
- 创建自定义布局类型(通常是现有布局类型或
Layout的子类),并覆写CreateLayoutManager()。 - 实现
ILayoutManagerFactory,把该实现注册到应用的 service provider 中,用它来指定每个布局类型应使用哪个 layout manager 实现。在第二种场景下,可以为已有布局类型定义新的 layout manager,例如为 Grid 提供带不同布局规则或其他定制的自定义 layout manager。这在以下场景尤其有用:用户想为某个布局“打补丁”恢复一个此前已废弃的行为,或者希望获得新行为但不想改动某个被广泛使用的现有布局类型。
文档中提到,创建自定义布局与 layout manager 相对容易,并维护了一个示例仓库帮助开发者入门。MAUI.Core 中所有内置布局的 layout manager 都集中在 src/Core/src/Layouts 目录下,包括GridLayoutManager.cs、AbsoluteLayoutManager.cs、StackLayoutManager.cs、VerticalStackLayoutManager.cs、HorizontalStackLayoutManager.cs、FlexLayoutManager.cs以及基类LayoutManager.cs,可作为自定义实现的参照模板。
内置布局详解
以下描述适用于 MAUI.Core 与 MAUI.Controls 提供的默认 layout manager 实现。
VerticalStackLayout / HorizontalStackLayout
VerticalStackLayout 与 HorizontalStackLayout 非常简单——把子视图一个接一个地堆叠。
- 在无约束方向上(VSL 是垂直方向,HSL 是水平方向),它们依次堆叠子视图直到全部堆完。该方向上它们不受约束:即使超过传给 Measure/Arrange 的宽度或高度约束参数,也会继续堆叠。换言之,VerticalStackLayout 的有效高度约束永远是无穷大;HorizontalStackLayout 的有效宽度约束永远是无穷大。
- 在另一个方向上,布局受约束。例如 Page 上的单个 VerticalStackLayout,其宽度会被约束为 Page 的宽度。
- 将 VSL 或 HSL 放入滚动方向匹配的 ScrollView,即可查看超出容器大小的内容。
- 两者都提供
Spacing属性,间距应用在布局内所有可见项之间。Visibility.Collapsed的视图不显示,也不计入间距。
StackLayout
StackLayout 与 VerticalStackLayout/HorizontalStackLayout 行为相同,但多了一个Orientation属性,用来决定其内部渲染成 VerticalStackLayout 还是 HorizontalStackLayout。在底层,Orientation只是切换所用的 LayoutManager。
StackLayout 还会检查子视图是否存在 “AndExpand” 布局选项;如果在相关方向存在(例如方向为Vertical且有垂直方向的 “AndExpand” 选项),就会换用 AndExpandLayoutManager。该 LayoutManager 在运行时把 StackLayout 转换成单行/单列的 Grid,并设置相应的行/列定义来扩展需要扩展的视图。
Grid
Grid 把给定区域细分为行和列来排布子视图。
RowDefinitions 与 ColumnDefinitions
Grid 的行列集合由RowDefinitions与ColumnDefinitions定义。行从上到下依次定义;没有显式 RowDefinitions 的 Grid 默认是单行,高度为GridLength.Star。列从左到右依次定义;没有显式 ColumnDefinitions 的 Grid 默认是单列,宽度为GridLength.Star。
间距
Grid 可以指定ColumnSpacing和RowSpacing。这两个double值指定 Grid 在行与列之间留出的空白。两个轴上的间距默认都是零。间距应用在行/列之间,不应用于 Grid 的外边缘。单列或单行的 Grid 在该轴上不会有任何间距。
空行/空列依然计入间距——即使该行/列没有任何子视图,间距仍然生效。例如下面这个 Grid:
<Grid RowSpacing="10" ColumnSpacing="10" RowDefinitions="Auto, Auto" ColumnDefinitions="Auto,Auto" />即使它是空的,也会测量为 10x10。
GridLength
每个行/列定义都指定一个GridLength,共有三种类型:
Explicit(显式):一个double值,指定行/列的大小。指定后,无论内容如何,该行/列在最终布局中都会得到这个大小。
Auto(自动):Grid 会测量该行/列中的所有子视图,并把高度/宽度设置为足以容纳最大视图。如果该行/列没有子视图,其大小设为零,但该行/列仍计入间距。
Star(星号):Star值表示剩余可用空间的加权比例。Star 值以"[weight]*"格式指定,其中[weight]是double,未指定时默认为 1.0。
举例:一个HeightRequest="100"且RowDefinitions="*,2*,6*,0.5*,0.5*"的 Grid。可用空间为 100 单位,权重总和为 10(1 + 2 + 6 + 0.5 + 0.5),所以每个*的大小为 100 / 10 = 10。因此各行的最终高度为 10、20、60、5、5 单位。
GridLength.Star只有在对应轴受约束时才有意义;当空间为无穷大时,“剩余空间”的概念不再成立。当带GridLength.Star的行/列以double.PositiveInfinity约束进行测量时,它会被当作GridLength.Auto处理。
AbsoluteLayout
AbsoluteLayout 允许子视图使用显式值和/或相对于布局大小的比例进行定位与缩放。
子视图在 AbsoluteLayout 中的布局由两个值的组合指定:
LayoutBounds
子视图的布局边界是一个Rect,指定视图的位置与大小。未指定时,默认布局边界为位置 (0, 0),Width与Height属性为AbsoluteLayout.AutoSize(常量值 -1)。
LayoutFlags
子视图的布局标志指定布局边界中的值如何使用。默认不指定标志(AbsoluteLayoutFlags.None)。XProportional与YProportional可以独立设置,也可用PositionProportional同时设置两者;同样,HeightProportional与WidthProportional可独立设置,也可用SizeProportional同时设置;所有标志可用AbsoluteLayoutFlags.All一次性设置。
布局边界/标志的交互
子视图的布局规则由布局边界与布局标志组合决定:
位置:若设置了XProportional/YProportional,则布局边界的 X/Y 值乘以 AbsoluteLayout 的宽/高得到最终坐标。例如一个 100 x 100 的 AbsoluteLayout,子视图布局边界为 (0.4, 0.6, 20, 20) 且设置了比例标志,则该子视图位置为 X = (100 * 0.4) = 40,Y = (100 * 0.6) = 60。若未设置比例标志,X/Y 即为显式值,例如布局边界 (45, 67, 20, 20) 的位置就是 X = 45、Y = 67。无论显式还是比例位置,都可以超出 AbsoluteLayout 的边界。
大小:若设置了WidthProportional/HeightProportional,则布局边界的宽/高乘以 AbsoluteLayout 的宽/高得到最终宽/高。例如 100 x 100 的 AbsoluteLayout,子视图布局边界为 (0, 0, 0.3, 0.47) 且设置了比例标志,则该子视图尺寸为 Width = 30、Height = 47。若未设置比例标志,宽/高即为显式值(如 45 x 20)。显式与比例尺寸同样都可以超出 AbsoluteLayout 边界。
无界尺寸(Unbounded Dimensions)
如果 AbsoluteLayout 在某个维度上是无界的(例如它是 VerticalStackLayout 的子视图且没有显式高度),那么该维度上的“比例”概念就失去了意义:该维度上的比例标志会被忽略;若布局边界在该维度指定了非 Auto 值,则使用该值;否则使用视图的自动尺寸。
例如,一个 AbsoluteLayout 是宽 200 的 VerticalStackLayout 的子视图,其唯一子视图的 LayoutBounds 为 (0, 0, 100, 0.5) 且设置了AbsoluteLayoutFlags.HeightProportional。由于 AbsoluteLayout 没有垂直约束,HeightProportional标志被忽略,子视图高度被设置为布局边界中的 0.5。
布局流程:Measure 全过程
MAUI.Core 的跨平台布局流程寄生在各平台的本地布局流程之上。总体而言,所有布局工作都由本地布局系统发起;当某个布局或内容后备控件因被本地布局系统测量/排布而触发时,跨平台布局流程才介入。
下面的时序图展示了本地布局系统想要测量某个后备视图时的过程:
假设被测量的跨平台视图是一个包含 Label 的 ContentView。本地平台(如 Android)需要在给定约束下获知 ContentView 的大小,例如宽度 100 单位、高度 200 单位。
平台带着约束调用 ContentView 后备视图(Android 上是ContentViewGroup)的Measure()。后备视图把约束转换为跨平台单位(如必要),然后以这些约束调用自身的CrossPlatformMeasure()方法,以确定内容(本例中的 Label)想要多大。
CrossPlatformMeasure()负责调用 Label 的Measure()。Label 测量其本地对应控件(详见下文),并基于该测量更新自己的DesiredSize属性。该值作为CrossPlatformMeasure()的结果返回给后备视图。后备视图做必要的内部簿记后,把测量尺寸返回给平台。
测量一个 Layout
Layout 的测量过程与上述基本相同,区别在于需要测量多个子视图:
遍历子视图并逐个测量的过程,通常由每种布局的 LayoutManager 在其Measure()方法内完成。
测量一个 View
每当跨平台 View 被测量时,它都会把实际测量工作交给其本地对应控件。
以上面的 Label 为例:Label 的Measure()方法接收CrossPlatformMeasure()给定的约束,做适当调整(例如减去它的 margins),然后把调整后的约束交给其 Handler 的GetDesiredSize()方法。Handler 知道本地控件(Android 上是 TextView),负责把约束转换为适合平台的取值并调用本地控件的Measure()。Handler 取得本地测量返回值,转换回跨平台值(如必要),返回给 Label。
Label 再按需调整结果(例如加回 margins 的大小),把结果记录在DesiredSize属性中,并作为Measure()的结果返回。
其他注意事项
- 每个平台的布局处理方式略有不同;跨平台布局代码的目标是尽可能平台无关。所有特例场景都应在各平台的 handler 平台代码中处理。跨平台布局代码中出现
#if是一种坏味道;如果真的有必要(大概率不应该有),必须附带大量解释说明。 - 一般规则:任何布局 pass 都应先调用
Measure()再调用Arrange()。在Arrange()之前多次调用Measure()是完全合法的——平台可能需要在排布视图前做一些试探性测量。 - 只要
Measure()至少被调用过一次,以不同大小/位置多次调用Arrange()也是合法的。例如桌面应用可能确定窗口缩放操作要求在某位置排布视图,但窗口大小的变化并不影响视图的测量——此时没有理由重新测量。 - 各平台一般会自行优化测量操作;平台代码在做这类决策时远优于跨平台布局代码。跨平台代码的目标应是“让路”,让平台自己做优化。例如跨平台代码对同一个 Android 视图用相同 measureSpec 连续调用两次
Measure(),原生 Android 代码会直接返回缓存值,除非它判定有充分理由需要重测。在跨平台层面尝试做这个决策(或缓存测量值)会破坏原生决策机制(原生拥有远为优越的局部信息)。
平台笔记:Android 的 MeasureSpec
当你看到 Android 本地测量方法时,它们通常接收widthMeasureSpec和heightMeasureSpec参数。你可能注意到它们往往是整数。非常重要的一点是:这些并不等价于MAUI 跨平台测量中使用的double widthConstraint与double heightConstraint。它们是 Android 的View.MeasureSpec构造——一个打包的整数值,同时包含约束的大小与约束的类型。
measureSpec 的 mode(即约束类型)被打包在整数的高位。可能的取值为UNSPECIFIED、EXACTLY、AT_MOST。
measureSpec 的 size 占据其余位。MeasureSpec类中有便捷方法用于提取 spec 的 size 与 mode。
因此,即使 measureSpec 是整数,也不能简单地对它做加减。想增减测量约束,需要解包 size、做出修改、再用MeasureSpec.MakeMeasureSpec()打包成新的 measureSpec。如果直接对 measureSpec 做加减,整数的本质与整数溢出会得到毫无意义的值。
还需要记住:measureSpec 的 size 值应当始终是原生像素。这意味着从跨平台尺寸构造 measureSpec 时,需要用Context.ToPixels()转换尺寸。
遗留兼容笔记
OnSizeAllocated
VisualElement.OnSizeAllocated(double width, double height)方法仍然保留可覆写,以支持从 Xamarin.Forms 移植到 MAUI.Controls 的控件。在 Forms 中,该方法通常用于响应尺寸变化,偶尔也用于确保控件已挂入控件层级、准备好显示在屏幕上。
该方法在 MAUI.Core 中完全不存在;在 MAUI.Controls 中它仅为向后兼容而存在,在布局流程的Arrange阶段Frame(为兼容也化名为Bounds)更新时被调用。具体而言,它在Frame被设置时调用;对典型 VisualElement,这发生在ArrangeOverride()方法内、平台本地 arrange 方法被调用之前。
为 MAUI.Controls 创建自定义组件时,推荐的定制点是覆写ArrangeOverride(Rect bounds)方法,而不是OnSizeAllocated()——前者提供更大的灵活性。
Xamarin.Forms → MAUI.Controls 布局差异
StackLayout
MAUI 的堆叠布局(StackLayout、VerticalStackLayout、HorizontalStackLayout)与 Xamarin.Forms 的 StackLayout 有几点差异。
第一,MAUI 堆叠布局非常简单:它们沿单一方向堆叠子视图直到全部堆完。即使超出堆叠方向的可用空间,它们也会继续堆下去。MAUI 堆叠布局只是沿特定方向排列控件,并不会细分空间。
这与 Xamarin.Forms 的 StackLayout 形成对比——后者会根据情形以及是否存在 “AndExpand” 布局选项(如FillAndExpand、CenterAndExpand)改变行为。有时 Forms 的 StackLayout 会细分空间,扩展至(或止步于)容器边缘;另一些时候它又超出容器扩展。所有这些特例都会影响布局性能,并让 StackLayout 的行为更难以推理。
第二点主要差异:MAUI 的 VerticalStackLayout 与 HorizontalStackLayout不识别“AndExpand” 布局选项。如果它们看到包含 “AndExpand” 的子视图,就当作没有 “AndExpand” 处理——例如FillAndExpand变成Fill。
为了简化从 Forms 到 MAUI 的迁移,MAUI.Controls 的 StackLayout确实仍会响应 “AndExpand”(至少暂时如此)。所有 “AndExpand” 选项都已被标记为Obsolete。如果想避免废弃属性带来的警告,应当把使用 “AndExpand” 的布局转换为合适的布局类型。建议流程如下:
- 如果你的布局不是 StackLayout,删除所有 “AndExpand” 用法。与 Xamarin.Forms 一样,“AndExpand” 选项对 StackLayout 之外的任何布局都没有效果。如果布局不是 StackLayout,那么 “AndExpand” 从来就没起过作用。
- 删除与堆叠方向正交的 “AndExpand” 属性。例如 StackLayout 的
Orientation为Vertical,其子视图带有HorizontalAlignment="CenterAndExpand"——这个 “AndExpand” 不做任何事,直接删掉即可。 - 如果 StackLayout 上还有剩余的 “AndExpand” 属性,应把该 StackLayout 转换为 Grid;Grid 天生用于细分空间,能提供 Xamarin.Forms 中 “AndExpand” 所提供的布局。例如:
<StackLayout> <Label Text="howdy"/> <Image VerticalOptions="FillAndExpand" src="dotnetbot.png"/> </StackLayout>可转换为:
<Grid RowDefinitions="Auto, *"> <Label Text="howdy"/> <Image Grid.Row="1" src="dotnetbot.png"/> </Grid>凡是标记了 “AndExpand” 的内容,都应放入大小为*的独立行或列中。
ScrollView 的变化
另一个主要差异:Xamarin.Forms 的 ScrollView 在堆叠时行为不一致。它对最小尺寸有一些任意限制(部分取决于其内容),并且在 StackLayout 内部会以不一致、有时令人惊讶的方式压缩自身,以便让其他项目适应屏幕。
而 MAUI 只是让 ScrollView 把视口扩展到其内容的大小,除非另有约束。这意味着在可以无限扩展的 VerticalStackLayout 内部,ScrollView 只会把视口高度设置为内容高度——它不会滚动。这对 Forms 用户可能有点意外。请记住,StackLayout 只是沿堆叠方向一直堆到内容耗尽,不会沿该轴细分容器。如果想在某方向把内容限制在受约束的空间里,应该使用其他控件,比如 Grid。
因此,不要这样写:
<StackLayout> <ScrollView>...</ScrollView> </StackLayout>更可能是这样:
<Grid> <ScrollView>...</ScrollView> </Grid>这也适用于把 ScrollView 放入标记为Auto的 Grid 行/列的情况。在 Forms 中,这种情形会一直把 ScrollView 当作Auto尺寸,直到 Grid 无法在屏幕上放下为止——届时会限制 ScrollView 为可用空间(实际上是把Auto变成*)。这种特殊处理令人困惑且计算代价更高,所以 MAUI 中 ScrollView 遵循与其他控件相同的规则——想多大就多大。
Grid
Xamarin.Forms 与 MAUI.Controls 的 Grid 行为最大的变化是:Grid 不再自动补加缺失的行/列。例如在 Forms 中你可以这样写:
<Grid> <Label Text="Hello"/> <Label Grid.Row="1" Text="World"/> </Grid>即使没有声明 Grid 有两行,Forms 也会猜测你的意图并自动补加第二行。MAUI.Controls 不会这么做——必须显式声明RowDefinitions="Auto,Auto"。这是出于性能考虑而做出的改变。
不过,MAUI.Controls仍然会为你假定第 0 行/列。也就是说,如果没声明任何RowDefinitions或ColumnDefinitions,默认即为RowDefinitions="*"与ColumnDefinitions="*"。
通用变化
MAUI.Controls 通常会满足显式尺寸请求。如果你要求控件宽 200 点,MAUI 就会照做让控件宽 200 点,即使容器只有 100 点宽。
延伸阅读
- 布局设计文档原文:docs/design/layout.md
- Core 层布局接口与实现:src/Core/src/Layouts(
ILayoutManager.cs、GridLayoutManager.cs、AbsoluteLayoutManager.cs、StackLayoutManager.cs等) - 布局核心接口
ILayout:src/Core/src/Core/ILayout.cs ComputeFrame/ComputeDesiredSize实现:src/Core/src/Layouts/LayoutExtensions.csLayoutAlignment枚举:src/Core/src/Primitives/LayoutAlignment.csDimension常量:src/Core/src/Primitives/Dimension.cs- Android 平台后备控件
LayoutViewGroup:src/Core/src/Platform/Android/LayoutViewGroup.cs - 若涉及其他 MAUI 设计主题,可继续参阅 docs/design 目录下的其余设计文档(如 HandlerResolution.md、Scoping.md 等)。
【免费下载链接】maui.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.项目地址: https://gitcode.com/GitHub_Trending/ma/maui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考