Un nouveau toolkit MVVM pourquoi ? Vous saurez tout !
Suite…
Le 12 novembre dernier j’ai entamé une série d’articles sur le nouveau toolkit MVVM intégré au Windows Community Toolkit (WCT). Ce toolkit MVVM est cross-plateforme, écrit à partir de zéro en .NET Standard 2.0, et sa première motivation est de remplacer MVVM Light et Caliburn.Micro dont la maintenance a été définitivement arrêtée.
Vous pouvez retrouver la Partie 1 en cliquant sur cette phrase ! Lisez ensuite la Partie 2 et la Partie 3 et vous serez au point pour le présent article !
Nota: Depuis l'écriture de cette série de papiers, le Toolkit MVVM a été transféré dans le Community Tookit et le namespace à installer est CommunityToolkit.Mvvm.
Passons maintenant aux aspects pratiques
La série en 5 parties à lire
Exemples de mise en œuvre du Toolkit
Cette partie reprend les principaux objets du Toolkit qui seront communément utilisés. D’autres objets existent, la plupart ont été présentés dans la première partie. Se référer à la doc Microsoft pour plus d’information à leur sujet. Ici je me limiterais aux types qui seront les plus utilisés – ou potentiellement les plus utilisés.
ObservableObject
Classe de base pour les objets dont les propriétés doivent être observables (INPC).
ObservableObject est une classe de base pour les objets observables en implémentant les interfaces INotifyPropertyChanged et INotifyPropertyChanging. Il peut être utilisé comme point de départ pour toutes sortes d’objets qui doivent prendre en charge les notifications de modification de propriété.
ObservableObject principales fonctionnalités :
- Il fournit une implémentation de base pour INotifyPropertyChanged et INotifyPropertyChanging, exposant les événements PropertyChanged et PropertyChanging.
- Il fournit une série de méthodes SetProperty qui peuvent être utilisées pour définir facilement des valeurs de propriété à partir de types héritant de ObservableObject, et pour déclencher automatiquement les événements appropriés.
- Il fournit la méthode SetPropertyAndNotifyOnCompletion, qui est analogue à SetProperty mais avec la possibilité de définir les propriétés Task et de déclencher automatiquement les événements de notification lorsque les tâches assignées sont terminées.
- Il expose les méthodes OnPropertyChanged et OnPropertyChanging, qui peuvent être remplacées dans les types dérivés pour personnaliser la façon dont les événements de notification sont déclenchés.
Propriété simple
public class User : ObservableObject{
private string name;
public string Name
{
get => name;
set => SetProperty(ref name, value);
}
}
```
La méthode SetProperty<T>(ref T, T, string)fournie vérifie la valeur actuelle de la propriété et la met à jour si elle est différente, puis déclenche automatiquement les événements pertinents. Le nom de la propriété est automatiquement capturé via l’utilisation de l’attribut [CallerMemberName], il n’est donc pas nécessaire de spécifier manuellement quelle propriété est mise à jour.
Encapsuler un objet non observable
Un scénario courant, par exemple lorsque vous travaillez avec des éléments de base de données, consiste à créer un modèle d’encapsulation qui relaie les propriétés du modèle de base de données et déclenche les notifications de modification de propriété si nécessaire. Ceci est également nécessaire lorsque vous souhaitez injecter une prise en charge de notification aux modèles qui n’implémentent pas l’interface INotifyPropertyChanged. ObservableObject fournit une méthode dédiée pour rendre ce processus plus simple. Pour l’exemple suivant, User est un modèle mappant directement une table de base de données, sans hériter de ObservableObject :
public class ObservableUser : ObservableObject
{ private readonly User user;
public ObservableUser(User user) => this.user = user;
public string Name
{
get => user.Name;
set => SetProperty(user.Name, value, user, (u, n) => u.Name = n);
}
}
```
Dans ce cas, nous utilisons la surcharge SetProperty<TModel, T>(T, T, TModel, Action<TModel, T>, string). La signature est légèrement plus complexe que la précédente - cela est nécessaire pour que le code soit toujours extrêmement efficace même si nous n’avons pas accès à un champ de sauvegarde comme dans le scénario précédent. Nous pouvons parcourir chaque partie de cette signature de méthode en détail pour comprendre le rôle des différents composants:
- TModel est un argument de type, indiquant le type du modèle que nous enveloppons. Dans ce cas, il s’agira de notre classe User. Notez que nous n’avons pas besoin de le spécifier explicitement - le compilateur C# le déduira automatiquement par la façon dont nous appelons la méthode SetProperty.
- T est le type de propriété que nous voulons définir. Comme pour TModel, cela est déduit automatiquement.
- T oldValue est le premier paramètre, et dans ce cas, nous utilisons user. Nom pour transmettre la valeur actuelle de cette propriété que nous encapsulons.
- T newValue est la nouvelle valeur à définir sur la propriété, et ici nous passons value, qui est la valeur d’entrée dans le property setter.
- Model TModel est le modèle cible que nous enveloppons, dans ce cas, nous passons l’instance stockée dans le champ utilisateur.
- Action<TModel, T> callback est une fonction qui sera appelée si la nouvelle valeur de la propriété est différente de la valeur actuelle et que la propriété doit être définie. Cela se fera par cette fonction de rappel, qui reçoit en entrée le modèle cible et la nouvelle valeur de propriété à définir. Dans ce cas, nous attribuons simplement la valeur d’entrée (que nous avons appelée n) à la propriété Name (en faisant 'u.Name = n'). Il est important ici d’éviter de capturer des valeurs de l’étendue actuelle et d’interagir uniquement avec celles données en entrée du rappel, car cela permet au compilateur C# de mettre en cache la fonction de rappel et d’effectuer un certain nombre d’améliorations de performances. C’est pour cette raison que nous n’accédons pas seulement directement au champ user ici ou au paramètre value dans le setter, mais que nous n’utilisons que les paramètres d’entrée pour l’expression lambda.
La méthode SetProperty<TModel, T>(T, TModel, Action<TModel, T>, string) rend la création de ces propriétés d’encapsulation extrêmement simple, car elle prend en charge à la fois la récupération et la définition des propriétés cibles tout en fournissant une API extrêmement compacte.
Gérer les propriétés de type Task<T>
Si une propriété est une « tâche », il est nécessaire de déclencher également l’événement de notification une fois la tâche terminée, afin que les liaisons soient mises à jour au bon moment. Par exemple pour afficher un indicateur de chargement ou d’autres informations d’état sur l’opération représentée par la tâche. ObservableObject dispose d’une API pour ce scénario :
public class MyModel : ObservableObject{
private TaskNotifier<int>? requestTask;
public Task<int>? RequestTask
{
get => requestTask;
set => SetPropertyAndNotifyOnCompletion(ref requestTask, value);
}
public void RequestValue()
{ RequestTask = WebService.LoadMyValueAsync(); }
}
```
Ici, la méthode SetPropertyAndNotifyOnCompletion<T>(ref TaskNotifier<T>, Task<T>, string) se chargera de mettre à jour le champ cible, de surveiller la nouvelle tâche, et de lever l’événement de notification lorsque cette tâche sera terminée. De cette façon, il est possible de simplement se lier à une propriété de tâche et d’être averti lorsque son état change. Le TaskNotifier<T> est un type spécial exposé par ObservableObject qui encapsule une instance Task<T> cible et active la logique de notification nécessaire pour cette méthode. Le type TaskNotifier est également disponible pour une utilisation directe si vous avez une Task générale uniquement.
ObservableRecipient
Le type ObservableRecipient est destiné à être utilisé comme base pour les ViewModels qui utilisent également les fonctionnalités IMessenger, car il fournit une prise en charge intégrée. En particulier:
- Il possède à la fois un constructeur sans paramètre et un constructeur qui prend une instance IMessenger, à utiliser avec l’injection de dépendance. Il expose également une propriété Messenger qui peut être utilisée pour envoyer et recevoir des messages dans le ViewModel. Si le constructeur sans paramètre est utilisé, l’instance WeakReferenceMessenger.Default est affectée à la propriété Messenger.
- Il expose une propriété IsActive pour activer/désactiver le ViewModel. Dans ce contexte, « activer » signifie qu’un ViewModel donné est marqué comme étant en cours d’utilisation, de sorte que, par exemple. il commencera à écouter les messages enregistrés, effectuera d’autres opérations de configuration, etc. Il existe deux méthodes associées, OnActivated et OnDeactivated, qui sont appelées lorsque la propriété change de valeur. Par défaut, OnDeactivated annule automatiquement l’enregistrement de l’instance actuelle de tous les messages enregistrés. Pour de meilleurs résultats et éviter les fuites de mémoire, il est recommandé d’utiliser OnActivated pour enregistrer les messages et d’utiliser OnDeactivated pour effectuer des opérations de nettoyage. Ce modèle permet à un ViewModel d’être activé/désactivé plusieurs fois, tout en étant sûr de collecter ce qui doit l’être sans risque de fuites de mémoire chaque fois qu’il est désactivé. Par défaut, OnActived enregistre automatiquement tous les gestionnaires de messages définis via l’interface IRecipient<TMessage>.
- Il expose une méthode Broadcast<T>(T, T, string) qui envoie un message PropertyChangedMessage<T> via l’instance IMessenger disponible à partir de la propriété Messenger. Cela peut être utilisé pour diffuser facilement des modifications des propriétés d’un ViewModel sans avoir à récupérer manuellement une instance Messenger à utiliser. Cette méthode est utilisée par la surcharge des différentes méthodes SetProperty, qui ont une propriété de diffusion bool supplémentaire pour indiquer s’il faut ou non envoyer également un message.
Voici un exemple de ViewModel qui gère les messages de type (imaginaire) LoggedInUserRequestMessage :
public class MyViewModel : ObservableRecipient, IRecipient<LoggedInUserRequestMessage>{
public void Receive(LoggedInUserRequestMessage message)
{
// Handle the message here
}
}
Dans l’exemple ci-dessus, OnActivated enregistre automatiquement l’instance en tant que destinataire pour les messages LoggedInUserRequestMessage, en utilisant cette méthode comme action à appeler. L’utilisation de l’interface IRecipient<TMessage> n’est pas obligatoire, et l’enregistrement peut également être effectué manuellement (même en utilisant uniquement une expression lambda en ligne) :
public class MyViewModel : ObservableRecipient{
protected override void OnActivated()
{
// Using a method group...
Messenger.Register<MyViewModel, LoggedInUserRequestMessage>(this, (r, m) => r.Receive(m));
// ...or a lambda expression
Messenger.Register<MyViewModel, LoggedInUserRequestMessage>(this, (r, m) =>
{ // Handle the message here }); }
private void Receive(LoggedInUserRequestMessage message)
{
// Handle the message here
}
}
ObservableValidator
ObservableValidator est une classe de base implémentant l’interface INotifyDataErrorInfo, qui prend en charge la validation des propriétés exposées à d’autres modules d’application. Il hérite également d’ObservableObject, il implémente donc INotifyPropertyChanged et INotifyPropertyChanging. Il peut être utilisé comme point de départ pour toutes sortes d’objets qui doivent prendre en charge à la fois les notifications de modification de propriété et la validation de propriété.
ObservableValidator possède les principales caractéristiques suivantes :
- Il fournit une implémentation de base pour INotifyDataErrorInfo, exposant l’événement ErrorsChanged et les autres API nécessaires.
- Il fournit une série de surcharges SetProperty supplémentaires (en plus de celles fournies par la classe ObservableObject de base), qui offrent la possibilité de valider automatiquement les propriétés et de lever les événements nécessaires avant de mettre à jour leurs valeurs.
- Il expose un certain nombre de surcharges TrySetProperty, qui sont similaires à SetProperty mais avec la possibilité de ne mettre à jour la propriété cible que si la validation réussit, et de renvoyer les erreurs générées (le cas échéant) pour une inspection plus approfondie.
- Il expose la méthode ValidateProperty, qui peut être utile pour déclencher manuellement la validation d’une propriété spécifique au cas où sa valeur n’a pas été mise à jour mais que sa validation dépend de la valeur d’une autre propriété qui a été mise à jour.
- Il expose la méthode ValidateAllProperties, qui exécute automatiquement la validation de toutes les propriétés d’instance publique dans l’instance actuelle, à condition qu’au moins un [ValidationAttribute] leur soit appliqué.
- Il expose une méthode ClearAllErrors qui peut être utile lors de la réinitialisation d’un modèle lié à un formulaire que l’utilisateur peut vouloir remplir à nouveau.
- Il propose un certain nombre de constructeurs qui permettent de passer différents paramètres pour initialiser l’instance ValidationContext qui sera utilisée pour valider les propriétés. Cela peut être particulièrement utile lors de l’utilisation d’attributs de validation personnalisés qui peuvent nécessiter des services ou des options supplémentaires pour fonctionner correctement.
Voici un exemple d’implémentation d’une propriété qui prend en charge à la fois les notifications de modification et la validation :
public class RegistrationForm : ObservableValidator{
private string name;
[Required]
[MinLength(2)]
[MaxLength(100)]
public string Name
{
get => name;
set => SetProperty(ref name, value, true);
}
}
Ici, nous appelons la méthode SetProperty<T>(ref T, T, bool, string) exposée par ObservableValidator, et ce paramètre bool supplémentaire défini sur true indique que nous voulons également valider la propriété lorsque sa valeur est mise à jour. ObservableValidator exécute automatiquement la validation sur chaque nouvelle valeur à l’aide de toutes les vérifications spécifiées avec les attributs appliqués à la propriété. D’autres composants (tels que les contrôles d’interface utilisateur) peuvent ensuite interagir avec le ViewModel et modifier leur état pour refléter les erreurs actuellement présentes dans le ViewModel, en s’inscrivant dans ErrorsChanged et en utilisant la méthode GetErrors(string) pour récupérer la liste des erreurs pour chaque propriété qui a été modifiée.
Custom validation methods
Parfois, la validation d’une propriété nécessite qu’un ViewModel ait accès à des services, des données ou d’autres API supplémentaires. Il existe différentes façons d’ajouter une validation personnalisée à une propriété, en fonction du scénario et du niveau de flexibilité requis. Voici un exemple de la façon dont le type [CustomValidationAttribute] peut être utilisé pour indiquer qu’une méthode spécifique doit être appelée pour effectuer une validation supplémentaire d’une propriété :
public class RegistrationForm : ObservableValidator{
private readonly IFancyService service;
public RegistrationForm(IFancyService service)
{ this.service = service; }
private string name;
[Required]
[MinLength(2)]
[MaxLength(100)]
[CustomValidation(typeof(RegistrationForm), nameof(ValidateName))]
public string Name
{
get => this.name;
set => SetProperty(ref this.name, value, true);
}
public static ValidationResult ValidateName(string name, ValidationContext context)
{
RegistrationForm instance = (RegistrationForm)context.ObjectInstance;
bool isValid = instance.service.Validate(name);
if (isValid)
{ return ValidationResult.Success; }
return new("The name was not validated by the fancy service");
}
}
Dans ce cas, nous disposons d’une méthode ValidateName statique qui effectuera la validation sur la propriété Name via un service injecté dans notre ViewModel. Cette méthode reçoit la valeur de la propriété Name et l’instance ValidationContext en cours d’utilisation, qui contient des éléments tels que l’instance ViewModel, le nom de la propriété en cours de validation et, éventuellement, un fournisseur de services et des indicateurs personnalisés que nous pouvons utiliser ou définir. Dans ce cas, nous récupérons l’instance RegistrationForm à partir du contexte de validation, et à partir de là, nous utilisons le service injecté pour valider la propriété. Notez que cette validation sera exécutée à côté de celles spécifiées dans les autres attributs, nous sommes donc libres de combiner des méthodes de validation personnalisées et des attributs de validation existants comme bon nous semble.
Custom validation attributes
Une autre façon d’effectuer une validation personnalisée consiste à implémenter un [ValidationAttribute] personnalisé. puis insérer la logique de validation dans la méthode IsValid remplacée. Cela permet une flexibilité supplémentaire par rapport à l’approche décrite ci-dessus, car il est très facile de réutiliser le même attribut à plusieurs endroits.
Supposons que nous voulions valider une propriété en fonction de sa valeur relative par rapport à une autre propriété dans le même modèle de vue. La première étape consisterait à définir une [GreaterThanAttribute] personnalisée, comme :
public sealed class GreaterThanAttribute : ValidationAttribute
{
public GreaterThanAttribute(string propertyName)
{
PropertyName = propertyName;
} public string PropertyName { get; }
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
object instance = validationContext.ObjectInstance,
otherValue = instance.GetType().GetProperty(PropertyName).GetValue(instance);
if (((IComparable)value).CompareTo(otherValue) > 0)
{ return ValidationResult.Success; }
return new("The current value is smaller than the other one");
}
}
Ensuite, nous pouvons ajouter cet attribut dans notre ViewModel:
public class ComparableModel : ObservableValidator
{
private int a;
[Range(10, 100)]
[GreaterThan(nameof(B))]
public int A
{
get => this.a;
set => SetProperty(ref this.a, value, true);
}
private int b;
[Range(20, 80)]
public int B
{
get => this.b;
set {
SetProperty(ref this.b, value, true);
ValidateProperty(A, nameof(A));
}
}
}
Dans ce cas, nous avons deux propriétés numériques qui doivent être dans une plage spécifique et avec une relation spécifique entre elles (A doit être supérieur à B). Nous avons ajouté le nouveau [GreaterThanAttribute] sur la première propriété, et nous avons également ajouté un appel à ValidateProperty dans le setter pour B, de sorte que A est validé à nouveau chaque fois que B change (puisque son état de validation en dépend). Nous avons juste besoin de ces deux lignes de code dans notre ViewModel pour activer cette validation personnalisée, et nous avons également l’avantage d’avoir un attribut de validation personnalisé réutilisable qui pourrait également être utile dans d’autres ViewModels de notre application. Cette approche facilite également la modularisation du code, car la logique de validation est désormais complètement découplée de la définition du ViewModel lui-même.
Conclusion partielle
Bien que simple, le WCT MVVM Toolkit contient de nombreuses subtilités permettant d’implémenter MVVM assez profondément. Une période d’auto formation sera nécessaire même si l’on connait bien MVVM Light ou Prism. Toutefois ce toolkit ne reprend que des grands principes déjà vus ailleurs et tout développeur déjà formé à la logique MVVM devrait s’en sortir très rapidement.
Il nous reste à voir par exemple le Commandes et la messagerie, puis nous étudierons l’injection de dépendances et l’IOC. Ces trois sujets sont assez imposants et je vais leur réserver un billet pour chaque histoire de ne pas vous assommer définitivement !
Alors restez à l’écoute, abonnez-vous au flux, donc…
Stay Tuned !