dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.18k stars 4.72k forks source link

[API Proposal]: Add weak event listener helper class #61517

Open dotMorten opened 2 years ago

dotMorten commented 2 years ago

Background and motivation

It's quite common in MVVM scenarios, that you will bind your models and viewmodels to UI controls. These UI controls will often listen for PropertyChanged and CollectionChanged events to update the UI dynamically. However since the UI views are quite often transient but the model data is long lived, you often see very expensive UI controls getting stuck in memory due to the event handler not getting unsubscribed. This problem is of course not limited to UI components only, but where you'll often see large costs associated with not unsubscribing from the events.

A typical pattern for this is to use a helper class to subscribe to weak event handlers, and over and over again we see different implementations of this in various libraries. So much so recently .NET MAUI decided to graduate their toolkit helper to the main MAUI library. This got me thinking that since this is such a common scenario, that .NET should provide this at a lower level for all libraries to use.

Earlier I had suggested that this should be a language feature, but it was concluded this should be an API feature: https://github.com/dotnet/roslyn/issues/101

Example of various implementations:

API Proposal

I think there are quite a few different approaches that can be taken, as shown with all the implementations above. I'd refer to the .NET MAUI example to start with:

public void AddEventHandler(Delegate? handler, [CallerMemberName] string eventName = null)
{
    ArgumentNullException.ThrowIfNull(eventName);
    ArgumentNullException.ThrowIfNull(handler)

    var methodInfo = handler.GetMethodInfo() ?? throw new NullReferenceException("Could not locate MethodInfo");

    AddEventHandler(eventName, handler.Target, methodInfo, eventHandlers);
}

public void RemoveEventHandler(Delegate? handler, [CallerMemberName] string eventName = "")
{
    ArgumentNullException.ThrowIfNull(eventName);
    ArgumentNullException.ThrowIfNull(handler)

    var methodInfo = handler.GetMethodInfo() ?? throw new NullReferenceException("Could not locate MethodInfo");

    RemoveEventHandler(eventName, handler.Target, methodInfo, eventHandlers);
}

API Usage

readonly WeakEventManager weakEventManager = new WeakEventManager();

event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
{
    add => weakEventManager.AddEventHandler(value);
    remove => weakEventManager.RemoveEventHandler(value);
}

void OnPropertyChanged([CallerMemberName] in string propertyName = "") => weakEventManager.HandleEvent(this, new PropertyChangedEventArgs(propertyName), nameof(INotifyPropertyChanged.PropertyChanged));

Alternative Designs

Windows Community Toolkit: WeakEventListener.cs

var weakEvent = new WeakEventListener<INotifyCollectionChanged, object, NotifyCollectionChangedEventArgs>(valNotifyCollection)
{
    OnEventAction = (instance, source, args) => obj.SetActive(IsNullOrEmpty(instance)),
    OnDetachAction = (weakEventListener) => valNotifyCollection.CollectionChanged -= weakEventListener.OnEvent
};

Risks

No response

dotnet-issue-labeler[bot] commented 2 years ago

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

terrajobst commented 2 years ago

@Redth @davidortinau @jamesmontemagno

huoyaoyuan commented 2 years ago

I remember there is weak event helper in WPF assemblies.

The weak event listener should get better integration with GC. For example, it should not require explicit clean up, and should clean up automatically when GC triggers.

jamesmontemagno commented 2 years ago

I like it and would be a great feature for library creators

ghost commented 2 years ago

Tagging subscribers to this area: @dotnet/area-system-runtime See info in area-owners.md if you want to be subscribed.

