Following these five tips will help you create .NET MAUI apps that more users can enjoy without performance issues.
In the world of mobile development, it is very important to always consider that not all users have high-end phones. Therefore, it is advisable to follow best practices to avoid bottlenecks or issues that may affect the performance of applications.
In this article, I will share five considerations you must follow when developing applications with .NET MAUI.
Undoubtedly, one of the most common mistakes made in mobile development in general is using the framework controls improperly, combining elements in ways we shouldn’t. It’s like trying to eat a steak with a saw: while we may eventually achieve the end goal of cutting and eating it, it would likely take much more time and be uncomfortable and, above all, there are better tools that allow us to do it more effectively.
Image created with ChatGPT
One of the first performance issues that a .NET MAUI developer encounters is when using nested Stack layouts. Internally, these layouts perform a series of recalculations every time a change occurs in the graphical interface. For example, when a new element is added to the stack or when one of them is hidden via the IsVisible
property.
As you can imagine, if there are nested Stacks and a change occurs in any of them, the space of the child Stack elements and the parents must be recalculated, resulting in a degradation in performance. The solution to this problem is to replace the nested StackLayout
with more efficient layouts for this task, such as a grid.
An example of nested StackLayout
elements is as follows:
<VerticalStackLayout Padding="10">
<HorizontalStackLayout Spacing="5">
<Image
HeightRequest="40"
Source="dotnet_bot.png"
WidthRequest="40" />
<VerticalStackLayout>
<Label Text="Full Name" />
<Label
FontSize="Small"
Text="Role"
TextColor="Gray" />
</VerticalStackLayout>
</HorizontalStackLayout>
<Button Text="Follow" />
</VerticalStackLayout>
This can be optimized using a Grid as shown below:
<Grid
Padding="10"
ColumnDefinitions="40, *"
ColumnSpacing="10"
RowDefinitions="20, 30, 50"
RowSpacing="5">
<Image Grid.RowSpan="2" Source="dotnet_bot.png" />
<Label
Grid.Column="1"
FontAttributes="Bold"
Text="Full Name" />
<Label
Grid.Row="1"
Grid.Column="1"
FontSize="Small"
Text="Role"
TextColor="Gray" />
<Button
Grid.Row="2"
Grid.ColumnSpan="2"
Text="Follow" />
</Grid>
Another common problem when working with layouts is attempting to nest controls designed to scroll without restrictions, such as CollectionView
or ScrollView
, within any type of StackLayout
, as shown in the following example:
<VerticalStackLayout>
<CollectionView...>
</CollectionView>
</VerticalStackLayout>
This is because the StackLayout
is designed to not require a size from its child elements; it simply gives them the space they need. The problem is that controls like CollectionView
and ScrollView
request the entire available size, preventing the StackLayout from correctly determining the space it should provide. The solution is simple: avoid this type of nesting in the UI.
While creating your cross-platform applications, you may find yourself in a situation where you want to add tap detection to a control that does not have it natively, such as an Image
control. In those cases, the TapGestureRecognizer
class comes to the rescue. The problem arises when a developer adds multiple nested TapGestureRecognizer
elements, as in the following example:
<Grid>
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding OuterTapCommand}" />
</Grid.GestureRecognizers>
<Image...>
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding InnerTapCommand}" />
</Image.GestureRecognizers>
</Image>
</Grid>
Due to the propagation nature of these elements, when a user taps an element on the screen, the propagation begins from the highest hierarchical level and moves down through the visual tree, looking for the ideal control to handle the action. However, if two or more controls in the hierarchy have TapGestureRecognizer
elements, a conflict occurs, as it is not easy to determine which control should handle the event.
The recommendation is to avoid using gesture recognizers on nested elements if the parent layout or control already has one assigned.
In the mobile world, there is something called the activation period, which is the time between when the application starts and when it is ready to use. We should try to minimize it as much as possible so that the user can start working with the app as quickly as possible.
Image created with ChatGPT
There are several ways to optimize the activation period. For example, a common mistake when working with lists in .NET MAUI is consuming a REST service that retrieves all records at once. This process can take a lot of time and waste unnecessary resources as the collection size increases.
One possible solution for these cases is to load only the first records that need to be displayed, allowing the user to load the other records on demand and in parts. Some controls like the Progress Telerik CollectionView allow for native on-demand data loading in .NET MAUI, making this task easier.
Similarly, if you need to load more records, you can use a control connected to your list control with a Shimmer effect like Maui.Skeleton while the information is loading, which will let the user know that the loading process is happening without blocking the graphical interface.
Image credit: https://github.com/horussoftware
A solution for other types of controls that need to retrieve data or files externally could be to add embedded files within the application that serve as placeholders while the final data is being loaded, allowing for much faster loading.
As a last resort, if you have to load a lot of information when starting the application, make sure to show loader animations so that the user knows that something is happening behind the scenes.
Finally, although using a dependency injection container provides a better way to manage references, we need to be cautious when using them as they add a layer of complexity by using reflection to create object instances.
In .NET MAUI particularly, this can negatively impact startup if there are nested dependencies or if these dependencies are reconstructed on each navigation between pages. One solution is to use manual factories, as shown in the following example:
MessageServiceFactory.cs
public static class MessageServiceFactory
{
private static IMessageService? _instance;
public static IMessageService Create()
=> _instance ??= new MessageService();
}
MainPage.xaml.cs
private void OnShowMessageClicked(object sender, EventArgs e)
{
var service = MessageServiceFactory.Instance;
lblMessage.Text = service.GetMessage();
}
With the above code, we avoid using reflection, which can help improve application initialization.
There are several dangers we face if we do not optimize the use of resources on mobile devices. Some of the most common are battery drain, freezing of the graphical interface, or even, in a more extreme case, the application’s closure by the operating system. This generally causes a horrible user experience, and we must avoid it at all costs.
Image created with ChatGPT
A good practice we should follow in general development with .NET is to wrap IDisposable
objects in using
sections to release resources when they are no longer needed, such as files, streams, etc., as shown below:
using (var writer = File.CreateText(filePath))
{
await writer.WriteLineAsync("Hello from .NET MAUI!");
}
Another thing you should avoid is saturating global resource dictionaries with resources that we do not share at the application level, but rather try to keep at the page level those that are only used on that page.
A common mistake is subscribing to events from another class or to events from controls outside the page lifecycle, which leads to the creation of strong references that prevent proper garbage collection.
The ideal in these cases is to unsubscribe classes when they are no longer used (for example, when leaving the page). An alternative is to use weak references. For instance, if we wanted to communicate between two objects, we could use MVVM Toolkit to avoid memory leaks, provide good performance and achieve natural decoupling, as shown below:
Sender.cs
public class Sender
{
public void Send(string text)
=> WeakReferenceMessenger.Default.Send(new TextNotification(text));
}
Receiver.cs
public class Receiver
{
public Receiver()
{
WeakReferenceMessenger.Default.Register<TextNotification>(
this,
(r, msg) =>
{
Console.WriteLine($"Receiver got: {msg.Value}");
});
}
}
It is also recommended to delay the creation of objects until they are used, which translates into performance improvement. This can be achieved using the Lazy<T>
type, which allows the initialization of an object upon accessing the Value
property. If you do not want to use the Lazy<T>
object, you can use Func<T>
, registering it in the .NET MAUI dependency container as shown below:
MauiProgram.cs
builder.Services.AddSingleton<IHeavyService, HeavyService>();
builder.Services.AddSingleton<Func<Task<IHeavyService>>>(sp =>
async () =>
{
var svc = sp.GetRequiredService<IHeavyService>();
await svc.InitializeAsync();
return svc;
});
MainViewModel.cs
public partial class MainViewModel : ObservableObject
{
private readonly Func<Task<IHeavyService>> _getHeavy;
public MainViewModel(Func<Task<IHeavyService>> getHeavy)
=> _getHeavy = getHeavy;
[RelayCommand]
public async Task ExecuteHeavyTaskAsync()
{
var svc = await _getHeavy();
await svc.DoWorkAsync();
}
}
Lastly, it is possible to use a linker called ILLink
that removes methods, properties, fields, etc., that are not used in the application, allowing for a smaller application size. This is available for Android, iOS and Mac Catalyst.
One crucial point to avoid giving users a bad experience when creating applications is to make good use of asynchrony.
Image created with ChatGPT
One of the first things to consider is to avoid doing heavy work on the UI thread by offloading it to a background thread so that the UI isn’t blocked.
For example, I once encountered a situation where I needed to use an external library that allowed me to generate PDF documents from an Excel file; however, all the APIs were synchronous, and I didn’t have access to modify the code. When testing the application, since the synchronous method took several seconds to create the document, the UI froze.
One way to solve this problem is to use a Task.Run
block to execute the synchronous operations on a thread pool thread, which will prevent the UI from freezing, as shown below:
await Task.Run(() =>
{
PdfGenerator.GenerateFromExcel(excelPath, pdfPath);
});
Another recommendation is to group multiple consecutive calls to a service. For example, I had a case with a client who needed to generate audio files from paragraphs in a text document. Since it made one service call at a time, it took the application many minutes to process a single document.
The solution was to apply parallelism in the tasks, which saved time and freed the UI thread sooner. Below is an example of code illustrating this:
async Task ProcessParagraphAsync(string text, int index)
{
var audioBytes = await audioService.GenerateAudioFromTextAsync(text);
var outputPath = Path.Combine(outputFolder, $"paragraph_{index + 1}.mp3");
await File.WriteAllBytesAsync(outputPath, audioBytes);
}
var paragraphs = document.SplitIntoParagraphs();
var audioTasks = new List<Task>();
for (int i = 0; i < paragraphs.Count; i++)
{
audioTasks.Add(ProcessParagraphAsync(paragraphs[i], i));
}
await Task.WhenAll(audioTasks);
Similarly, it is advisable to use the MVVM Toolkit, as it internally uses the AsyncCommand
pattern for working with commands. This pattern has an internal state that allows you to know whether a task is being executed or not. Additionally, AsyncCommand
captures an error if one occurs and allows us to register a global handler or display an error message.
Finally, avoid blocking the UI yourself using mechanisms like Task.Wait
, Task.Result
or GetAwaiter().GetResult
, as these can cause deadlocks.
The last performance consideration for .NET MAUI applications is that you should carefully analyze which methods generate the most load on the processor as well as potential bottlenecks.
Image created with ChatGPT
The first recommendation is to always conduct thorough testing on mid-range and low-end phones to see how your application performs in high-demand scenarios.
Similarly, the .NET MAUI team has compiled a series of tools to profile .NET MAUI apps, such as dotnet-trace and PerfView. These tools will provide you with a better overview of potential bottlenecks in the application, as well as startup issues and memory leaks that may exist.
Finally, the .NET MAUI developer community has some interesting projects like the DotNet.Meteor project, a VS Code extension for running, debugging and profiling .NET MAUI applications. Additionally, you can also use the MemoryToolkit.Maui project to check for memory leaks in your .NET MAUI applications.
Throughout this article, you’ve learned five performance recommendations to keep in mind when developing applications with .NET MAUI. Follow these best practices, aim to put yourself in the user’s shoes and always strive to deliver the best possible user experience.
Héctor Pérez is a Microsoft MVP with more than 10 years of experience in software development. He is an independent consultant, working with business and government clients to achieve their goals. Additionally, he is an author of books and an instructor at El Camino Dev and Devs School.