Performance Improvements in .NET MAUI

Jonathan Peppers

.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:

ApplicationFrameworkStartup Time(ms)
Xamarin.AndroidXamarin306.5
Xamarin.FormsXamarin498.6
Xamarin.Forms (Shell)Xamarin817.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:

ApplicationFrameworkStartup Time(ms)
Xamarin.AndroidXamarin306.5
Xamarin.FormsXamarin498.6
Xamarin.Forms (Shell)Xamarin817.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

App Size Improvements

Improvements in the .NET Podcast Sample

Experimental or Advanced Options

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:

speedscope view

speedscope view

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:

graph of startup .NET MAUI template

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):

graph of startup .NET Podcast sample

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:

graph of app size .NET Podcast sample

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:

ApplicationJIT Time(ms)AOT Time(ms)
dotnet new maui1078.0ms683.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 Releasebuilds 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 Handleof 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:

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:

MethodMeanErrorStdDevGen 0Allocated
Border (before)323.2 µs0.82 µs0.68 µs0.97665 KB
Border (after)242.3 µs1.34 µs1.25 µs0.97665 KB
ContentView (before)354.6 µs2.61 µs2.31 µs1.46486 KB
ContentView (after)258.3 µs0.49 µs0.43 µs1.46486 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:

MethodMeanErrorStdDevAllocated
Java338.4 µs4.21 µs3.52 µs744 B
CSharp410.2 µs7.92 µs6.61 µs1,336 B
XML490.0 µs7.77 µs7.27 µs2,321 B

Next, we configured BenchmarkDotNet to do a single run, to bettersimulate what would happen on startup:

MethodMean
Java4.619 ms
CSharp37.337 ms
XML39.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:

  1. Saving AndroidAsset files to a temp folder.
  2. 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:

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.

MethodMeanErrorStdDevGen 0Allocated
Parse (before)99.13 ns0.281 ns0.235 ns0.0267168 B
Parse (after)52.54 ns0.292 ns0.259 ns0.005132 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’s EmbeddedFontLoaderuntil it is needed.
  • Don’t use ILoggerFactory to create a generic logger. Instead getthe ILogger 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 Releasebuilds.

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_loadsetting was introduced in Mono that the Android workload could optinto. We found this improved the startup of a dotnet new mauiproject 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 DataContractSerializerresulted 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 ShowsServicewas 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 Releaseconfigurations:

<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!