.NET Multi-platform App UI (.NET MAUI) continues to evolve with each release, and .NET 9 brings a focus on trimming and a new supported runtime: NativeAOT. These features can help you reduce application size, improve startup times, and ensure your applications run smoothly on various platforms. Both developers looking to optimize their .NET MAUI applications and NuGet package authors are able to take advantage of these features in .NET 9.
We’ll also walk through the options available to you as a developer
for measuring the performance of your .NET MAUI applications. Both CPU
sampling and memory snapshots are available via dotnet-trace
and
dotnet-gcdump
respectively. These can give insights into performance
problems in your application, NuGet packages, or even something we
should look into for .NET MAUI.
Background
By default, .NET MAUI applications on iOS and Android use the following settings:
- “Self-contained”, meaning a copy of the BCL and runtime are included with the application.
Note
This makes .NET MAUI applications suitable for running on “app stores” as no prerequisites such as installing a .NET runtime are required.- Partially trimmed (
TrimMode=partial
), meaning that code within your applications or NuGet packages are not trimmed by default.
Note
This is a good default, as it is the most compatible with existing code and NuGet packages in the ecosystem.Full Trimming
This is where full-trimming (TrimMode=full
) can make an impact on
your application’s size. If you have a substantial amount of C# code
or NuGet packages, you may be missing out on a significant application
size reduction.
To opt into full trimming, you can add the following to your .csproj
file:
<PropertyGroup>
<TrimMode>full</TrimMode>
</PropertyGroup>
For an idea on the impact of full trimming:
Note
MyPal is a sample .NET MAUI application that is a useful comparison because of its usage of several common NuGet packages.See our trimming .NET MAUI documentation for more information on “full” trimming.
NativeAOT
Building upon full trimming, NativeAOT both relies on libraries being trim-compatible and AOT-compatible. NativeAOT is a new runtime that can improve startup time and reduce application size compared to existing runtimes.
Note
NativeAOT is not yet supported on Android, but is available on iOS, MacCatalyst, and Windows.To opt into NativeAOT:
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
</PropertyGroup>
For an idea on the impact of NativeAOT and application size:
And startup performance:
Note
macOS
on the above graphs is running on MacCatalyst
, the default for .NET MAUI applications running on Mac operating systems.See our NativeAOT deployment documentation for more information about this newly supported runtime.
NuGet Package Authors
As a NuGet package author, you may wish for your package to run in either fully trimmed or NativeAOT scenarios. This can be useful for developers targeting .NET MAUI, mobile, or even self-contained ASP.NET microservices.
To support NativeAOT, you will need to:
- Mark your assemblies as “trim-compatible” and “AOT-compatible”.
- Enable Roslyn analyzers for trimming and NativeAOT.
- Solve all the warnings.
Begin with modifying your .csproj
file:
<PropertyGroup>
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
These properties will enable Roslyn analyzers as well as include
[assembly: AssemblyMetadata]
information in the resulting .NET
assembly. Depending on your library’s usage of features like
System.Reflection, you could have either just a few warnings or
potentially many warnings.
See the documentation on preparing libraries for trimming for more information.
XAML and Trimming
Sometimes, taking advantage of NativeAOT in your app can be as easy as adding a property to your project file. However, for many .NET MAUI applications, there can be a lot of warnings to solve. The NativeAOT compiler removes unnecessary code and metadata to make the app smaller and faster. However, this requires understanding which types can be created and which methods can and cannot be called at runtime. This is often impossible to do in code which heavily uses System.Reflection. There are two areas in .NET MAUI which fall into this category: XAML and data-binding.
Compiled XAML
Loading XAML at runtime provides flexibility and enables features like XAML hot reload. XAML can instantiate any class in the whole app, the .NET MAUI SDK, and referenced NuGet packages. XAML can also set values to any property.
Conceptually, loading a XAML layout at runtime requires:
- Parsing the XML document.
- Looking up the control types based on the XML element names using
Type.GetType(xmlElementName)
. - Creating new instances of the controls using
Activator.CreateInstance(controlType)
. - Converting the raw string XML attribute values into the target type of the property.
- Setting properties based on the names of the XML attributes.
This process can not only be slow, but it presents a great challenge
for NativeAOT. For example, the trimmer does not know which types
would be looked up using the Type.GetType
method. This means that
either the compiler would need to keep all the classes from the whole
.NET MAUI SDK and all the NuGet packages in the final app, or the
method might not be able to find the types declared in the XML input
and fail at runtime.
Fortunately, .NET MAUI has a solution – XAML compilation.
This turns XAML into the actual code for the InitializeComponent()
method at build time. Once the code is generated, the NativeAOT
compiler has all the information it needs to trim your app.
In .NET 9, we implemented the last remaining XAML features that the compiler could not handle in previous releases, especially compiling bindings. Lastly, if your app relies on loading XAML at runtime, NativeAOT might not be suitable for your application.
Compiled Bindings
A binding ties together a source property with a target property. When the source changes, the value is propagated to the target.
Bindings in .NET MAUI are defined using a string
“path”. This path
resembles C# expressions for accessing properties and indexers. When
the binding is applied to a source object, .NET MAUI uses
System.Reflection to follow the path to access the desired source
property. This suffers from the same problems as loading XAML at
runtime, because the trimmer does not know which properties could be
accessed by reflection and so it does not know which properties it can
safely trim from the final application.
When we know the type of the source object at build time from
x:DataType
attributes, we can compile the binding path into a simple
getter method (and a setter method for two-way bindings). The compiler
will also ensure that the binding listens to any property changes
along the binding path of properties that implement
INotifyPropertyChanged
.
The XAML compiler could already compile most bindings in .NET 8 and earlier. In .NET 9 we made sure any binding in your XAML code can be compiled. Learn more about this feature in the compiled bindings documentation.
Compiled bindings in C#
The only supported way of defining bindings in C# code up until .NET 8
has been using a string
-based path. In .NET 9, we are adding a new
API which allows us to compile the binding using a source generator:
// .NET 8 and earlier
myLabel.SetBinding(Label.TextProperty, "Text");
// .NET 9
myLabel.SetBinding(Label.TextProperty, static (Entry nameEntry) => nameEntry.Text);
The Binding.Create()
method is also an option, for when you need to
save the Binding
instance for later use:
var nameBinding = Binding.Create(static (Entry nameEntry) => nameEntry.Text);
.NET MAUI’s source generator will compile the binding the same way the XAML compiler does. This way the binding can be fully analyzed by the NativeAOT compiler.
Even if you aren’t planning to migrate your application to NativeAOT,
compiled bindings can improve the general performance of the binding.
To illustrate the difference, let’s use BenchmarkDotNet
to measure
the difference between the calls to SetBinding()
on Android using the
Mono runtime:
// 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);
}
}
When I ran the benchmark on Samsung Galaxy S23, I got the following results:
Method | Mean | Error | StdDev | Ratio | RatioSD |
---|---|---|---|---|---|
Classic_SetBinding | 67.81 us | 1.338 us | 1.787 us | 1.00 | 0.04 |
Compiled_SetBinding | 30.61 us | 0.629 us | 1.182 us | 0.45 | 0.02 |
The classic binding needs to first parse the string
-based path and
then use System.Reflection to get the current value of the source.
Each subsequent update of the source property will also be faster with
the compiled binding:
// 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);
}
}
Method | Mean | Error | StdDev | Ratio | RatioSD |
---|---|---|---|---|---|
Classic_UpdateWhenSourceChanges | 46.06 us | 0.934 us | 1.369 us | 1.00 | 0.04 |
Compiled_UpdateWhenSourceChanges | 30.85 us | 0.634 us | 1.295 us | 0.67 | 0.03 |
The differences for a single binding aren’t that dramatic but they add
up. This can be noticeable on complex pages with many bindings or when
scrolling lists like CollectionView
or ListView
.
The full source code of the above benchmarks is available on GitHub.
Profiling .NET MAUI Applications
Attaching dotnet-trace
to a .NET MAUI application, allows you to get
profiling information in formats like .nettrace
and .speedscope
.
These give you CPU sampling information about the time spent in each
method in your application. This is quite useful for finding where
time is spent in the startup or general performance of your .NET
applications. Likewise, dotnet-gcdump
can take memory snapshots of
your application that display every managed C# object in memory.
dotnet-dsrouter
is a requirement for connecting dotnet-trace
to a
remote device, and so this is not needed for desktop applications.
You can install these tools with:
$ 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.
From here, instructions differ slightly for each platform, but generally the steps are:
- Build your application in
Release
mode. For Android, toggle<AndroidEnableProfiler>true</AndroidEnableProfiler>
in your.csproj
file, so the required Mono diagnostic components are included in the application. - If profiling mobile, run
dotnet-dsrouter android
(ordotnet-dsrouter ios
, etc.) on your development machine. - Configure environment variables, so the application can connect to
the profiler. For example, on Android:
$ adb reverse tcp:9000 tcp:9001 # no output $ adb shell setprop debug.mono.profile '127.0.0.1:9000,nosuspend,connect' # no output
- Run your application.
- Attach
dotnet-trace
(ordotnet-gcdump
) to the application, using the PID ofdotnet-dsrouter
:$ 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
For iOS, macOS, and MacCatalyst, see the iOS profiling wiki page for more information.
Note
For Windows applications, you might just consider using Visual Studio’s built-in profiling tools, butdotnet-trace collect -- C:\path\to\an\executable.exe
is also an option.Now that you’ve collected a file containing performance information, opening them to view the data is the next step:
dotnet-trace
by default outputs.nettrace
files, which can be opened in PerfView or Visual Studio.dotnet-trace collect --format speedscope
outputs.speedscope
files, which can be opened in the Speedscope web app.dotnet-gcdump
outputs.gcdump
files, which can be opened in PerfView or Visual Studio. Note that there is not currently a good option to open these files on macOS.
In the future, we hope to make profiling .NET MAUI applications easier in both future releases of the above .NET diagnostic tooling and Visual Studio.
Note
Note that the NativeAOT runtime does not have support fordotnet-trace
and performance profiling. You can use the other supported runtimes for this, or use native profiling tools instead such as Xcode’s Instruments.See the profiling .NET MAUI wiki page for links to documentation on each platform or a profiling demo on YouTube for a full walkthrough.
Conclusion
.NET 9 introduces performance enhancements for .NET MAUI applications
through full trimming and NativeAOT. These features enable developers
to create more efficient and responsive applications by reducing
application size and improving startup times. By leveraging tools like
dotnet-trace
and dotnet-gcdump
, developers can gain insights into
their application’s performance.
For a full rundown on .NET MAUI trimming and NativeAOT, see the .NET Conf 2024 session on the topic.
0 comments
Be the first to start the discussion.