Preventing Memory Leaks in .NET MAUI: Best Practices and Strategies

Photo by Daan Mooij on Unsplash

Preventing Memory Leaks in .NET MAUI: Best Practices and Strategies

Premise

Memory leaks in .NET MAUI can occur in various ways, with events being a common culprit. This article will focus on memory leaks caused by events, while also highlighting concepts applicable to other scenarios.

.NET events in general

We all know how to define and use an event in C#.

// Definition
public event EventHandler MyEvent;

// Subscription
myObject.MyEvent += mySubscriber.MyDelegate;

// Unsubscription
myObject.MyEvent -= mySubscriber.MyDelegate;

What we should keep in mind is that every time we subscribe to an event as shown above we’re creating a strong reference from event source (myObject) to the delegate (mySubscriber.MyDelegate) which in this example includes the mySubscriber object.

MAUI common events

This language feature is being used many times in MAUI on key parts of what we used every day:

  • INotifyPropertyChanged interface → PropertyChanged event (used by Binding)

  • INotifyCollectionChanged interface → CollectionChanged event (used by ItemsSource)

  • ICommand interface → CanExecuteChanged event (used by Command)

So every time we use those features we should pay attention to what we do.

How to easily cause a memory leak without realizing it

Let’s take as example the ICommand use case by using the RelayCommand provided by the MVVM toolkit.

[RelayCommand]
public async Task CompleteAsync() { }

We can tie the command to our button via XAML Binding.

<Button Command="{Binding CompleteCommand}" Text="Complete!" />

What’s happening here?

  • Button now has a strong reference to CompleteCommand due to the fact that we’ve set the Command bindable property value to it

  • CompleteCommand.CanExecuteChanged event now holds a strong reference to Button in order to notify changes to bool CanExecute (object? parameter)

Let's celebrate: we've unintentionally created a circular reference!

Having a circular reference doesn’t mean we’re automatically creating a memory leak, because the garbage collector (GC) is perfectly able to handle them.

When nothing references both the view model and the page (therefore the Button), the garbage collector will collect everything.

What if our view model lives longer than the page?

Suppose you have a “To do” app where each item view model is being created and hosted by a singleton service.

Your page view model will look like this:

public class TodoListPageModel(ITodoService service)
{
    public ObservableCollection TodoList => service.List;
}

Now even if you pop the page, the Button inside would still be referenced by the singleton TodoItemViewModel exposing the CompleteCommand.

This is when the memory leak happens.

How to prevent the memory leak

Using a WeakEventManager (better safe than sorry)

While this looks like a smart solution, it comes with a cost: performance and predictability.

Looking at MAUI Command implementation we can clearly see that it uses this WeakEventManager under the hood together with the language feature that lets you customize events.

readonly WeakEventManager _weakEventManager = new WeakEventManager();

public event EventHandler CanExecuteChanged
{
    add { _weakEventManager.AddEventHandler(value); }
    remove { _weakEventManager.RemoveEventHandler(value); }
}

A WeakEventManager is a C# implementation of an event manager which uses WeakReference to hold the subscription reference.

If a subscriber is collected by GC, the next event being triggered will automatically clean invalid subscriptions.

This strategy ensures we have no memory leaks at the cost of using more memory and more CPU.

Another downside is that it needs to be implemented where the event is defined which may be out of our control (i.e. third party libraries like CommunityToolkit).

Manually clearing our subscriptions (with great power comes great responsibility)

Given that CommunityToolkit RelayCommand is not using WeakEventManager by design we have to manually clear our bindings manually.

That basically comes down to setting BindingContext to null after the page is gone.
That will propagate null to all descendants and it will clean all the bound commands and properties without having to worry about leaks.

The timing of page is gone depends on the navigation system you’re using.

Nalu.Maui.Navigation offers a leak detection system which is very helpful to identify these situations and also a mechanism to properly dispose pages.

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .UseNaluNavigation<App>(
            configurator => configurator
                .WithLeakDetectorState(NavigationLeakDetectorState.EnabledWithDebugger)
        )

Nalu.Maui.Navigation creates an IServiceScope for each page, ensuring that all scoped services (including the Page) will be disposed when the page is removed from the navigation stack.

In this case we can simply inherit from IDisposable and clear our binding context:

public sealed partial class MyPage : ContentPage, IDisposable
{
    public MyPage(IMyModel myModel)
    {
        InitializeComponent();
        BindingContext = myModel;
    }

    public void Dispose()
    {
        BindingContext = null;
    }
}

Additional documentation to debug and prevent memory leaks

The documentation of .NET MAUI offers an entire page related to memory leaks telling us:

  • How to analyze leaks by collecting gcdump

  • How to prevent leaks at platform level (especially iOS)

My personal suggestion on the latter is to use weak references to reference the virtual view from the platform one.