本文翻译自 Jonathan 和 Simon 的 .NET MAUI Performance Features in .NET 9
.NET 多平台应用 UI (.NET MAUI) 随着各个版本的发布而不断发展,与此同时,.NET 9 重点引入了裁剪功能和一个新的受支持运行时:NativeAOT。这些功能可以帮助您减少应用程序大小、提高启动速度,并确保应用程序在各个平台上流畅运行。无论是希望优化 .NET MAUI 应用的开发者,还是 NuGet 包的作者,都可以在 .NET 9 中使用这些功能。
我们还将向开发人员介绍可用于测量 .NET MAUI 应用程序性能的选项。CPU 采样和内存快照可分别通过 dotnet-trace
和 dotnet-gcdump
获得。这些工具可以帮助您深入了解应用程序、NuGet 包的性能问题,甚至是我们需要关注的 .NET MAUI 问题。
背景
默认情况下, iOS 和 Android 上的 .NET MAUI 应用程序使用以下设置:
- “自包含”: 意味着应用程序中包含 BCL 和运行时的副本。
注意
这使得 .NET MAUI 应用程序适合在“应用商店”上运行,因为不需要安装 .NET 运行时等先决条件。- 部分裁剪(
TrimMode=partial
),意味着默认情况下不修剪应用程序或 NuGet 包中的代码。
注意
完全裁剪
这就是完全裁剪(TrimMode=full
)能影响应用程序大小的地方。如果您的应用包含大量 C# 代码或 NuGet 包,您可能正在错失大幅减少应用程序体积的机会。
要选择完全裁剪,您可以将以下内容添加到 .csproj
文件中:
<PropertyGroup>
<TrimMode>full</TrimMode>
</PropertyGroup>
以下是完全裁剪的影响示例:
注意
MyPal 是一个 .NET MAUI 示例应用程序,由于它使用了多个常见的 NuGet 包,因此非常适合作为对比参考。有关完全裁剪的更多信息,请参阅我们的裁剪 .NET MAUI 文档。
NativeAOT
NativeAOT 建立在完全裁剪的基础上,既依赖于与裁剪兼容的库,也依赖于与 AOT 兼容的库。NativeAOT 是一种新的运行时,与现有运行时相比,它可以缩短启动时间并减小应用程序大小。
注意
NativeAOT 尚未支持 Android,但在 iOS、MacCatalyst 和 Windows中可用。要启用 NativeAOT:
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
</PropertyGroup>
以下是 NativeAOT 对应用程序体积影响的示例:
以及启动性能:
注意
上述图表中的 macOS 运行在MacCatalyst
上,这是 .NET MAUI 应用在 Mac 操作系统上的默认运行方式。 有关此新支持的运行时更多信息,请参阅我们的 NativeAOT 部署文档。
NuGet包作者
作为 NuGet 包的作者,您可能希望您的软件包能够在完全裁剪或 NativeAOT 场景下运行。这对于面向 .NET MAUI、移动应用,甚至是自包含的 ASP.NET 微服务开发者来说非常有用。
要支持 NativeAOT,您需要:
- 将程序集标记为 “trim-compatible” 和 “AOT-compatible”。
- 启用 Roslyn 分析器以检查裁剪和 NativeAOT 兼容性。
- 解决所有警告。
首先,修改您的 .csproj
文件:
<PropertyGroup>
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
这些属性将启用 Roslyn 分析器,并在生成的 .NET 程序集中包含 [assembly: AssemblyMetadata]
信息。根据您的库对 System.Reflection 等特性的使用情况,您可能会遇到少量,也有可能是大量的警告。
有关详细信息,请参阅有关准备裁剪库的文档。
XAML和裁剪
有时,在您的应用中使用 NativeAOT 可以像在项目文件中添加一个属性一样简单。但是,对于许多 .NET MAUI 应用程序,可能需要解决的警告会有很多。 NativeAOT 编译器通过移除不必要的代码和元数据,使应用更小、更快。不过,这需要了解哪些类型可以被创建,哪些方法在运行时可以被调用,哪些不可以。对于大量使用 System.Reflection 的代码来说,这通常是难以实现的。在 .NET MAUI 中,有两个领域属于这一类:XAML 和数据绑定。
编译XAML
在运行时加载 XAML 提供了灵活性并支持 XAML 热重载等功能。XAML 可以实例化整个应用程序、.NET MAUI SDK 和引用的 NuGet 包中的任何类。XAML 还可以为任何属性设置值。
从概念上讲,在运行时加载 XAML 布局需要:
- 解析 XML 文档。
- 使用
Type.GetType(xmlElementName)
根据 XML 元素名称查找控件类型。 - 使用
Activator.CreateInstance(controlType)
创建控件的新实例。 - 将原始的字符串 XML 属性值转换为目标属性的类型。
- 根据 XML 属性的名称设置属性。
这个过程不仅缓慢,而且对 NativeAOT 来说也是一个巨大的挑战。例如,修剪器不知道使用 Type.GetType
方法会查找哪些类型。这意味着编译器要么必须在最终应用中保留整个 .NET MAUI SDK 和所有 NuGet 包的所有类,要么该方法可能无法找到 XML 输入中声明的类型,从而导致运行时失败。
幸运的是,.NET MAUI 提供了一个解决方案——XAML 编译。这会在构建时将 XAML 转换为 InitializeComponent() 方法的实际代码。生成代码后,NativeAOT 编译器将拥有裁剪应用程序所需的所有信息。
在 .NET 9 中,我们实现了编译器在早期版本中无法处理的最后剩余的 XAML 功能,尤其是编译绑定。最后,如果您的应用程序依赖于在运行时加载 XAML,则 NativeAOT 可能不适合您的应用程序。
编译绑定
编译绑定将源属性与目标属性关联在一起。当源属性发生变化时,值会自动传播到目标属性。
.NET MAUI 中的绑定使用字符串 “路径(path”定义。此路径类似于用于访问属性和索引器的 C# 表达式。当将绑定应用于源对象时,.NET MAUI 使用 System.Reflection 遵循路径来访问所需的源属性。这与在运行时加载 XAML 存在相同的问题,因为裁剪器不知道哪些属性可以通过反射访问,因此也就不知道可以安全地从最终应用程序中裁剪哪些属性。
当我们通过 x:DataType
属性在构建中明确源对象的类型时,我们就可以将绑定路径编译为一个简单的 getter 方法(以及双向绑定的 setter 方法)。编译器还会确保绑定能够监听所有实现 INotifyPropertyChanged
接口的属性变化。
在 .NET 8 及更早版本中,XAML 编译器已经可以编译大多数绑定。在 .NET 9 中,我们确保可以编译 XAML 代码中的任何绑定。有关此功能的更多信息,请参阅已编译的绑定文档。
C#中的编译绑定
在 .NET 8 及之前的版本中,在 C# 代码中定义数据绑定的唯一支持方式是使用基于字符串的路径。 在 .NET 9 中,我们引入了一种新的 API,允许使用源生成器来编译数据绑定:
// .NET 8 and earlier
myLabel.SetBinding(Label.TextProperty, "Text");
// .NET 9
myLabel.SetBinding(Label.TextProperty, static (Entry nameEntry) => nameEntry.Text);
Binding.Create()
方法也是一个可选方案,适用于需要保存绑定实例以供后续使用的情况:
var nameBinding = Binding.Create(static (Entry nameEntry) => nameEntry.Text);
.NET MAUI 的源生成器将以与 XAML 编译器相同的方式编译绑定。这样,NativeAOT 编译器就可以对绑定进行全面分析。
即使您不打算将应用程序迁移到 NativeAOT,编译后的绑定也能提高绑定的整体性能。为了展示其中的差异,让我们使用 BenchmarkDotNet
来测量在 Android 上使用 Mono 运行时调用 SetBinding()
的性能差异。
// dotnet build -c Release -t:Run -f net9.0-android
public class SetBindingBenchmark
{
private readonly ContactInformation _contact = new ContactInformation(new FullName("John"));
private readonly Label _label = new();
[GlobalSetup]
public void Setup()
{
DispatcherProvider.SetCurrent(new MockDispatcherProvider());
_label.BindingContext = _contact;
}
[Benchmark(Baseline = true)]
public void Classic_SetBinding()
{
_label.SetBinding(Label.TextProperty, "FullName.FirstName");
}
[Benchmark]
public void Compiled_SetBinding()
{
_label.SetBinding(Label.TextProperty, static (ContactInformation contact) => contact.FullName?.FirstName);
}
[IterationCleanup]
public void Cleanup()
{
_label.RemoveBinding(Label.TextProperty);
}
}
当我在三星 Galaxy S23 上运行基准测试时,得到了以下结果:
方法 | 平均时间 | 偏差 | 标准偏差 | 比率 | 比率标准差 |
Classic_SetBinding | 67.81 us | 1.338 us | 1.787 us | 1.00 | 0.04 |
Compiled_SetBinding | 30.61 µs | 0.629 us | 1.182 us | 0.45 | 0.02 |
经典绑定首先需要解析基于字符串的路径,然后使用 System.Reflection 获取源的当前值。而使用编译绑定后,每次对源属性的更新也会更快。
// dotnet build -c Release -t:Run -f net9.0-android
public class UpdateValueTwoLevels
{
ContactInformation _contact = new ContactInformation(new FullName("John"));
Label _label = new();
[GlobalSetup]
public void Setup()
{
DispatcherProvider.SetCurrent(new MockDispatcherProvider());
_label.BindingContext = _contact;
}
[IterationSetup(Target = nameof(Classic_UpdateWhenSourceChanges))]
public void SetupClassicBinding()
{
_label.SetBinding(Label.TextProperty, "FullName.FirstName");
}
[IterationSetup(Target = nameof(Compiled_UpdateWhenSourceChanges))]
public void SetupCompiledBinding()
{
_label.SetBinding(Label.TextProperty, static (ContactInformation contact) => contact.FullName?.FirstName);
}
[Benchmark(Baseline = true)]
public void Classic_UpdateWhenSourceChanges()
{
_contact.FullName.FirstName = "Jane";
}
[Benchmark]
public void Compiled_UpdateWhenSourceChanges()
{
_contact.FullName.FirstName = "Jane";
}
[IterationCleanup]
public void Reset()
{
_label.Text = "John";
_contact.FullName.FirstName = "John";
_label.RemoveBinding(Label.TextProperty);
}
}
方法 | 平均时间 | 偏差 | 标准偏差 | 比率 | 比率标准差 |
Classic_UpdateWhenSourceChanges | 46.06 us | 0.934 us | 1.369 us | 1.00 | 0.04 |
Compiled_UpdateWhenSourceChanges | 30.85 µs | 0.634 us | 1.295 us | 0.67 | 0.03 |
单个绑定的性能差异并不大,但累积起来就很明显了。在包含大量绑定的复杂页面上,或者在滚动 CollectionView
或 ListView
等列表时,这种差异可能会更加明显。
上述基准测试的完整源代码可在 GitHub 上找到。
.NET MAUI 应用程序的性能分析
将 dotnet-trace
附加到 .NET MAUI 应用程序,可以获取 .nettrace
和 .speedscope
等格式的性能分析信息。这些信息可为您提供有关应用程序中每个方法所用时间的 CPU 采样信息。这对于查找应用程序启动时或整体性能中的耗时点非常有用。同样,dotnet-gcdump
可以获取应用程序的内存快照,显示内存中所有托管的 C# 对象。此外,dotnet-dsrouter
是将 dotnet-trace
连接到远程设备所必需的,但对于桌面应用程序来说并不需要。
您可以使用以下方式安装这些工具:
$ dotnet tool install -g dotnet-trace
You can invoke the tool using the following command: dotnet-trace
Tool 'dotnet-trace' was successfully installed.
$ dotnet tool install -g dotnet-dsrouter
You can invoke the tool using the following command: dotnet-dsrouter
Tool 'dotnet-dsrouter' was successfully installed.
$ dotnet tool install -g dotnet-gcdump
You can invoke the tool using the following command: dotnet-gcdump
Tool 'dotnet-gcdump' was successfully installed.
从这里开始,不同平台的操作略有不同,但总体步骤如下:
-
- 以发布 (Release) 模式构建您的应用程序。对于安卓端,需要在
.csproj
文件中启用<AndroidEnableProfiler>true</AndroidEnableProfiler>
,以便将必要的 Mono 诊断组件包含在应用程序中。 - 如果要对移动端进行性能分析,在开发机器上运行
dotnet-dsrouter android
(或dotnet-dsrouter ios
等)。 - 配置环境变量,使应用程序能够连接到性能分析器。例如,在 Android 上:
$ adb reverse tcp:9000 tcp:9001 # no output $ adb shell setprop debug.mono.profile '127.0.0.1:9000,nosuspend,connect' # no output
- 以发布 (Release) 模式构建您的应用程序。对于安卓端,需要在
- 运行程序。
- 使用
dotnet-dsrouter
的 PID,将dotnet-trace
(或dotnet-gcdump
)附加到应用程序:$ dotnet-trace ps 38604 dotnet-dsrouter ~/.dotnet/tools/dotnet-dsrouter.exe ~/.dotnet/tools/dotnet-dsrouter.exe android $ dotnet-trace collect -p 38604 --format speedscope No profile or providers specified, defaulting to trace profile 'cpu-sampling' Provider Name Keywords Level Enabled By Microsoft-DotNETCore-SampleProfiler 0x0000F00000000000 Informational(4) --profile Microsoft-Windows-DotNETRuntime 0x00000014C14FCCBD Informational(4) --profile Waiting for connection on /tmp/maui-app Start an application with the following environment variable: DOTNET_DiagnosticPorts=/tmp/maui-app
对于 iOS、macOS 和 MacCatalyst,请参阅 iOS 性能分析 Wiki 页面获取更多信息。
注意
现在,您已经收集了包含性能信息的文件,接着是打开它们来查看数据:
dotnet-trace
默认生成.nettrace
文件,可以在 PerfView 或 Visual Studio 中打开。
- 使用
dotnet-trace collect --format speedscope
生成的.speedscope
文件,可以在 Speedscope Web 应用中打开。
dotnet-gcdump
生成.gcdump
文件,可以在 PerfView 或 Visual Studio 中打开。但目前 macOS 上尚无较好的工具来查看这些文件。
未来,我们希望在后续的 .NET 诊断工具 和 Visual Studio 版本中,使 .NET MAUI 应用程序的性能分析更加便捷。
注意dotnet-trace
和性能分析。您可以使用其他受支持的运行时进行分析,或者使用例如 Xcode’s Instruments 等原生性能分析工具。
总结
.NET 9 通过完全裁剪和 NativeAOT 为 .NET MAUI 应用程序带来了性能提升。这些功能可以减少应用程序体积并提升启动速度,从而让开发者构建更高效、更流畅的应用程序。通过使用 dotnet-trace 和 dotnet-gcdump 等工具,开发者可以深入分析应用程序的性能。
要全面了解 .NET MAUI 的裁剪和 NativeAOT,请参阅有关该主题的 .NET Conf 2024 会议。
MAUI 以后会支持 鸿蒙吗
Eddie, use English Eddie. It not my native language either, but that is a common language of choice for IT in the world.