Premise
Memory leaks in .NET MAUI can occur in various ways, with event
s being a common culprit. This article will focus on memory leaks caused by event
s, 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 byBinding
)INotifyCollectionChanged
interface →CollectionChanged
event (used byItemsSource
)ICommand
interface →CanExecuteChanged
event (used byCommand
)
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 toCompleteCommand
due to the fact that we’ve set theCommand
bindable property value to itCompleteCommand.CanExecuteChanged
event now holds a strong reference toButton
in order to notify changes tobool 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 event
s.
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.