Issue Details
### Background and motivation It's quite common in MVVM scenarios, that you will bind your models and viewmodels to UI controls. These UI controls will often listen for PropertyChanged and CollectionChanged events to update the UI dynamically. However since the UI views are quite often transient but the model data is long lived, you often see very expensive UI controls getting stuck in memory due to the event handler not getting unsubscribed. This problem is of course not limited to UI components only, but where you'll often see large costs associated with not unsubscribing from the events. A typical pattern for this is to use a helper class to subscribe to weak event handlers, and over and over again we see different implementations of this in various libraries. So much so [recently .NET MAUI decided to graduate their toolkit helper to the main MAUI library](https://github.com/dotnet/maui/issues/2703#issuecomment-966312097). This got me thinking that since this is such a common scenario, that .NET should provide this at a lower level for all libraries to use. Earlier I had suggested that this should be a language feature, but it was concluded this should be an API feature: https://github.com/dotnet/roslyn/issues/101 Example of various implementations: - [.NET MAUI](https://github.com/dotnet/maui/blob/60789a501229c10dcc388a26767ce3baef6bb1c9/src/Controls/src/Core/WeakEventManager.cs) - [Xamarin.Forms](https://github.com/xamarin/Xamarin.Forms/blob/02c4532c8498bead280803ba6a166ec46efa5014/Xamarin.Forms.Core/WeakEventManager.cs) - [Windows Community Toolkit](https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/b20c6ce1665fe04e411c5cf8c0da18b580bde71c/Microsoft.Toolkit.Uwp/Helpers/WeakEventListener.cs) - [Xamarin.Forms Community Toolkit](https://github.com/xamarin/XamarinCommunityToolkit/blob/2b1c7a10a30f71b801550f88d8c165b7eda23112/src/CommunityToolkit/Xamarin.CommunityToolkit/Helpers/WeakEventManager.shared.cs) - [Telerik UI for UWP](https://github.com/telerik/UI-For-UWP/blob/9911c4e88a0f66593d79bf36e506183c2b0c47ea/Controls/Core.UWP/WeakEvents/WeakEventHandler.cs) - [ArcGIS Runtime Toolkit](https://github.com/Esri/arcgis-toolkit-dotnet/blob/bd93da6d33551da85f5d5dd8ab219de8b69ebcd8/src/Toolkit/Toolkit/WeakEventListener.cs) - A [search for `WeakEventListener` on Github](https://github.com/search?l=C%23&q=weakeventlistener&type=Code) reveals C# is quite common to have this: ![image](https://user-images.githubusercontent.com/1378165/141506841-425c174f-3413-44a7-9cb5-7d4f94ade9e1.png) - Same when [searching for `WeakEvent`](https://github.com/search?l=C%23&q=weakevent&type=Code) ![image](https://user-images.githubusercontent.com/1378165/141507007-f0f07c8f-d9a2-442e-b668-4ccf19bf6790.png) ### API Proposal I think there are quite a few different approaches that can be taken, as shown with all the implementations above. I'd refer to the .NET MAUI example to start with: ```C# public void AddEventHandler(Delegate? handler, [CallerMemberName] string eventName = null) { ArgumentNullException.ThrowIfNull(eventName); ArgumentNullException.ThrowIfNull(handler) var methodInfo = handler.GetMethodInfo() ?? throw new NullReferenceException("Could not locate MethodInfo"); AddEventHandler(eventName, handler.Target, methodInfo, eventHandlers); } public void RemoveEventHandler(Delegate? handler, [CallerMemberName] string eventName = "") { ArgumentNullException.ThrowIfNull(eventName); ArgumentNullException.ThrowIfNull(handler) var methodInfo = handler.GetMethodInfo() ?? throw new NullReferenceException("Could not locate MethodInfo"); RemoveEventHandler(eventName, handler.Target, methodInfo, eventHandlers); } ``` ### API Usage ```cs readonly WeakEventManager weakEventManager = new WeakEventManager(); event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged { add => weakEventManager.AddEventHandler(value); remove => weakEventManager.RemoveEventHandler(value); } void OnPropertyChanged([CallerMemberName] in string propertyName = "") => weakEventManager.HandleEvent(this, new PropertyChangedEventArgs(propertyName), nameof(INotifyPropertyChanged.PropertyChanged)); ``` ### Alternative Designs Windows Community Toolkit: [WeakEventListener.cs](https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/2b124ff5e7ebc2061f64a3dff7953f1c1f548807/Microsoft.Toolkit.Uwp.UI.Controls.DataGrid/Utilities/WeakEventListener.cs) ```cs var weakEvent = new WeakEventListener(valNotifyCollection) { OnEventAction = (instance, source, args) => obj.SetActive(IsNullOrEmpty(instance)), OnDetachAction = (weakEventListener) => valNotifyCollection.CollectionChanged -= weakEventListener.OnEvent }; ``` ### Risks _No response_
Author: dotMorten
Assignees: -
Labels: `api-suggestion`, `area-System.Runtime`, `untriaged`
Milestone: -
hawkerm commented 1 year ago

+1 Related to https://github.com/dotnet/runtime/issues/18645 as well.

michael-hawker commented 1 year ago

Looked more into this a bit from our Windows Community Toolkit code vs. the MAUI one recently.

