Performance Improvements in .NET MAUI
.NET Multi-platform App UI (MAUI) unifies Android, iOS, macOS, and Windows APIs into a singleAPI so you can write one app that runs natively on many platforms. We arefocused on improving both your daily productivityas well as performance of your applications. Gains in developerproductivity, we believe, should not be at the cost of applicationperformance.
The same could be said about application size — what overhead ispresent in a blank .NET MAUI application? When we began optimizing.NET MAUI, it was clear that iOS had some work needed to improveapplication size, while Android was lacking in startup performance.
iOS application size of a dotnet new maui
project was originallyaround 18MB. Likewise .NET MAUI startup times on Android in earlierpreviews were not looking too good:
Application | Framework | Startup Time(ms) |
---|---|---|
Xamarin.Android | Xamarin | 306.5 |
Xamarin.Forms | Xamarin | 498.6 |
Xamarin.Forms (Shell) | Xamarin | 817.7 |
dotnet new android | .NET 6 (Early Preview) | 210.5 |
dotnet new maui | .NET 6 (Early Preview) | 683.9 |
.NET Podcast | .NET 6 (Early Preview) | 1299.9 |
This is an average of ten runs on a Pixel 5 device. See ourmaui-profiling repo for details on how these numbers wereobtained.
Our goal was for .NET MAUI to be faster than its predecessor,Xamarin.Forms, and it was clear that we had some work to do in .NETMAUI itself. The dotnet new android
template was already shaping upto launch faster than Xamarin.Android, mostly due to the new BCL andMono runtime in .NET 6.
The dotnet new maui
template was not yet using the Shellnavigation pattern, but plans were in the works for it to be thedefault navigation pattern in .NET MAUI. We knew there would be aperformance-hit in the template when we adopted this change.
To arrive at where we are today, it was a collaboration of severaldifferent teams. We improved areas like Microsoft.Extensions andDependencyInjection usage, AOT compilation, Java interop, XAML, codein .NET MAUI in general, and many more.
After the dust settled, we arrived at a much better place:
Application | Framework | Startup Time(ms) |
---|---|---|
Xamarin.Android | Xamarin | 306.5 |
Xamarin.Forms | Xamarin | 498.6 |
Xamarin.Forms (Shell) | Xamarin | 817.7 |
dotnet new android | .NET 6 (MAUI GA) | 182.8 |
dotnet new maui (No Shell**) | .NET 6 (MAUI GA) | 464.2 |
dotnet new maui (Shell) | .NET 6 (MAUI GA) | 568.1 |
.NET Podcast App (Shell) | .NET 6 (MAUI GA) | 814.2 |
**
– this is the original dotnet new maui
template that is not using Shell.
Details below, enjoy!
Table Of Contents
Startup Performance Improvements
- Profiling on Mobile
- Measuring Over Time
- Profiled AOT
- Single-file Assembly Stores
- Spanify RegisterNativeMembers
- System.Reflection.Emit and Constructors
- System.Reflection.Emit and Methods
- Newer Java.Interop APIs
- Multi-dimensional Java Arrays
- Use Glide for Android Images
- Reduce Java Interop Calls
- Port Android XML to Java
- Remove Microsoft.Extensions.Hosting
- Less Shell Initialization on Startup
- Fonts Should Not Use Temporary Files
- Compute OnPlatform at Compile-time
- Use Compiled Converters in XAML
- Optimize Color Parsing
- Don’t Use Culture-aware String Comparisons
- Create Loggers Lazily
- Use Factory Methods for Dependency Injection
- Load ConfigurationManager Lazily
- Default VerifyDependencyInjectionOpenGenericServiceTrimmability
- Improve the Built-in AOT Profile
- Enable Lazy-loading of AOT Images
- Remove Unused Encoding Object in System.Uri
App Size Improvements
- Fix defaults for MauiImage Sizes
- Remove Application.Properties and DataContractSerializer
- Trim Unused HTTP Implementations
Improvements in the .NET Podcast Sample
- Remove Microsoft.Extensions.Http Usage
- Remove Newtonsoft.Json Usage
- Run First Network Request in Background
Experimental or Advanced Options
- Trimming Resource.designer.cs
- R8 Java Code Shrinker
- AOT Everything
- AOT and LLVM
- Record a Custom AOT Profile
Startup Performance Improvements
Profiling on Mobile
I have to mention the .NET diagnostic tooling available for mobileplatforms, as it was our step number 0 for making .NET MAUI faster.
Profiling .NET 6 Android applications requires usage of a tool calleddotnet-dsrouter
. This tool enables dotnet trace
to connect toa running mobile application on Android, iOS, etc. This was probablythe most impactful tool we used for profiling .NET MAUI.
To get started with dotnet trace
and dsrouter
, begin byconfiguring some settings via adb
and launching dsrouter
:
adb reverse tcp:9000 tcp:9001
adb shell setprop debug.mono.profile '127.0.0.1:9000,suspend'
dotnet-dsrouter client-server -tcps 127.0.0.1:9001 -ipcc /tmp/maui-app --verbose debug
Next launch dotnet trace
, such as:
dotnet-trace collect --diagnostic-port /tmp/maui-app --format speedscope
After launching an Android app built with -c Release
and-p:AndroidEnableProfiler=true
, you’ll notice the connection whendotnet trace
outputs:
Press <Enter> or <Ctrl+C> to exit...812 (KB)
Simply press enter after your application is fully launched to get a*.speedscope
saved in the current directory. You can open this fileat https://speedscope.app, for an in-depth look into the time eachmethod takes during application startup:
See our documentation for further details using dotnet trace
inAndroid applications. I would recommend profiling Release
builds ona physical Android device to get the best picture of the real-worldperformance of your app.
Measuring Over Time
Our friends in the .NET fundamentals team setup a pipeline to track.NET MAUI performance scenarios, such as:
- Package size
- On disk size (uncompressed)
- Individual file breakdown
- Application startup
This allowed us to see the impact of improvements or regressions overtime, seeing numbers for each commit of the dotnet/maui repo. We couldalso determine if the difference was caused by changes inxamarin-android, xamarin-macios, or dotnet/runtime.
So for example, a graph of startup time (in milliseconds) of thedotnet new maui
template, running on a physical Pixel 4a device:
Note that the Pixel 4a is considerably slower than the Pixel 5.
We could pinpoint the commit in dotnet/maui where regressions andimprovements occurred. It can’t be understated how useful this was fortracking our goals.
Likewise, we could see our progress in the .NET Podcast app overtime on the same Pixel 4a device(s):
This graph was our true focus, as it was a “real application” close towhat developers would see in their own mobile apps.
As for application size, it is a much more stable number — making itvery easy to zero in when things got worse or better:
See dotnet-podcasts#58, AndroidX#520, anddotnet/maui#6419 for details on these improvements.
Profiled AOT
During our initial performance tests of .NET MAUI, we saw how JIT(just in time) vs AOT (ahead of time) compiled code can perform:
Application | JIT Time(ms) | AOT Time(ms) |
---|---|---|
dotnet new maui | 1078.0ms | 683.9ms |
JIT-ing happens the first time each C# method is called, whichimplicitly impacts startup performance in mobile applications.
Also problematic is the app size increase caused by AOT. An Androidnative library is added to the final app for every .NET assembly. Togive the best of both worlds, startup tracing or Profiled AOT isa current feature of Xamarin.Android. This is a mechanism for AOT’ingthe startup path of applications, which improves launch timessignificantly with only a modest app size increase.
It made complete sense for this to be the default option for Release
builds in .NET 6. In the past, the Android NDK was required (amultiple gigabyte download) for doing AOT of any kind withXamarin.Android. We did the legwork for building AOT’d applicationswithout an Android NDK installed, making it possible to be the defaultgoing forward.
We recorded built-in profiles for dotnet new android
, maui
, andmaui-blazor
templates that benefit most applications. If you wouldlike to record a custom profile in .NET 6, you can try ourexperimental Mono.Profiler.Android package. We are working onfull support for recording custom profiles in a future .NET release.
See xamarin-android#6547 and dotnet/maui#4859 fordetails about this improvement.
Single-file Assembly Stores
Previously, if you reviewed a Release
Android .apk
contents inyour favorite zip-file utility, you can see .NET assemblies located at:
assemblies/Java.Interop.dll
assemblies/Mono.Android.dll
assemblies/System.Runtime.dll
assemblies/arm64-v8a/System.Private.CoreLib.dll
assemblies/armeabi-v7a/System.Private.CoreLib.dll
assemblies/x86/System.Private.CoreLib.dll
assemblies/x86_64/System.Private.CoreLib.dll
These files were loaded individually with the mmap
systemcall, which was a cost per .NET assembly inside the app. This isimplemented in C/C++ in the Android workload, using a callback thatthe Mono runtime provides for assembly loading. MAUI applications havea lot of assemblies, so we introduced a new$(AndroidUseAssemblyStore)
feature that is enabled by default forRelease
builds.
After this change, you end up with:
assemblies/assemblies.manifest
assemblies/assemblies.blob
assemblies/assemblies.arm64_v8a.blob
assemblies/assemblies.armeabi_v7a.blob
assemblies/assemblies.x86.blob
assemblies/assemblies.x86_64.blob
Now Android startup only has to call mmap
twice: once forassemblies.blob
, and a second time for the architecture-specificblob. This had a noticeable impact on applications with many .NETassemblies.
If you need to inspect the IL, of these assemblies from a compiledAndroid application, we created an assembly-store-reader toolfor “unpacking” these files.
Another option is to build your application with these settingsdisabled:
dotnet build -c Release -p:AndroidUseAssemblyStore=false -p:AndroidEnableAssemblyCompression=false
This allows you to be able to unzip the resulting .apk
with yourfavorite zip utility and inspect the .NET assemblies with a tool likeILSpy. This is a good way to diagnose trimmer/linker issues.
See xamarin-android#6311 for details about this improvement.
Spanify RegisterNativeMembers
When a C# object is created from Java, a small Java wrapper isinvoked, such as:
public class MainActivity extends android.app.Activity
{
public static final String methods;
static {
methods = "n_onCreate:(Landroid/os/Bundle;)V:GetOnCreate_Landroid_os_Bundle_Handler\n";
mono.android.Runtime.register ("foo.MainActivity, foo", MainActivity.class, methods);
}
The list of methods
is a \n
and :
-delimited list of Java NativeInterface (JNI) signatures that are overridden in managed C# code.You get one of these for each Java method that is overridden in C#.
When the actual Java onCreate()
method is called for an AndroidActivity
:
public void onCreate (android.os.Bundle p0)
{
n_onCreate (p0);
}
private native void n_onCreate (android.os.Bundle p0);
Through various magic and hand waving, n_onCreate
calls into theMono runtime and invokes our OnCreate()
method in C#.
The code splitting the \n
and :
-delimited list of methods waswritten in the early days of Xamarin using string.Split()
. Sufficeto say Span<T>
didn’t exist back then, but we can use it now!This improves the cost of any C# class that subclasses a Java class,so it is a wider reaching improvement than just .NET MAUI.
You might ask, “why use strings at all?” Using Java arrays appears tohave a worse performance impact than delimited strings. In our testingcalling into JNI to get Java array elements, performs worse thanstring.Split
and our new usage of Span
. We have some ideas onhow we can re-architect this in future .NET releases.
In addition to .NET 6, this change shipped in the latest version ofXamarin.Android for current customers.
See xamarin-android#6708 for details about this improvement.
System.Reflection.Emit and Constructors
Since the early days of Xamarin, we had a somewhat complicated methodfor invoking C# constructors from Java.
First, we had some reflection calls that happen once on startup:
static MethodInfo newobject = typeof (System.Runtime.CompilerServices.RuntimeHelpers).GetMethod ("GetUninitializedObject", BindingFlags.Public | BindingFlags.Static)!;
static MethodInfo gettype = typeof (System.Type).GetMethod ("GetTypeFromHandle", BindingFlags.Public | BindingFlags.Static)!;
static FieldInfo handle = typeof (Java.Lang.Object).GetField ("handle", BindingFlags.NonPublic | BindingFlags.Instance)!;
This appears to be leftover from very early versions of Mono and hasjust persisted to this day. RuntimeHelpers.GetUninitializedObject()
,for example can be called directly.
Followed by some complex System.Reflection.Emit usage with a passed inSystem.Reflection.ConstructorInfo cinfo
instance:
DynamicMethod method = new DynamicMethod (DynamicMethodNameCounter.GetUniqueName (), typeof (void), new Type [] {typeof (IntPtr), typeof (object []) }, typeof (DynamicMethodNameCounter), true);
ILGenerator il = method.GetILGenerator ();
il.DeclareLocal (typeof (object));
il.Emit (OpCodes.Ldtoken, type);
il.Emit (OpCodes.Call, gettype);
il.Emit (OpCodes.Call, newobject);
il.Emit (OpCodes.Stloc_0);
il.Emit (OpCodes.Ldloc_0);
il.Emit (OpCodes.Ldarg_0);
il.Emit (OpCodes.Stfld, handle);
il.Emit (OpCodes.Ldloc_0);
var len = cinfo.GetParameters ().Length;
for (int i = 0; i < len; i++) {
il.Emit (OpCodes.Ldarg, 1);
il.Emit (OpCodes.Ldc_I4, i);
il.Emit (OpCodes.Ldelem_Ref);
}
il.Emit (OpCodes.Call, cinfo);
il.Emit (OpCodes.Ret);
return (Action<IntPtr, object?[]?>) method.CreateDelegate (typeof (Action <IntPtr, object []>));
We call the delegate returned, such that the IntPtr
is the Handle
of the Java.Lang.Object
subclass and the object[]
are anyparameters for that particular C# constructor. System.Reflection.Emithas a significant cost for both the first use of it on startup, aswell as each future call.
After some careful review, we could make the handle
fieldinternal
, and simplify this code to:
var newobj = RuntimeHelpers.GetUninitializedObject (cinfo.DeclaringType);
if (newobj is Java.Lang.Object o) {
o.handle = jobject;
} else if (newobj is Java.Lang.Throwable throwable) {
throwable.handle = jobject;
} else {
throw new InvalidOperationException ($"Unsupported type: '{newobj}'");
}
cinfo.Invoke (newobj, parms);
What this code does is create an object without calling theconstructor (ok weird?), set the handle
field, and then invoke theconstructor. This is done so that the Handle
is valid on anyJava.Lang.Object
when the C# constructor begins. The Handle
wouldbe needed for any Java interop inside the constructor (like callingother Java methods on the class) as well as calling any base Javaconstructors.
The new code significantly improved any C# constructor called fromJava, so this particular change improves more than just .NET MAUI. Inaddition to .NET 6, this change shipped in the latest version ofXamarin.Android for current customers.
See xamarin-android#6766 for details about this improvement.
System.Reflection.Emit and Methods
When you override a Java method in C#, such as:
public class MainActivity : Activity
{
protected override void OnCreate(Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
//...
}
}
In the transition from Java to C#, we have to wrap the C# method tohandle exceptions such as:
try
{
// Call the actual C# method here
}
catch (Exception e) when (_unhandled_exception (e))
{
AndroidEnvironment.UnhandledException (e);
if (Debugger.IsAttached || !JNIEnv.PropagateExceptions)
throw;
}
If a managed exception is unhandled in OnCreate()
, for example, thenyou actually end up with a native crash (and no managed C# stacktrace). We need to make sure the debugger can break on the exceptionif it is attached, and log the C# stack trace otherwise.
Since the beginning of Xamarin, the above code was generated viaSystem.Reflection.Emit:
var dynamic = new DynamicMethod (DynamicMethodNameCounter.GetUniqueName (), ret_type, param_types, typeof (DynamicMethodNameCounter), true);
var ig = dynamic.GetILGenerator ();
LocalBuilder? retval = null;
if (ret_type != typeof (void))
retval = ig.DeclareLocal (ret_type);
ig.Emit (OpCodes.Call, wait_for_bridge_processing_method!);
var label = ig.BeginExceptionBlock ();
for (int i = 0; i < param_types.Length; i++)
ig.Emit (OpCodes.Ldarg, i);
ig.Emit (OpCodes.Call, dlg.Method);
if (retval != null)
ig.Emit (OpCodes.Stloc, retval);
ig.Emit (OpCodes.Leave, label);
bool filter = Debugger.IsAttached || !JNIEnv.PropagateExceptions;
if (filter && JNIEnv.mono_unhandled_exception_method != null) {
ig.BeginExceptFilterBlock ();
ig.Emit (OpCodes.Call, JNIEnv.mono_unhandled_exception_method);
ig.Emit (OpCodes.Ldc_I4_1);
ig.BeginCatchBlock (null!);
} else {
ig.BeginCatchBlock (typeof (Exception));
}
ig.Emit (OpCodes.Dup);
ig.Emit (OpCodes.Call, exception_handler_method!);
if (filter)
ig.Emit (OpCodes.Throw);
ig.EndExceptionBlock ();
if (retval != null)
ig.Emit (OpCodes.Ldloc, retval);
ig.Emit (OpCodes.Ret);
This code is called twice for a dotnet new android
app, but ~58times for a dotnet new maui
app!
Instead using System.Reflection.Emit, we realized we could actuallywrite a strongly-typed “fast path” for each common delegate type.There is a generated delegate
that matches each signature:
void OnCreate(Bundle savedInstanceState);
// Maps to *JNIEnv, JavaClass, Bundle
// Internal to each assembly
internal delegate void _JniMarshal_PPL_V(IntPtr, IntPtr, IntPtr);
So we could list every signature used by dotnet maui
apps, such as:
class JNINativeWrapper
{
static Delegate? CreateBuiltInDelegate (Delegate dlg, Type delegateType)
{
switch (delegateType.Name)
{
// Unsafe.As<T>() is used, because _JniMarshal_PPL_V is generated internal in each assembly
case nameof (_JniMarshal_PPL_V):
return new _JniMarshal_PPL_V (Unsafe.As<_JniMarshal_PPL_V> (dlg).Wrap_JniMarshal_PPL_V);
// etc.
}
return null;
}
// Static extension method is generated to avoid capturing variables in anonymous methods
internal static void Wrap_JniMarshal_PPL_V (this _JniMarshal_PPL_V callback, IntPtr jnienv, IntPtr klazz, IntPtr p0)
{
// ...
}
}
The drawback to this approach is that we have to list more cases whena new signature is used. We don’t want to exhaustively list everycombination, as this will cause IL-size to grow. We are investigatinghow to improve this in future .NET releases.
See xamarin-android#6657 and xamarin-android#6707 fordetails about this improvement.
Newer Java.Interop APIs
The original Xamarin APIs in Java.Interop.dll
, are APIs such as:
JNIEnv.CallStaticObjectMethod
Where the “new way” to call into Java makes fewer memory allocationsper call:
JniEnvironment.StaticMethods.CallStaticObjectMethod
When C# bindings are generated for Java methods at build time, thenewer/faster methods are used by default — and have been for sometime in Xamarin.Android. Previously, Java binding projects could set$(AndroidCodegenTarget)
to XAJavaInterop1
, which caches andreuses jmethodID
instances on each call. See the java.interoprepo for the history on this feature.
The remaining places that this is an issue, are anywhere we have“manual” bindings. These tend to also be frequently used methods, soit was worthwhile to fix these!
Some examples of improving this situation:
JNIEnv.FindClass()
in xamarin-android#6805JavaList
andJavaList<T>
in xamarin-android#6812
Multi-dimensional Java Arrays
When passing C# arrays back and forth to Java, an intermediate stephas to copy the array so the appropriate runtime can access it. Thisis really a developer experience situation, as C# developers expect towrite something like:
var array = new int[] { 1, 2, 3, 4};
MyJavaMethod (array);
Where inside MyJavaMethod
would do:
IntPtr native_items = JNIEnv.NewArray (items);
try
{
// p/invoke here, actually calls into Java
}
finally
{
if (items != null)
{
JNIEnv.CopyArray (native_items, items); // If the calling method mutates the array
JNIEnv.DeleteLocalRef (native_items); // Delete our Java local reference
}
}
JNIEnv.NewArray()
accesses a “type map” to know which Java class needsto be used for the elements of the array.
A particular Android API used by dotnet new maui
projects was problematic:
public ColorStateList (int[][]? states, int[]? colors)
A multi-dimensional int[][]
array was found to access the “type map”for each element. We could see this when enabling additional logging,many instances of:
monodroid: typemap: failed to map managed type to Java type: System.Int32, System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e (Module ID: 8e4cd939-3275-41c4-968d-d5a4376b35f5; Type token: 33554653)
monodroid-assembly: typemap: called from
monodroid-assembly: at Android.Runtime.JNIEnv.TypemapManagedToJava(Type )
monodroid-assembly: at Android.Runtime.JNIEnv.GetJniName(Type )
monodroid-assembly: at Android.Runtime.JNIEnv.FindClass(Type )
monodroid-assembly: at Android.Runtime.JNIEnv.NewArray(Array , Type )
monodroid-assembly: at Android.Runtime.JNIEnv.NewArray[Int32[]](Int32[][] )
monodroid-assembly: at Android.Content.Res.ColorStateList..ctor(Int32[][] , Int32[] )
monodroid-assembly: at Microsoft.Maui.Platform.ColorStateListExtensions.CreateButton(Int32 enabled, Int32 disabled, Int32 off, Int32 pressed)
For this case, we should be able to call JNIEnv.FindClass()
once andreuse this value for each item in the array!
We are investigating how to improve this further in future .NETreleases. One such example would be dotnet/maui#5654, where weare simply looking into creating the arrays completely in Java instead.
See xamarin-android#6870 for details about this improvement.
Use Glide for Android Images
Glide
is the recommendedimage-loading library for modern Android applications. Googledocumentation even recommends using it, because the built-in AndroidBitmap
class can be painfully difficult to use correctly.glidex.forms
was aprototype for using Glide in Xamarin.Forms, but we promoted Glide tobe “the way” to load images in .NET MAUI going forward.
To reduce the overhead of JNI interop, .NET MAUI’s Glideimplementation is mostly written in Java, such as:
import com.bumptech.glide.Glide;
//...
public static void loadImageFromUri(ImageView imageView, String uri, Boolean cachingEnabled, ImageLoaderCallback callback) {
//...
RequestBuilder<Drawable> builder = Glide
.with(imageView)
.load(androidUri);
loadInto(builder, imageView, cachingEnabled, callback);
}
Where ImageLoaderCallback
is subclassed in C# to handle completionin managed code. The result is that the performance of images from theweb should be significantly improved from what you got previously inXamarin.Forms.
See dotnet/maui#759 and dotnet/maui#5198 for detailsabout this improvement.
Reduce Java Interop Calls
Let’s say you have the following Java APIs:
public void setFoo(int foo);
public void setBar(int bar);
The interop for these methods look something like:
public unsafe static void SetFoo(int foo)
{
JniArgumentValue* __args = stackalloc JniArgumentValue[1];
__args[0] = new JniArgumentValue(foo);
return _members.StaticMethods.InvokeInt32Method("setFoo.(I)V", __args);
}
public unsafe static void SetBar(int bar)
{
JniArgumentValue* __args = stackalloc JniArgumentValue[1];
__args[0] = new JniArgumentValue(bar);
return _members.StaticMethods.InvokeInt32Method("setBar.(I)V", __args);
}
So calling both of these methods would stackalloc
twice and p/invoketwice. It would be more performant to create a small Java wrapper,such as:
public void setFooAndBar(int foo, int bar)
{
setFoo(foo);
setBar(bar);
}
Which translates to:
public unsafe static void SetFooAndBar(int foo, int bar)
{
JniArgumentValue* __args = stackalloc JniArgumentValue[2];
__args[0] = new JniArgumentValue(foo);
__args[1] = new JniArgumentValue(bar);
return _members.StaticMethods.InvokeInt32Method("setFooAndBar.(II)V", __args);
}
.NET MAUI views are essentially C# objects with lots of propertiesthat need to be set in Java in this exact same way. If we apply thisconcept to every Android View
in .NET MAUI, we can create an ~18argument method to be used on View
creation. Subsequent propertychanges can can call the standard Android APIs directly.
This had a dramatic increase in performance, for even very simple .NETMAUI controls:
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
Border (before) | 323.2 µs | 0.82 µs | 0.68 µs | 0.9766 | 5 KB |
Border (after) | 242.3 µs | 1.34 µs | 1.25 µs | 0.9766 | 5 KB |
ContentView (before) | 354.6 µs | 2.61 µs | 2.31 µs | 1.4648 | 6 KB |
ContentView (after) | 258.3 µs | 0.49 µs | 0.43 µs | 1.4648 | 6 KB |
See dotnet/maui#3372 for details about this improvement.
Port Android XML to Java
Reviewing dotnet trace
output on Android, we can see a reasonableamount of time spent in:
20.32.ms mono.android!Android.Views.LayoutInflater.Inflate
Reviewing the stack trace, the time is actually spent in Android/Javato inflate the layout, and no work is happening on the .NET side.
If you look at a compiled Android .apk
andres/layouts/bottomtablayout.axml
in Android Studio, the XML is justplain XML. Only a few identifiers are transformed to integers. Thismeans Android has to parse this XML and create Java objects throughJava’s reflection APIs — it seemed like we could get fasterperformance by not using XML?
Testing a standard BenchmarkDotNet comparison, we found that the useof Android layouts performed worse even than C# when interop isinvolved:
Method | Mean | Error | StdDev | Allocated |
---|---|---|---|---|
Java | 338.4 µs | 4.21 µs | 3.52 µs | 744 B |
CSharp | 410.2 µs | 7.92 µs | 6.61 µs | 1,336 B |
XML | 490.0 µs | 7.77 µs | 7.27 µs | 2,321 B |
Next, we configured BenchmarkDotNet to do a single run, to bettersimulate what would happen on startup:
Method | Mean |
---|---|
Java | 4.619 ms |
CSharp | 37.337 ms |
XML | 39.364 ms |
We looked at one of the simpler layouts in .NET MAUI, bottom tabnavigation:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/bottomtab.navarea"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="fill"
android:layout_weight="1" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomtab.tabbar"
android:theme="@style/Widget.Design.BottomNavigationView"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
We could port this to four Java methods, such as:
@NonNull
public static List<View> createBottomTabLayout(Context context, int navigationStyle);
@NonNull
public static LinearLayout createLinearLayout(Context context);
@NonNull
public static FrameLayout createFrameLayout(Context context, LinearLayout layout);
@NonNull
public static BottomNavigationView createNavigationBar(Context context, int navigationStyle, FrameLayout bottom)
This allows us to only cross from C# into Java four times whencreating bottom tab navigation on Android. It also allows the AndroidOS to skip loading and parsing an .xml
to “inflate” Java objects. Wecarried this idea throughout dotnet/maui removing allLayoutInflater.Inflate()
calls on startup.
See dotnet/maui#5424, dotnet/maui#5493, anddotnet/maui#5528 for details about these improvements.
Remove Microsoft.Extensions.Hosting
Microsoft.Extensions.Hosting
provides a .NET Generic Host formanaging dependency injection, logging, configuration, and applicationlifecycle within a .NET application. This has an impact to startuptimes that did not seem appropriate for mobile applications.
It made sense to remove Microsoft.Extensions.Hosting
usage from .NETMAUI. Instead of trying to interoperate with the “Generic Host” tobuild the DI container, .NET MAUI has its own simple implementationthat is optimized for mobile startup. Additionally, .NET MAUI nolonger adds logging providers by default.
With this change, we saw reduction in startup time of a dotnet new maui
Android app between 5-10%. And it reduces the size of the sameapp on iOS from 19.2 MB
=> 18.0 MB
.
See dotnet/maui#4505 and dotnet/maui#4545 for detailsabout this improvement.
Less Shell Initialization on Startup
Xamarin.Forms Shell is a pattern for navigation incross-platform applications. This pattern was brought forward to .NETMAUI, where it is recommended as the default way for buildingapplications.
As we discovered the cost of using Shell in startup (for bothXamarin.Forms and .NET MAUI), we found a couple places to optimize:
- Don’t parse routes on startup — wait until a navigation occurs thatwould need them.
- If no query strings have been supplied for navigation then just skipthe code that processes query strings. This removes a code path thatuses System.Reflection heavily.
- If the page doesn’t have a visible
BottomNavigationView
, thendon’t setup the menu items or any of the appearance elements.
See dotnet/maui#5262 for details about this improvement.
Fonts Should Not Use Temporary Files
A significant amount of time was spend in .NET MAUI apps loading fonts:
32.19ms Microsoft.Maui!Microsoft.Maui.FontManager.CreateTypeface(System.ValueTuple`3<string, Microsoft.Maui.FontWeight, bool>)
Reviewing the code, it was doing more work than needed:
- Saving
AndroidAsset
files to a temp folder. - Use the Android API,
Typeface.CreateFromFile()
to load the file.
We can actually use the Typeface.CreateFromAsset()
Android APIdirectly and not use a temporary file at all.
See dotnet/maui#4933 for details about this improvement.
Compute OnPlatform at Compile-time
Usage of the {OnPlatform}
markup extension:
<Label Text="Platform: " />
<Label Text="{OnPlatform Default=Unknown, Android=Android, iOS=iOS" />
…can actually be computed at compile-time, where thenet6.0-android
and net6.0-ios
get the appropriate value. In afuture .NET release, we will look into the same optimization for the<OnPlatform/>
XML element.
See dotnet/maui#4829 and dotnet/maui#5611 for detailsabout this improvement.
Use Compiled Converters in XAML
The following types are now converted at XAML compile time, instead ofat runtime:
Color
: dotnet/maui#4687CornerRadius
: dotnet/maui#5192FontSize
: dotnet/maui#5338GridLength
,RowDefinition
,ColumnDefinition
: dotnet/maui#5489
This results in better/faster generated IL from .xaml
files.
Optimize Color Parsing
The original code for Microsoft.Maui.Graphics.Color.Parse()
could berewritten to make better use of Span<T>
and avoid string allocations.
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
Parse (before) | 99.13 ns | 0.281 ns | 0.235 ns | 0.0267 | 168 B |
Parse (after) | 52.54 ns | 0.292 ns | 0.259 ns | 0.0051 | 32 B |
Being able to use a switch
-statement on ReadonlySpan<char>
dotnet/csharplang#1881 will improve this case even further in afuture .NET release.
See dotnet/Microsoft.Maui.Graphics#343 anddotnet/Microsoft.Maui.Graphics#345 for details about thisimprovement.
Don’t Use Culture-aware String Comparisons
Reviewing dotnet trace
output of a dotnet new maui
project showedthe real cost of the first culture-aware string comparison onAndroid:
6.32ms Microsoft.Maui.Controls!Microsoft.Maui.Controls.ShellNavigationManager.GetNavigationState
3.82ms Microsoft.Maui.Controls!Microsoft.Maui.Controls.ShellUriHandler.FormatUri
3.82ms System.Private.CoreLib!System.String.StartsWith
2.57ms System.Private.CoreLib!System.Globalization.CultureInfo.get_CurrentCulture
Really, we did not even want to use a culture-aware comparison inthis case — it was simply code brought over from Xamarin.Forms.
So for example if you have:
if (text.StartsWith("f"))
{
// do something
}
In this case you can simply do this instead:
if (text.StartsWith("f", StringComparision.Ordinal))
{
// do something
}
If done across an entire application,System.Globalization.CultureInfo.CurrentCulture
can avoid beingcalled, as well as improving the overall speed of this if
-statementby a small amount.
To fix this situation across the entire dotnet/maui repo, weintroduced code analysis rules to catch these:
dotnet_diagnostic.CA1307.severity = error
dotnet_diagnostic.CA1309.severity = error
See dotnet/maui#4988 for details about this improvement.
Create Loggers Lazily
The ConfigureFonts()
API was spending a bit of time on startup doingwork that could be deferred until later. We could also improve thegeneral usage of the logging infrastructure in Microsoft.Extensions.
Some improvements we made were:
- Defer creating “logger” classes until they are needed.
- The built-in logging infrastructure is disabled by default, and hasto be enabled explicitly.
- Delay calling
Path.GetTempPath()
in Android’sEmbeddedFontLoader
until it is needed. - Don’t use
ILoggerFactory
to create a generic logger. Instead gettheILogger
service directly, so it is cached.
See dotnet/maui#5103 for details about this improvement.
Use Factory Methods for Dependency Injection
When using Microsoft.Extensions.DependencyInjection
, registeringservices such as:
IServiceCollection services /* ... */;
services.TryAddSingleton<IFooService, FooService>();
Microsoft.Extensions has to do a bit of System.Reflection to createthe first instance of FooService
. This was noticeable in dotnet trace
output on Android.
Instead if you do:
// If FooService has no dependencies
services.TryAddSingleton<IFooService>(sp => new FooService());
// Or if you need to retrieve some dependencies
services.TryAddSingleton<IFooService>(sp => new FooService(sp.GetService<IBar>()));
In this case, Microsoft.Extensions can simply call yourlamdba/anonymous method and no System.Reflection is involved.
We made this improvement across all of dotnet/maui, as well as makinguse of BannedApiAnalyzers
, so that no one would accidentallyuse the slower overload of TryAddSingleton()
going forward.
See dotnet/maui#5290 for details about this improvement.
Default VerifyDependencyInjectionOpenGenericServiceTrimmability
The .NET Podcast sample was spending 4-7ms worth of time in:
Microsoft.Extensions.DependencyInjection.ServiceLookup.CallsiteFactory.ValidateTrimmingAnnotations()
The $(VerifyDependencyInjectionOpenGenericServiceTrimmability)
MSBuild property triggers this method to run. This feature switchensures that DynamicallyAccessedMembers
are applied correctly toopen generic types used in Dependency Injection.
In the base .NET SDK, this switch is enabled whenPublishTrimmed=true
. However, Android apps don’t setPublishTrimmed=true
in Debug
builds, so developers are missing outon this validation.
Conversely, in published apps, we don’t want to pay the cost of doingthis validation. So this feature switch should be off in Release
builds.
See xamarin-android#6727 and xamarin-macios#14130 fordetails about this improvement.
Load ConfigurationManager Lazily
System.Configuration.ConfigurationManager
isn’t used by many mobileapplications, and it turns out to be quite expensive to create one!(~7.59ms on Android, for example)
One ConfigurationManager
was being created by default at startup in.NET MAUI, we can defer its creation using Lazy<T>
, so it will notbe created unless requested.
See dotnet/maui#5348 for details about this improvement.
Improve the Built-in AOT Profile
The Mono runtime has a report (see our documentation) for theJIT times of each method, such as:
Total(ms) | Self(ms) | Method
3.51 | 3.51 | Microsoft.Maui.Layouts.GridLayoutManager/GridStructure:.ctor (Microsoft.Maui.IGridLayout,double,double)
1.88 | 1.88 | Microsoft.Maui.Controls.Xaml.AppThemeBindingExtension/<>c__DisplayClass20_0:<Microsoft.Maui.Controls.Xaml.IMarkupExtension<Microsoft.Maui.Controls.BindingBase>.ProvideValue>g__minforetriever|0 ()
1.66 | 1.66 | Microsoft.Maui.Controls.Xaml.OnIdiomExtension/<>c__DisplayClass32_0:<ProvideValue>g__minforetriever|0 ()
1.54 | 1.54 | Microsoft.Maui.Converters.ThicknessTypeConverter:ConvertFrom (System.ComponentModel.ITypeDescriptorContext,System.Globalization.CultureInfo,object)
This was a selection of the top JIT-times in the .NET Podcastsample in a Release
build using Profiled AOT. These seemed likecommonly used APIs that developers would want to use in .NET MAUIapplications.
To make sure these methods are in the AOT profile, we used these APIsin the “recorded app” we use in dotnet/maui:
_ = new Microsoft.Maui.Layouts.GridLayoutManager(new Grid()).Measure(100, 100);
<SolidColorBrush x:Key="ProfiledAot_AppThemeBinding_Color" Color="{AppThemeBinding Default=Black}"/>
<CollectionView x:Key="ProfiledAot_CollectionView_OnIdiom_Thickness" Margin="{OnIdiom Default=1,1,1,1}" />
Calling these methods within this test application ensured they wouldbe in the built-in .NET MAUI AOT profile.
After this change, we looked at an updated JIT report:
Total (ms) | Self (ms) | Method
2.61 | 2.61 | string:SplitInternal (string,string[],int,System.StringSplitOptions)
1.57 | 1.57 | System.Number:NumberToString (System.Text.ValueStringBuilder&,System.Number/NumberBuffer&,char,int,System.Globalization.NumberFormatInfo)
1.52 | 1.52 | System.Number:TryParseInt32IntegerStyle (System.ReadOnlySpan`1<char>,System.Globalization.NumberStyles,System.Globalization.NumberFormatInfo,int&)
Which led to further additions to the profile:
var split = "foo;bar".Split(';');
var x = int.Parse("999");
x.ToString();
We did similar changes for Color.Parse()
,Connectivity.NetworkAccess
, DeviceInfo.Idiom
, andAppInfo.RequestedTheme
that should be commonly used in .NET MAUIapplications.
See dotnet/maui#5559, dotnet/maui#5682, anddotnet/maui#6834 for details about these improvements.
If you would like to record a custom AOT profile in .NET 6, you can tryour experimental Mono.Profiler.Android package. We are working onfull support for recording custom profiles in a future .NET release.
Enable Lazy-loading of AOT Images
Previously, the Mono runtime would load all AOT images at startup toverify the MVID of the managed .NET assembly (such as Foo.dll
)matches the AOT image (libFoo.dll.so
). In most .NET applications,some AOT images may not need to be loaded until later.
A new --aot-lazy-assembly-load
or mono_opt_aot_lazy_assembly_load
setting was introduced in Mono that the Android workload could optinto. We found this improved the startup of a dotnet new maui
project on a Pixel 6 Pro by around 25ms.
This is enabled by default, but if desired you can disable thissetting in your .csproj
via:
<AndroidAotEnableLazyLoad>false</AndroidAotEnableLazyLoad>
See dotnet/runtime#67024 and xamarin-android#6940 fordetails about these improvements.
Remove Unused Encoding Object in System.Uri
dotnet trace
output of a MAUI app, showed that around 7ms were spentloading UTF32 and Latin1 encodings the first time System.Uri
APIsare used:
namespace System
{
internal static class UriHelper
{
internal static readonly Encoding s_noFallbackCharUTF8 = Encoding.GetEncoding(
Encoding.UTF8.CodePage, new EncoderReplacementFallback(""), new DecoderReplacementFallback(""));
This field was left in place accidentally. Simply removing thes_noFallbackCharUTF8
field, improved startup for any .NETapplication using System.Uri
or related APIs.
See dotnet/runtime#65326 for details about this improvement.
App Size Improvements
Fix defaults for MauiImage Sizes
The dotnet new maui
template displays a friendly “.NET bot” image.This is implemented by using an .svg
file as a MauiImage
with thecontents:
<svg width="419" height="519" viewBox="0 0 419 519" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- everything else -->
By default, MauiImage
uses the width
and height
values in the.svg
as the “base size” of the image. Reviewing the build outputshowed these images are scaled to:
obj\Release\net6.0-android\resizetizer\r\mipmap-xxxhdpi
appiconfg.png = 1824x1824
dotnet_bot.png = 1676x2076
This seemed to be a bit oversized for Android devices? We can simplyspecify %(BaseSize)
in the template, which also gives an example onhow to choose an appropriate size for these images:
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\appiconfg.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.svg" BaseSize="168,208" />
Which results in more appropriate sizes:
obj\Release\net6.0-android\resizetizer\r\mipmap-xxxhdpi\
appiconfg.png = 512x512
dotnet_bot.png = 672x832
We also could have modified the .svg
contents, but that may not bedesirable depending on how a graphics designer would use this image inother design tools.
In another example, a 3008×5340 .jpg
image:
<MauiImage Include="Resources\Images\large.jpg" />
…was being upscaled to 21360×12032! Setting Resize="false"
wouldprevent the image from being resized, but we made this the defaultoption for non-vector images. Going forward, developers should be ableto rely on the default value or specify %(BaseSize)
and %(Resize)
as needed.
These changes improved startup performance as well as app size. Seedotnet/maui#4759 and dotnet/maui#6419 for detailsabout these improvements.
Remove Application.Properties and DataContractSerializer
Xamarin.Forms had an API for persisting key-value pairs through aApplication.Properties
dictionary. This usedDataContractSerializer
internally, which is not the best choice formobile applications that are self-contained & trimmed. Parts ofSystem.Xml
from the BCL can be quite large, and we don’t want to payfor this cost in every .NET MAUI application.
Simply removing this API and all usage of DataContractSerializer
resulted in a ~855KB improvement on Android and a ~1MB improvement oniOS.
See dotnet/maui#4976 for details about this improvement.
Trim Unused HTTP Implementations
The linker switch, System.Net.Http.UseNativeHttpHandler
was notappropriately trimming away the underlying managed HTTP handler(SocketsHttpHandler
). By default, AndroidMessageHandler
andNSUrlSessionHandler
are used to leverage the underlying Android andiOS networking stacks.
By fixing this, more IL code is able to be trimmed away in any .NETMAUI application. In one example, an Android application using HTTPwas able to completely trim away several assemblies:
Microsoft.Win32.Primitives.dll
System.Formats.Asn1.dll
System.IO.Compression.Brotli.dll
System.Net.NameResolution.dll
System.Net.NetworkInformation.dll
System.Net.Quic.dll
System.Net.Security.dll
System.Net.Sockets.dll
System.Runtime.InteropServices.RuntimeInformation.dll
System.Runtime.Numerics.dll
System.Security.Cryptography.Encoding.dll
System.Security.Cryptography.X509Certificates.dll
System.Threading.Channels.dll
See dotnet/runtime#64852, xamarin-android#6749, andxamarin-macios#14297 for details about this improvement.
Improvements in the .NET Podcast Sample
We made a few adjustments to the sample itself where the change wasconsidered “best practice”.
Remove Microsoft.Extensions.Http Usage
Using Microsoft.Extensions.Http is too heavy-weight for mobile applications,and doesn’t provide any real value in this situation.
So instead of using DI for HttpClient
:
builder.Services.AddHttpClient<ShowsService>(client =>
{
client.BaseAddress = new Uri(Config.APIUrl);
});
// Then in the service ctor
public ShowsService(HttpClient httpClient, ListenLaterService listenLaterService)
{
this.httpClient = httpClient;
// ...
}
We simply create an HttpClient
to be used within the service:
public ShowsService(ListenLaterService listenLaterService)
{
this.httpClient = new HttpClient() { BaseAddress = new Uri(Config.APIUrl) };
// ...
}
We would recommend using a single HttpClient
instance per webservice your application needs to interact with.
See dotnet/runtime#66863 and dotnet-podcasts#44 fordetails about this improvement.
Remove Newtonsoft.Json Usage
The .NET Podcast Sample was using a library calledMonkeyCache
that depends on Newtonsoft.Json. This is not aproblem in itself, except that .NET MAUI + Blazor applications dependon a few ASP.NET Core libraries which in turn depend onSystem.Text.Json. The application was effectively “paying twice” forJSON parsing libraries, which had an impact on app size.
We ported MonkeyCache
2.0 to use System.Text.Json, eliminating theneed for Newtonsoft.Json
in the app. This reduced the app size oniOS from 29.3MB to 26.1MB!
See monkey-cache#109 and dotnet-podcasts#58 for detailsabout this improvement.
Run First Network Request in Background
Reviewing dotnet trace
output, the initial request in ShowsService
was blocking the UI thread initializing Connectivity.NetworkAccess
,Barrel.Current.Get
, and HttpClient
. This work could be done in abackground thread — resulting in a faster startup time in this case.Wrapping the first call in Task.Run()
improves startup of thissample by a reasonable amount.
An average of 10 runs on a Pixel 5a device:
Before
Average(ms): 843.7
Average(ms): 847.8
After
Average(ms): 817.2
Average(ms): 812.8
This type of change, it is always recommended to base the decision offof dotnet trace
or other profiling results and measure the changesbefore and after.
See dotnet-podcasts#57 for details about this improvement.
Experimental or Advanced Options
If you would like to optimize your .NET MAUI application even furtheron Android, there are a couple features that are either advanced orexperimental, and not enabled by default.
Trimming Resource.designer.cs
Since the beginning of Xamarin, Android applications include agenerated Properties/Resource.designer.cs
file for accessing integeridentifiers for AndroidResource
files. This is a C#/managed versionof the R.java
class, to allow usage of these identifiers as plain C#fields (and sometimes const
) without any interop into Java.
In an Android Studio “library” project, when you include a file likeres/drawable/foo.png
, you get a field like:
package com.yourlibrary;
public class R
{
public class drawable
{
// The actual integer here maps to a table inside the final .apk file
public final int foo = 1234;
}
}
You can use this value, for example, to display this image in an ImageView
:
ImageView imageView = new ImageView(this);
imageView.setImageResource(R.drawable.foo);
When you build com.yourlibrary.aar
, the Android gradle plugindoesn’t actually put this class inside the package. Instead, theconsuming Android application is what actually knows what theinteger will be. So the R
class is generated when the Androidapplication builds, generating an R
class for every Android libraryconsumed.
Xamarin.Android took a different approach, to do this integer fixup atruntime. There wasn’t really a great precedence of doing somethinglike this with C# and MSBuild? So for example, a C# Android librarymight have:
public class Resource
{
public class Drawable
{
// The actual integer here is *not* final
public int foo = -1;
}
}
Then the main application would have code like:
public class Resource
{
public class Drawable
{
public Drawable()
{
// Copy the value at runtime
global::MyLibrary.Resource.Drawable.foo = foo;
}
// The actual integer here *is* final
public const int foo = 1234;
}
}
This situation has been working well for quite some time, butunfortunately the number of resources in Google’s libraries likeAndroidX, Material, Google Play Services, etc. have really started tocompound. In dotnet/maui#2606, for example, 21,497 fields wereset at startup! We created a way to workaround this at the time, butwe also have a new custom trimmer step to perform fixups at build time(during trimming) instead of at runtime.
To opt into the feature:
<AndroidLinkResources>true</AndroidLinkResources>
This will make your Release
builds replace cases like:
ImageView imageView = new(this);
imageView.SetImageResource(Resource.Drawable.foo);
To instead, inline the integer directly:
ImageView imageView = new(this);
imageView.SetImageResource(1234); // The actual integer here *is* final
The one known issue with this feature are Styleable
values like:
public partial class Styleable
{
public static int[] ActionBarLayout = new int[] { 16842931 };
}
Replacing int[]
values is not currently supported, which made it notsomething we can enable by default. Some apps will be able to turn onthis feature, the dotnet new maui
template and perhaps many .NETMAUI Android applications would not run into this limitation.
In a future .NET release, we may be able to enable$(AndroidLinkResources)
by default, or perhaps redesign thingsentirely.
See xamarin-android#5317, xamarin-android#6696, anddotnet/maui#4912 for details about this feature.
R8 Java Code Shrinker
R8 is whole-program optimization,shrinking and minification tool that converts java byte code tooptimized dex code. R8
uses the Proguard
keep rule format forspecifying the entry points for an application. As you might expect,many applications require additional Proguard
rules to keep thingsworking. R8
can be too aggressive, and remove something that iscalled by Java reflection, etc. We don’t yet have a good approach formaking this the default across all .NET Android applications.
To opt into using R8
for Release
builds, add the following to your.csproj
:
<!-- NOTE: not recommended for Debug builds! -->
<AndroidLinkTool Condition="'$(Configuration)' == 'Release'">r8</AndroidLinkTool>
If launching a Release
build of your application crashes afterenabling this, review adb logcat
output to see what went wrong.
If you see a java.lang.ClassNotFoundException
orjava.lang.MethodNotFoundException
, you may need to add aProguardConfiguration
file to your project such as:
<ItemGroup>
<ProguardConfiguration Include="proguard.cfg" />
</ItemGroup>
-keep class com.thepackage.TheClassYouWantToPreserve { *; <init>(...); }
We are investigating options to enable R8
by default in a future.NET release.
See our documentation on D8/R8 for details.
AOT Everything
Profiled AOT is the default, because it gives the best tradeoffbetween app size and startup performance. If app size is not a concernfor your application, you might consider using AOT for all .NETassemblies.
To opt into this, add the following to your .csproj
for Release
configurations:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<RunAOTCompilation>true</RunAOTCompilation>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
</PropertyGroup>
This will reduce the amount of JIT compilation that happens duringstartup in your application, as well as navigation to later screens,etc.
AOT and LLVM
LLVM provides a modern source- andtarget-independent optimizer that can be combined with Mono AOTCompiler output. The result is a slightly larger app size and longerRelease
build times, with better runtime performance.
To opt into using LLVM for Release
builds, add the following to your.csproj
:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<RunAOTCompilation>true</RunAOTCompilation>
<EnableLLVM>true</EnableLLVM>
</PropertyGroup>
This feature can be used in combination with Profiled AOT (or AOT-ingeverything). Compare your application before & after to know whatimpact EnableLLVM
has on your application size and startupperformance.
Currently, an Android NDK is required to be installed to use thisfeature. If we can solve this requirement, EnableLLVM
could becomethe default in a future .NET Release.
See our documentation on EnableLLVM
for details.
Record a Custom AOT Profile
Profiled AOT by default uses “built-in” profiles that we ship in .NETMAUI and Android workloads to be useful for most applications. To getthe optimal startup performance, you ideally would record a profilespecific to your application. We have an experimentalMono.Profiler.Android package for this scenario.
To record a profile:
dotnet add package Mono.AotProfiler.Android
dotnet build -t:BuildAndStartAotProfiling
# Wait until app launches, or you navigate to a screen
dotnet build -t:FinishAotProfiling
This will produce a custom.aprof
in your project directory. To useit for future builds:
<ItemGroup>
<AndroidAotProfile Include="custom.aprof" />
</ItemGroup>
We are working on full support for recording custom profiles in afuture .NET release.
Conclusion
I hope you’ve enjoyed our .NET MAUI performance treatise. You shouldcertainly pat yourself on the back if you made it this far.
Please try out .NET MAUI, file issues, or learn more athttp://dot.net/maui!
17 comments
Good enhanced performance.
Awesome !! These details will definitely help developers enforce best practices while building apps with MAUI
Great overview of the work that went into it, thanks for sharing.
Gains in developer productivity you call it, but actually every second/third feature of MAUI is “trip and fall” for a developer. The largest gain would be if all the features released in MAUI actually worked.
What is 0.5 sec saved on startup for developer when he spends 10 hours messing with a feature which doesn’t work in the end (unexpected when it’s production ready release)?
The largest downgrade to developer productivity is assuming that a framework works while does not and you must find workarounds or skip using the feature entirely because there is no workaround because simply the feature even isn’t finished.
With responsibility for what you created the MAUI team should learn from DevExpress! Their product always comes out 100 % implemented and then they accept user improvements in very fast iterations!
And if a corner case is discovered in a feature, they release a fix in days. Compared to MAUI which is months or maybe never.
Currently there are many cases where an API on a platform does nothing but documented as working.
E.g. MAUI Essentials APIs – taking photos on Windows does nothing (btw hasn’t worked in WinUI for 1 year!), Connectivity event handler for Windows crashes the app.
Processing images using Maui.Graphics on Windows does nothing because they cannot be loaded as IImage (not fully implemented)!
Application icon and splashscreen is buggy/broken and properties for customizing size and aspect ratio aren’t working on both Android and Windows.
How can this be a full release? Has the developmen team even tried out the APIs after they have merged it into the main branch?
How can you release a feature that does not work – taking look at the publicly available source code and seeing only parts implemented? I don’t understand this behavior with MAUI.
MAUI is a unification of issues and bugs from all platforms which it includes.
This framework is not ready for production at all because when you try use something, half of things aren’t working as documented! This is not a behavior of a production ready framework but an alpha version of a framework!
MAUI team has a serious lack of developers considering the amount of issues and their status on github – mostly only tags are added and no issues are being solved. Even the simple ones with 100% reproduction, sample project provided exist for 2 months or more without any progress. Reminds me of Xamarin
The problem is that when you give Xamarin new name, the issues aren’t automagically gone.
100% agree, but J.P.’s specialization is a bit different from completing the MAUI features -> https://github.com/jonathanpeppers
I also agree with you 100%, I consider them a beautiful team but lack manpower and I can not understand why Microsoft does not invest more in this, meanwhile Flutter already has all this ready in less years of Xamarin / Maui and all free.
Agree 100%. I’m stopped using it, just tired from bugs. Can’t finish even simple app, even basic controls as Scrollview are buggy (
Hi Jan, most of the folks that contributed to this effort were not the same engineers that would complete features in MAUI. We also paused performance work after MAUI RC 1 shipped, as to give as much priority as possible towards stability & bug fixes in the remaining time up to GA.
This is just the first release — I’m pretty confident things can keep getting better in future releases. Keep filing issues, thanks!
Hi Jonathan, thank you, I appreciate that (your and your team’s work), being myself always focused on the performance of our apps as well.
I see now – it’s two teams. Then I hope that the “MAUI features” team will get more people in.
I am filling issues extensively but I think that is not enough to get them solved because for 2 months I’ve been seeing mostly only new and new issues are created and sorted but the team is not solving them because there are too many issues.
Actually, the only complex issue which is being solved is the app icon and app splashscreen now and it took 1 month before someone started working on it (mattleibow). And also I think it is currently the only complex reported issue which is being worked on (at least what I can see in public GitHub issues on the MAUI repo).
Also the issues from Maui.Graphics are remaining stale. From what I can see from releases of Maui.Graphics, last changes have been actually made in late november 2021 when it was probably decided that MAUI will not ship with .NET 6 and since then abandoned. The only activity on Maui.Graphics in the last 6 months has been a week ago when they decided to merge it with MAUI repo.
I agree, it was fast to declare MAUI ready for production, but I saw exponential improvement since. So, I wish the MAUI team will keep up with the bugs introduced by the interest they raised. It is a challenge I hope they will tackle. Cheers!
Awesome blog as always Jon, I think its going to take me at least 3 reads to fully process everything!
I’m looking forward to seeing these performance improvements in my MAUI and Xamarin apps.
Hello, it might be not question for you Jon, but this is topic about performance on Android.
What can be done to increase performance of CollectionView and ListView ? There are issues about this on Xamarin github (few years without answer) and MAUI has the same problem. I’ve read a lot about this topic and available ways to improve it but nothing really works. If you put 100 items in CollectionView (Cell recycle is ON) with 2 labels in each row, its lagging while scrolling on android.
Now we have
dotnet-trace
support, I’ve found a few things like:https://github.com/dotnet/maui/pull/7996
This should help the general performance of any
Label
.I still want to profile your scenario and see what I find. If you could file an issue with a sample project (@ mention me), that would help a lot. Thanks!
https://github.com/dotnet/maui/issues/8012
Thank you.
This article is awesome. Some parts of it should be integrated in Docs for net-android and MAUI
I am using MVVM and after this last update (June 2022) I noticed that some elements in Android UI would no longer update correctly
Report an issue for every of these problems to the maui github repository