The Microsoft.Toolkit.Mvvm
package (aka MVVM Toolkit) is finally out, so it's time to start planning for the next update π
This issue is meant to be for tracking planned features, discuss them and possibly propose new ideas well (just like in CommunityToolkit/WindowsCommunityToolkit#3428).
One aspect I want to investigate for this next release, which I think makes sense for the package, is support for Roslyn source generators. Support for these would be added through a secondary project, ie. Microsoft.Toolkit.Mvvm.SourceGenerators
, that would be shipped together with the MVVM Toolkit, but then added to consuming projects as a development dependency, ie. as an analyzer. They would be included in the same NuGet package, so it wouldn't be possible to just reference the source generator directly (which wouldn't make sense anyway). This is the same setup I'm using in my own library ComputeSharp, in fact I'm applying a lot of the things I learnt while working on that project to the MVVM Toolkit as well π₯³
There are two three main aspects that can be investigated through support for source generators:
- Better extensibility and composition (opt-in)
- Less verbosity (opt-in)
- Better performance (always on)
Here's some more details about what I mean by these two categories exactly.
Better extensibility
There's one "problem" with C# that has been brought up by devs before while using the MVVM Toolkit and in general (eg. here): lack for multiple inheritance. While that makes perfect sense and there's plenty of valid reasons why that's the case, the fact remains that it makes things sometimes inconvenient, when eg. you want/need to inherit from a specific type but then still want to use the features exposed by other classes in the MVVM Toolkit. The solution? Source generators! π
For now I'm thinking about adding the following attributes to the package:
[INotifyPropertyChanged]
[ObservableObject]
[ObservableRecipient]
The way they work is that they let you annotate a type and then rely on the generator injecting all the APIs from those types automatically, so you don't need to worry about it and it's like you were effectively having multiple inheritance. Here's an example:
[ObservableObject]
partial class MyViewModel : SomeOtherClass
{
}
This class now inherits from SomeOtherClass
, but it still has all the same APIs from ObservableObject
! This includes the PropertyChanged
and PropertyChanging
events, the methods to raise them and all the additional helper methods!
[ObservableRecipient]
does the same but copying members from ObservableRecipient
(eg. this could be used to effectively have a type that is both a validator but also a recipient), and [INotifyPropertyChanged]
instead offers minimal support just for INotifyPropertyChanged
, with optionally also the ability to include additional helpers or not. This approach and the different attributes offer maximum flexibility for users to choose the best way to construct their architecture without having to compromise between what APIs to use from the MVVM Toolkit and how they want/have to organize their type hierarchy. π
NOTE: this category is marked as "opt-in" because the attributes are completely optional. Not using them will have no changes at all on the behavior of the toolkit, so developers just wanting to inherit from the base types in the library as usual will absolutely still be able to do so. This just gives consumers more flexibility depending on their exact use case scenario.
Less verbosity
This was first suggested by @michael-hawker in this comment, the idea is to also provide helpers to reduce the code verbosity in simple cases, such as when defining classic observable properties. For now I've added these attributes:
[ObservableProperty]
[AlsoNotifyFor]
[ICommand]
The first two can be used to easily declare observable properties, by annotating a field. [ObservableProperty]
will create the code necessary to implement the property itself, whereas [AlsoNotifyFor]
will customize the generated code by adding extra notification events (ie. calls to OnPropertyChanged
) for properties whose value depends on the property being updated.
Here's an example of how these two attributes can be used together:
Viewmodel definition:
public sealed partial class PersonViewModel : ObservableObject
{
[ObservableProperty]
[AlsoNotifyFor(nameof(FullName))]
private string name;
[ObservableProperty]
[AlsoNotifyFor(nameof(FullName))]
private string surname;
public string FullName => $"{Name} {Surname}";
}
Generated code:
public sealed partial class PersonViewModel
{
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public string Name
{
get => name;
set
{
if (!global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(name, value))
{
OnPropertyChanging();
name = value;
OnPropertyChanged();
OnPropertyChanged("FullName");
}
}
}
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public string Surname
{
get => surname;
set
{
if (!global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(surname, value))
{
OnPropertyChanging();
surname = value;
OnPropertyChanged();
OnPropertyChanged("FullName");
}
}
}
}
There is also a brand new [ICommand]
attribute type, which can be used to easily create command properties over methods, by leveraging the relay command types in the MVVM Toolkit. The way this works is simple: just write a method with a valid signature for either RelayCommand
, RelayCommand<T>
, AsyncRelayCommand
or AsyncRelayCommand<T>
and add the [ICommand]
attribute over it - the generator will create a lazily initialized property with the right command type that will automatically wrap that method. Cancellation tokens for asynchronous commands are supported too! π
Here's an example of how this attribute can be used with four different command types:
Viewmodel definition:
public sealed partial class MyViewModel
{
[ICommand]
private void Greet()
{
}
[ICommand]
private void GreetUser(User user)
{
}
[ICommand]
private Task SaveFileAsync()
{
return Task.CompletedTask;
}
[ICommand]
private Task LogUserAsync(User user)
{
return Task.CompletedTask;
}
}
Generated code:
public sealed partial class MyViewModel
{
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
private global::Microsoft.Toolkit.Mvvm.Input.RelayCommand? greetCommand;
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::Microsoft.Toolkit.Mvvm.Input.IRelayCommand GreetCommand => greetCommand ??= new global::Microsoft.Toolkit.Mvvm.Input.RelayCommand(new global::System.Action(Greet));
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
private global::Microsoft.Toolkit.Mvvm.Input.RelayCommand<global::MyApp.User>? greetUserCommand;
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::Microsoft.Toolkit.Mvvm.Input.IRelayCommand<global::MyApp.User> GreetUserCommand => greetUserCommand ??= new global::Microsoft.Toolkit.Mvvm.Input.RelayCommand<global::UnitTests.Mvvm.Test_ICommandAttribute.User>(new global::System.Action<global::UnitTests.Mvvm.Test_ICommandAttribute.User>(GreetUser));
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
private global::Microsoft.Toolkit.Mvvm.Input.AsyncRelayCommand? saveFileAsyncCommand;
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::Microsoft.Toolkit.Mvvm.Input.IAsyncRelayCommand SaveFileAsyncCommand => saveFileAsyncCommand ??= new global::Microsoft.Toolkit.Mvvm.Input.AsyncRelayCommand(new global::System.Func<global::System.Threading.Tasks.Task>(SaveFileAsync));
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
private global::Microsoft.Toolkit.Mvvm.Input.AsyncRelayCommand<global::MyApp.User>? logUserAsyncCommand;
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ICommandGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::Microsoft.Toolkit.Mvvm.Input.IAsyncRelayCommand<global::MyApp.User> LogUserAsyncCommand => logUserAsyncCommand ??= new global::Microsoft.Toolkit.Mvvm.Input.AsyncRelayCommand<global::UnitTests.Mvvm.Test_ICommandAttribute.User>(new global::System.Func<global::UnitTests.Mvvm.Test_ICommandAttribute.User, global::System.Threading.Tasks.Task>(LogUserAsync));
}
Better performance
Another area that I want to investigate with source generators is possibly getting some performance improvemeents by removing reflection where possible. Now, the MVVM Toolkit is already quite light on reflection (as it was designed with that in mind, especially the messenger types), but I think there might be a few places where things could still be improved with source generators. For instance, this method uses quite a bit of reflection.
We could keep this for compatibility and also as a "fallback" implementation, but then we could have the source generator emit a type-specific version of this method with all the necessary handlers already specified, with no reflection. We'd just need to generate the appropriate method in the consuming assembly, and then the C# compiler would automatically pick that one up due to how overload resolution works (since the object recipient
in the original method is less specific than a MyViewModel recipient
parameter that the generated method would have). Still haven't done a working proof of concept for this point specifically, but it's next on my list and will update as soon as that's done too, just wanted to open this issue in the meantime to start gathering feedbacks and discuss ideas π
EDIT: I've now added a generator that will create a method for this for all types implementing IRecipient<T>
:
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#pragma warning disable
namespace Microsoft.Toolkit.Mvvm.Messaging.__Internals
{
internal static partial class __IMessengerExtensions
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
[global::System.Obsolete("This method is not intended to be called directly by user code")]
public static global::System.Action<IMessenger, object> CreateAllMessagesRegistrator(global::MyApp.MyViewModel _)
{
static void RegisterAll(IMessenger messenger, object obj)
{
var recipient = (global::MyApp.MyViewModel)obj;
messenger.Register<global::MyApp.MessageA>(recipient);
messenger.Register<global::MyApp.MessageB>(recipient);
}
return RegisterAll;
}
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
[global::System.Obsolete("This method is not intended to be called directly by user code")]
public static global::System.Action<IMessenger, object, TToken> CreateAllMessagesRegistratorWithToken<TToken>(global::MyApp.MyViewModel _)
where TToken : global::System.IEquatable<TToken>
{
static void RegisterAll(IMessenger messenger, object obj, TToken token)
{
var recipient = (global::MyApp.MyViewModel)obj;
messenger.Register<global::MyApp.MessageA, TToken>(recipient, token);
messenger.Register<global::MyApp.MessageB, TToken>(recipient, token);
}
return RegisterAll;
}
}
}
This is then now picked up automatically when RegisterAll
is called, so that the LINQ expression can be skipped entirely.
There are two generated methods so that the non-generic one can be used in the more common scenario where a registration token is not used, and that completely avoids runtime-code generation of all sorts and also more reflection (no more MakeDynamicMethod
), making it particularly AOT-friendly π
EDIT 2: I've applied the same concept to the other place where I was using compiled LINQ expressions too, that is the ObservableValidator.ValidateAllProperties
method. We now have a new generator that will process all types inheriting from ObservableValidator
, and create helper methods like this that will then be loaded at runtime by the MVVM Toolkit as above:
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#pragma warning disable
namespace Microsoft.Toolkit.Mvvm.ComponentModel.__Internals
{
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ObservableValidatorValidateAllPropertiesGenerator", "7.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCode]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
[global::System.Obsolete("This type is not intended to be used directly by user code")]
internal static partial class __ObservableValidatorExtensions
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
[global::System.Obsolete("This method is not intended to be called directly by user code")]
public static global::System.Action<object> CreateAllPropertiesValidator(global::MyApp.PersonViewModel _)
{
static void ValidateAllProperties(object obj)
{
var instannce = (global::MyApp.PersonViewModel)obj;
__ObservableValidatorHelper.ValidateProperty(instance, instance.Name, nameof(instance.Name));
__ObservableValidatorHelper.ValidateProperty(instance, instance.Age, nameof(instance.Age));
}
return ValidateAllProperties;
}
}
}
When the source generators are used, the MVVM Toolkit is now 100% without dynamic code generation! π
Tracking changes so far
Feedbacks and feature ideas are welcome! π