I think both APIs are required (or a solution to the problems they're trying to solve). They're not 'alternate' or competing implementations, they both try to solve the problem from different sides of the event syntax. One is used when you own the event code and want to prevent others from capturing a reference to you. The other is used when you don't own the event, and don't want your code to capture the reference (in the provided example of a ViewModel capturing a UI object for instance this is usually the case). That is to say:

  1. On one side, like the MAUI one, it's at the implementation level of the event itself and someone owning that code, such that anyone subscribing to the event don't capture the reference to the instance.

  2. On the other side, like the WCT one, it's at the reference level of someone wanting to subscribe to the event without capturing the instance. This is useful for classes from the framework where the code isn't owned and using the pattern above.

So, I think both patterns are required for different scenarios? Don't think there's a singular API that can support both scenarios easily?

It could be nice if there was just some syntactic sugar for both sides to make either of these scenarios work... like on the implementors side to prevent being captured have it be declared as a weak event:

public weak event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged;

Or also on the subscribing side:

object.PropertyChanged ~= HandlePropertyChanged;

(Just picked ~ here as it's not commonly used and represents a weaker relationship, but it could be anything.)

All the extra overhead of not capturing references would then just be handled by the runtime without the extra overhead (and risk for mis-implementation of the pattern, which is error prone) on the developer. This is a hard pattern to understand, nuanced, hard to detect when done wrong, and easy to mis-code.

It could be also better optimized in the case that a weak event subscriber tries to register to an already weak event this way.

But at least there'd be a way for either side of an event to not capture a reference and have it be weak without the other side having to do anything or a bunch of complex patterns and knowledge required outside of 'wanting a weak reference'.

michael-hawker commented 1 year ago

Wanted to provide the current WeakEventListener pattern from our implementation here as well, as the example in the post doesn't really convey how it works end-to-end:

var inpc = rowGroupInfo.CollectionViewGroup as INotifyPropertyChanged;
var weakPropertyChangedListener = new WeakEventListener<DataGrid, object, PropertyChangedEventArgs>(this) {
    OnEventAction = static (instance, source, eventArgs) => instance.CollectionViewGroup_PropertyChanged(source, eventArgs),
    OnDetachAction = (weakEventListener) => inpc.PropertyChanged -= weakEventListener.OnEvent // Use Local References Only
}
inpc.PropertyChanged += weakPropertyChangedListener.OnEvent;
jonathanpeppers commented 1 year ago

I've been looking into some performance implications of MAUI's WeakEventManager.

Take for example usage, like in Application.RequestedThemeChanged.

public event EventHandler<AppThemeChangedEventArgs> RequestedThemeChanged
{
    add => _weakEventManager.AddEventHandler(value);
    remove => _weakEventManager.RemoveEventHandler(value);
}

This event fires when Dark or Light mode changes on each platform, and you can data-bind the result via the {AppThemeBinding} markup extension. I think the idea to use this on Application.RequestedThemeChanged, was to be helpful so that developers wouldn't as easily create memory leaks, as Application lives as long as the process.

However, what happens in practice is that:

A general "weak event" pattern in .NET would be nice here.

But I would be more interested to know how it could be implemented in a performant way.

If the design is a new C# compiler feature like object.PropertyChanged ~= HandlePropertyChanged;. What does the C# compiler emit?

Or is this only possible to implement when combined with a new runtime feature?

jbe2277 commented 1 year ago

@jonathanpeppers I'm wondering if a weak subscribing side approach has a better performance than a weak publisher (implementor) side as your example from MAUI.

I have implemented the weak subscribing side approach here which works very similar to the WPF WeakEventManager:

I believe it might be faster than the MAUI implementation because it does not use reflection or open delegates.

Example usage for PropertyChanged event:

public class Publisher : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
}

public class Subscriber
{
    public void Init(Publisher publisher)
    {
        // Instead of publisher.PropertyChanged += Handler; use the following statement:
        WeakEvent.PropertyChanged.Add(publisher, Handler)        
    }

    public void Handler(object? sender, PropertyChangedEventArgs e) { }
}

More details can be found on this Wiki page Weak Event.

jonathanpeppers commented 1 year ago

One of the issues I see, it seems every implementation involves two WeakReference<T> and an intermediate object (that may or may not include a finalizer).

I don't see how to make "weak events" in the ~same performance/ballpark as a vanilla C# event.