March 6th, 2025

.NET MAUI在 .NET 9 中的性能功能

Eddie Chen
Partner Technical Advisor

本文翻译自 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-tracedotnet-gcdump 获得。这些工具可以帮助您深入了解应用程序、NuGet 包的性能问题,甚至是我们需要关注的 .NET MAUI 问题。 

背景

默认情况下, iOS 和 Android 上的 .NET MAUI 应用程序使用以下设置: 

  • “自包含”: 意味着应用程序中包含 BCL 和运行时的副本。 

注意

这使得 .NET MAUI 应用程序适合在“应用商店”上运行,因为不需要安装 .NET 运行时等先决条件。 

  • 部分裁剪(TrimMode=partial),意味着默认情况下不修剪应用程序或 NuGet 包中的代码。 

注意

这是很好的默认设置,因为它与生态系统中现有的代码和 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 布局需要: 

  1. 解析 XML 文档。 
  2. 使用 Type.GetType(xmlElementName) 根据 XML 元素名称查找控件类型。 
  3. 使用 Activator.CreateInstance(controlType) 创建控件的新实例。 
  4. 将原始的字符串 XML 属性值转换为目标属性的类型。 
  5. 根据 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 

单个绑定的性能差异并不大,但累积起来就很明显了。在包含大量绑定的复杂页面上,或者在滚动 CollectionViewListView 等列表时,这种差异可能会更加明显。 

上述基准测试的完整源代码可在 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.

从这里开始,不同平台的操作略有不同,但总体步骤如下: 

    1. 以发布 (Release) 模式构建您的应用程序。对于安卓端,需要在 .csproj 文件中启用 <AndroidEnableProfiler>true</AndroidEnableProfiler>,以便将必要的 Mono 诊断组件包含在应用程序中。
    2. 如果要对移动端进行性能分析,在开发机器上运行 dotnet-dsrouter android(或 dotnet-dsrouter ios 等)。
    3. 配置环境变量,使应用程序能够连接到性能分析器。例如,在 Android 上:
      $ adb reverse tcp:9000 tcp:9001
      # no output
      $ adb shell setprop debug.mono.profile '127.0.0.1:9000,nosuspend,connect'
      # no output
  1. 运行程序。 
  2. 使用 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 页面获取更多信息。 

注意

对于 Windows 应用程序,您可以考虑使用 Visual Studio 内置的性能分析工具,但使用 dotnet-trace collect — C:\path\to\an\executable.exe 也可以作为另一种选择。 

现在,您已经收集了包含性能信息的文件,接着是打开它们来查看数据: 

  • 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 应用程序的性能分析更加便捷。 

注意

NativeAOT 运行时不支持 dotnet-trace 和性能分析。您可以使用其他受支持的运行时进行分析,或者使用例如 Xcode’s Instruments 等原生性能分析工具。 

总结

.NET 9 通过完全裁剪和 NativeAOT 为 .NET MAUI 应用程序带来了性能提升。这些功能可以减少应用程序体积并提升启动速度,从而让开发者构建更高效、更流畅的应用程序。通过使用 dotnet-trace 和 dotnet-gcdump 等工具,开发者可以深入分析应用程序的性能。 

要全面了解 .NET MAUI 的裁剪和 NativeAOT,请参阅有关该主题的 .NET Conf 2024 会议

Author

Eddie Chen
Partner Technical Advisor

2 comments

  • Emil Kalchev 5 days ago

    Eddie, use English Eddie. It not my native language either, but that is a common language of choice for IT in the world.