news 2026/9/13 18:04:59

.NET MAUI 跨平台布局系统深度解析:Layout、Measure/Arrange 与平台适配原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET MAUI 跨平台布局系统深度解析:Layout、Measure/Arrange 与平台适配原理

.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 的跨平台布局架构:从LayoutAlignmentDimensionZ-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 在容器中的定位方式,共有四个取值:FillStartCenterEnd。在 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 沿该轴有显式尺寸(即WidthHeight被设置为Dimension.Unset之外的值),ComputeFrame()就会遇到冲突:用户既要求 Fill 又要求显式尺寸。此时显式尺寸优先,Frame 采用显式尺寸;若该尺寸小于可用空间,则 Frame 居中放置。

对应到源码,这一“显式尺寸优先、退化为居中”的逻辑正是 LayoutExtensions.cs 中AlignHorizontal/AlignVertical所做的:当对齐方式为FillIsExplicitSet(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.WidthIView.Height需要返回一个实际数字(介于Dimension.MinimumDimension.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.VisibleIsVisible == 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 的后备控件

所有布局在每个平台都由一个统一的后备控件承载:

平台控件
iOSLayoutView
AndroidLayoutViewGroup
WinUILayoutPanel

(如果你熟悉 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 的后备控件

所有内容视图在每个平台也由统一控件承载:

平台控件
iOSContentView
AndroidContentViewGroup
WinUIContentPanel

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 处理其布局。如果用户想为布局定义自定义布局逻辑,有两条路径:

  1. 创建自定义布局类型(通常是现有布局类型或Layout的子类),并覆写CreateLayoutManager()
  2. 实现ILayoutManagerFactory,把该实现注册到应用的 service provider 中,用它来指定每个布局类型应使用哪个 layout manager 实现。在第二种场景下,可以为已有布局类型定义新的 layout manager,例如为 Grid 提供带不同布局规则或其他定制的自定义 layout manager。这在以下场景尤其有用:用户想为某个布局“打补丁”恢复一个此前已废弃的行为,或者希望获得新行为但不想改动某个被广泛使用的现有布局类型。

文档中提到,创建自定义布局与 layout manager 相对容易,并维护了一个示例仓库帮助开发者入门。MAUI.Core 中所有内置布局的 layout manager 都集中在 src/Core/src/Layouts 目录下,包括GridLayoutManager.csAbsoluteLayoutManager.csStackLayoutManager.csVerticalStackLayoutManager.csHorizontalStackLayoutManager.csFlexLayoutManager.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 的行列集合由RowDefinitionsColumnDefinitions定义。行从上到下依次定义;没有显式 RowDefinitions 的 Grid 默认是单行,高度为GridLength.Star。列从左到右依次定义;没有显式 ColumnDefinitions 的 Grid 默认是单列,宽度为GridLength.Star

间距

Grid 可以指定ColumnSpacingRowSpacing。这两个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),WidthHeight属性为AbsoluteLayout.AutoSize(常量值 -1)。

LayoutFlags

子视图的布局标志指定布局边界中的值如何使用。默认不指定标志(AbsoluteLayoutFlags.None)。XProportionalYProportional可以独立设置,也可用PositionProportional同时设置两者;同样,HeightProportionalWidthProportional可独立设置,也可用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 本地测量方法时,它们通常接收widthMeasureSpecheightMeasureSpec参数。你可能注意到它们往往是整数。非常重要的一点是:这些并不等价于MAUI 跨平台测量中使用的double widthConstraintdouble heightConstraint。它们是 Android 的View.MeasureSpec构造——一个打包的整数值,同时包含约束的大小与约束的类型

measureSpec 的 mode(即约束类型)被打包在整数的高位。可能的取值为UNSPECIFIEDEXACTLYAT_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” 布局选项(如FillAndExpandCenterAndExpand)改变行为。有时 Forms 的 StackLayout 会细分空间,扩展至(或止步于)容器边缘;另一些时候它又超出容器扩展。所有这些特例都会影响布局性能,并让 StackLayout 的行为更难以推理。

第二点主要差异:MAUI 的 VerticalStackLayout 与 HorizontalStackLayout不识别“AndExpand” 布局选项。如果它们看到包含 “AndExpand” 的子视图,就当作没有 “AndExpand” 处理——例如FillAndExpand变成Fill

为了简化从 Forms 到 MAUI 的迁移,MAUI.Controls 的 StackLayout确实仍会响应 “AndExpand”(至少暂时如此)。所有 “AndExpand” 选项都已被标记为Obsolete。如果想避免废弃属性带来的警告,应当把使用 “AndExpand” 的布局转换为合适的布局类型。建议流程如下:

  1. 如果你的布局不是 StackLayout,删除所有 “AndExpand” 用法。与 Xamarin.Forms 一样,“AndExpand” 选项对 StackLayout 之外的任何布局都没有效果。如果布局不是 StackLayout,那么 “AndExpand” 从来就没起过作用。
  2. 删除与堆叠方向正交的 “AndExpand” 属性。例如 StackLayout 的OrientationVertical,其子视图带有HorizontalAlignment="CenterAndExpand"——这个 “AndExpand” 不做任何事,直接删掉即可。
  3. 如果 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 行/列。也就是说,如果没声明任何RowDefinitionsColumnDefinitions,默认即为RowDefinitions="*"ColumnDefinitions="*"

通用变化

MAUI.Controls 通常会满足显式尺寸请求。如果你要求控件宽 200 点,MAUI 就会照做让控件宽 200 点,即使容器只有 100 点宽。

延伸阅读

  • 布局设计文档原文:docs/design/layout.md
  • Core 层布局接口与实现:src/Core/src/Layouts(ILayoutManager.csGridLayoutManager.csAbsoluteLayoutManager.csStackLayoutManager.cs等)
  • 布局核心接口ILayout:src/Core/src/Core/ILayout.cs
  • ComputeFrame/ComputeDesiredSize实现:src/Core/src/Layouts/LayoutExtensions.cs
  • LayoutAlignment枚举:src/Core/src/Primitives/LayoutAlignment.cs
  • Dimension常量: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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 18:00:35

RoboMaster电控硬件实战讲义:从炸机到可靠设计

1. 项目概述&#xff1a;这本讲义不是“教材”&#xff0c;而是RoboMaster电控工程师的实战备忘录“Robomaster硬件基础讲义V0.2.1”——看到这个标题&#xff0c;我第一反应不是去翻目录&#xff0c;而是下意识摸了摸自己工装裤口袋里那枚被磨得发亮的STM32F407最小系统板。它…

作者头像 李华
网站建设 2026/9/13 17:59:48

DenseNet鸟类细粒度分类:121/161/169/201四版本选型与PyTorch实战

简介&#xff1a;本资源是一个基于DenseNet系列&#xff08;121/161/169/201&#xff09;的鸟类图像多类别分类实战项目&#xff0c;面向深度学习初学者与计算机视觉实践者&#xff0c;聚焦图像识别任务中的模型选型、迁移学习与评估体系构建。项目完整实现训练、验证与多维度性…

作者头像 李华