Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Microsoft MVVM Toolkit-Partie 5

Un nouveau toolkit MVVM pourquoi ? Vous saurez tout ou presque dans cette série en 5 parties !

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.

Après la traditionnelle “trêve des confiseurs“ il est temps de reprendre avec force et vigueur nos travaux pour cette nouvelle année qui débute !
Vous pouvez retrouver la Partie 1 en cliquant sur cette phrase ! Lisez ensuite la Partie 2, la Partie 3 et la Partie 4 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.


Dans les parties 1 à 3 je vous présente le toolkit et ses raisons d’être ainsi que son installation, dans la seconde moitié de cette série (parties 4 et 5) je vous présente plus en détail les façons d’utiliser le toolkit. Dans cette dernière partie il nous reste à voir les Commandes et la Messagerie. L’IOC sera discutée plus tard dans un autre article car elle ne fait pas partie du Toolkit, on utilise ce qu’on veut même si certaines voies sont préconisées.

La Messagerie

L’interface IMessenger est un contrat pour les types qui peuvent être utilisés pour échanger des messages entre différents objets. Cela peut être utile pour découpler différents modules d’une application sans avoir à conserver des références fortes aux types référencés. Il est également possible d’envoyer des messages à des canaux spécifiques, identifiés de manière unique par un jeton, et d’avoir différents messagers dans différentes sections d’une application. MVVM Toolkit fournit deux implémentations prêts à l’emploi : WeakReferenceMessenger et StrongReferenceMessenger : le premier utilise des références faibles en interne, offrant une gestion automatique de la mémoire pour les destinataires, tandis que le second utilise des références fortes et oblige les développeurs à désabonner manuellement leurs destinataires lorsqu’ils ne sont plus nécessaires (vous trouverez plus de détails sur la façon de désinscrire les gestionnaires de messages ci-dessous), mais en échange de cela offre de meilleures performances et beaucoup moins d’utilisation de la mémoire.

Attention : Étant donné que le type WeakReferenceMessenger est plus simple à utiliser et correspond au comportement du type Messenger de la bibliothèque MvvmLight, il s’agit du type par défaut utilisé par le type ObservableRecipient dans MVVM Toolkit. Le StrongReferenceType peut toujours être utilisé en passant une instance au constructeur de cette classe.

Les types implémentant IMessenger sont responsables de la maintenance des liens entre les destinataires (destinataires de messages) et leurs types de messages enregistrés, avec des gestionnaires de messages relatifs. Tout objet peut être enregistré en tant que destinataire pour un type de message donné à l’aide d’un gestionnaire de messages, qui sera appelé chaque fois que l’instance IMessenger est utilisée pour envoyer un message de ce type. Il est également possible d’envoyer des messages via des canaux de communication spécifiques (chacun identifié par un jeton unique), de sorte que plusieurs modules puissent échanger des messages du même type sans provoquer de conflits. Les messages envoyés sans jeton utilisent le canal partagé par défaut.

Il existe deux façons d’effectuer l’enregistrement des messages : soit via l’interface IRecipient<TMessage>, soit à l’aide d’un délégué MessageHandler<TRecipient, TMessage> agissant en tant que gestionnaire de messages. Le premier vous permet d’enregistrer tous les gestionnaires avec un seul appel à l’extension RegisterAll, qui enregistre automatiquement les destinataires de tous les gestionnaires de messages déclarés, tandis que le second est utile lorsque vous avez besoin de plus de flexibilité ou lorsque vous souhaitez utiliser une expression lambda simple comme gestionnaire de messages.

WeakReferenceMessenger et StrongReferenceMessenger exposent également une propriété Default qui offre une implémentation thread-safe intégrée dans le package. Il est également possible de créer plusieurs instances de messagerie si nécessaire, par exemple si une autre est injectée avec un fournisseur de services DI dans un module différent de l’application (par exemple, plusieurs fenêtres s’exécutant dans le même processus). Ce type d’utilisation réclame une organisation planifiée de la messagerie stricte, documentée et pensée. Dans le cas contraire le logiciel risque de ne même pas pouvoir être débogué ! A réserver aux sportifs odacieux donc… Une seule messagerie sera très largement suffisant pour toutes les applications, même de grande taille.

Envoi et réception de messages

Partons des éléments suivants pour construire un exemple simple :

// Create a message

public class LoggedInUserChangedMessage : ValueChangedMessage<User>

{

    public LoggedInUserChangedMessage(User user) : base(user)

    {       

    }

}

 

// Register a message in some module

WeakReferenceMessenger.Default.Register<LoggedInUserChangedMessage>(this, (r, m) =>

{

    // Handle the message here, with r being the recipient and m being the

    // input message. Using the recipient passed as input makes it so that

    // the lambda expression doesn't capture "this", improving performance.

});

 

// Send a message from some other module

WeakReferenceMessenger.Default.Send(new LoggedInUserChangedMessage(user));


Imaginons que ce type de message soit utilisé dans une application de messagerie simple, qui affiche un en-tête avec le nom d’utilisateur et l’image de profil de l’utilisateur actuellement connecté, un panneau avec une liste de conversations et un autre panneau avec des messages de la conversation en cours, si l’une est sélectionnée. Supposons que ces trois sections sont prises en charge par les types HeaderViewModel, ConversationsListViewModel et ConversationViewModel respectivement. Dans ce scénario, le message LoggedInUserChangedMessage peut être envoyé par HeaderViewModel une fois l’opération de connexion terminée, et les deux autres ViewModels peuvent inscrire des gestionnaires pour celui-ci. Par exemple, ConversationsListViewModel chargera la liste des conversations pour le nouvel utilisateur, et ConversationViewModel fermera simplement la conversation en cours, le cas échéant.

L’instance IMessenger se charge de remettre les messages à tous les destinataires enregistrés. Notez qu’un destinataire peut s’abonner à des messages d’un type spécifique. Notez que les types de messages hérités ne sont pas enregistrés dans les implémentations IMessenger par défaut fournies par MVVM Toolkit. Il est donc nécessaire de les traiter comme si cet héritage n’existait pas.

Lorsqu’un destinataire n’est plus nécessaire, vous devez le désenregistrer afin qu’il cesse de recevoir des messages. Vous pouvez annuler l’inscription par type de message, par jeton d’enregistrement ou par destinataire :

// Unregisters the recipient from a message type

WeakReferenceMessenger.Default.Unregister<LoggedInUserChangedMessage>(this);

 

// Unregisters the recipient from a message type in a specified channel

WeakReferenceMessenger.Default.Unregister<LoggedInUserChangedMessage, int>(this, 42);

 

// Unregister the recipient from all messages, across all channels

WeakReferenceMessenger.Default.UnregisterAll(this);


Attention : Comme mentionné précédemment, le désabonnement n’est pas strictement nécessaire lors de l’utilisation du type WeakReferenceMessenger, car il utilise des références faibles pour suivre les destinataires, ce qui signifie que les destinataires inutilisés seront toujours éligibles pour le garbage collector même s’ils ont encore des gestionnaires de messages actifs. Il est malgré tout toujours bon de les désabonner, au moins pour améliorer les performances et faire en sorte qu’en cas de changement pour une messagerie à références fortes cela ne cause pas de fuite de mémoire. D’autre part, l’implémentation de StrongReferenceMessenger utilise des références fortes pour suivre les destinataires enregistrés. Ceci est fait pour des raisons de performances, et cela signifie que chaque destinataire enregistré doit être désinscrit manuellement pour éviter les fuites de mémoire. Autrement dit, tant qu’un destinataire est enregistré, l’instance StrongReferenceMessenger utilisée conservera une référence active à celle-ci, ce qui empêchera le garbage collector de pouvoir collecter cette instance. Vous pouvez soit gérer cela manuellement, soit hériter d’ObservableRecipient, qui par défaut se charge automatiquement de supprimer tous les enregistrements de messages pour le destinataire lorsqu’il est désactivé (voir la section sur ObservableRecipient pour plus d’informations à ce sujet).

Il est également possible d’utiliser l’interface IRecipient<TMessage> pour enregistrer les gestionnaires de messages. Dans ce cas, chaque destinataire devra implémenter l’interface pour un type de message donné et fournir une méthode Receive(TMessage) qui sera appelée lors de la réception de messages, comme ceci :

// Create a message

public class MyRecipient : IRecipient<LoggedInUserChangedMessage>

{

    public void Receive(LoggedInUserChangedMessage message)

    {

        // Handle the message here...  

    }

}

 

// Register that specific message...

WeakReferenceMessenger.Default.Register<LoggedInUserChangedMessage>(this);

 

// ...or alternatively, register all declared handlers

WeakReferenceMessenger.Default.RegisterAll(this);

 

// Send a message from some other module

WeakReferenceMessenger.Default.Send(new LoggedInUserChangedMessage(user));


Utilisation des messages de demande

Une autre caractéristique utile des instances de messagerie est qu’elles peuvent également être utilisées pour demander des valeurs d’un module à un autre. Pour ce faire, le package inclut une classe RequestMessage<T> de base, qui peut être utilisée comme suit :

// Create a message

public class LoggedInUserRequestMessage : RequestMessage<User>

{

}

 

// Register the receiver in a module

WeakReferenceMessenger.Default.Register<MyViewModel, LoggedInUserRequestMessage>(this, (r, m) =>

{

    // Assume that "CurrentUser" is a private member in our viewmodel.

    // As before, we're accessing it through the recipient passed as

    // input to the handler, to avoid capturing "this" in the delegate.

    m.Reply(r.CurrentUser);

});

 

// Request the value from another module

User user = WeakReferenceMessenger.Default.Send<LoggedInUserRequestMessage>();


La classe RequestMessage<T> inclut un convertisseur implicite qui permet la conversion d’un Objet LoggedInUserRequestMessage vers son objet User contenu. Cela vérifiera également qu’une réponse a été reçue pour le message et lèvera une exception si ce n’est pas le cas. Il est également possible d’envoyer des messages de demande sans cette garantie de réponse obligatoire : il suffit de stocker le message renvoyé dans une variable locale, puis de vérifier manuellement si une valeur de réponse est disponible ou non. Cela ne déclenchera pas l’exception automatique si aucune réponse n’est reçue lors du retour de la méthode Send.

Le même espace de noms inclut également un message de demandes de base pour d’autres scénarios : AsyncRequestMessage<T>, CollectionRequestMessage<T> et AsyncCollectionRequestMessage<T>. Voici comment utiliser un message de demande asynchrone :

// Create a message

public class LoggedInUserRequestMessage : AsyncRequestMessage<User>

{

}

 

// Register the receiver in a module

WeakReferenceMessenger.Default.Register<MyViewModel, LoggedInUserRequestMessage>(this, (r, m) =>

{

    m.Reply(r.GetCurrentUserAsync()); // We're replying with a Task<User>

});

 

// Request the value from another module (we can directly await on the request)

User user = await WeakReferenceMessenger.Default.Send<LoggedInUserRequestMessage>();


Les Commandes

RelayCommand et RelayCommant<T>

RelayCommand et RelayCommand<T> ont les principales caractéristiques suivantes :

  • Ils fournissent une implémentation de base de l’interface ICommand.
  • Ils implémentent également l’interface IRelayCommand (et IRelayCommand<T>), qui expose une méthode NotifyCanExecuteChanged pour déclencher l’événement CanExecuteChanged.
  • Ils exposent des constructeurs prenant des délégués comme Action et Func<T>, qui permettent l’encapsulation de méthodes standard et d’expressions lambda.

Exemple d’utilisation d’une commande simple :

public class MyViewModel : ObservableObject

{

    public MyViewModel()

    {

        IncrementCounterCommand = new RelayCommand(IncrementCounter);

    }

 

    private int counter;

 

    public int Counter

    {

        get => counter;

        private set => SetProperty(ref counter, value);

    }

 

    public ICommand IncrementCounterCommand { get; }

 

    private void IncrementCounter() => Counter++;

}

Et voici l’UI XAML qui pourrait être utilisatrice de ce code :

<Page

    x:Class="MyApp.Views.MyPage"

    xmlns:viewModels="using:MyApp.ViewModels">

    <Page.DataContext>

        <viewModels:MyViewModel x:Name="ViewModel"/>

    </Page.DataContext>

 

    <StackPanel Spacing="8">

        <TextBlock Text="{x:Bind ViewModel.Counter, Mode=OneWay}"/>

        <Button

            Content="Click me!"

            Command="{x:Bind ViewModel.IncrementCounterCommand}"/>

    </StackPanel>

</Page>

Le Button se lie à ICommand dans le ViewModel, qui encapsule la méthode privée IncrementCounter. Le TextBlock affiche la valeur de la propriété Counter et est mis à jour chaque fois que la valeur de la propriété change.

AsyncRelayCommand and AsyncRelayCommand<T>

AsyncRelayCommand et AsyncRelayCommand<T> sont des implémentations ICommand qui étendent les fonctionnalités offertes par RelayCommand, avec prise en charge des opérations asynchrones.

Elles ont les principales fonctionnalités suivantes :

  • Elles étendent les fonctionnalités des commandes synchrones incluses dans la bibliothèque, avec la prise en charge des délégués de retour de tâche (Task).
  • Elles peuvent encapsuler des fonctions asynchrones avec un paramètre CancellationToken supplémentaire pour prendre en charge l’annulation, et elles exposent des propriétés CanBeCanceled et IsCancellationRequested, ainsi qu’une méthode Cancel.
  • Elles exposent une propriété ExecutionTask qui peut être utilisée pour surveiller la progression d’une opération en attente et un IsRunning qui peut être utilisé pour vérifier la fin d’une opération. Ceci est particulièrement utile pour lier une commande à des éléments d’interface utilisateur tels que des indicateurs de chargement.
  • Elles implémentent les interfaces IAsyncRelayCommand et IAsyncRelayCommand<T>, ce qui signifie que le ViewModel peut facilement exposer des commandes à l’aide de celles-ci pour réduire le couplage étroit entre les types. Par exemple, cela facilite le remplacement d’une commande par une implémentation personnalisée exposant la même surface d’API publique, si nécessaire.

Utilisation de commandes asynchrones

Imaginons un scénario similaire à celui décrit dans l’exemple RelayCommand, mais avec une commande exécutant une opération asynchrone :

public class MyViewModel : ObservableObject

{

    public MyViewModel()

    {

        DownloadTextCommand = new AsyncRelayCommand(DownloadText);

    }

 

    public IAsyncRelayCommand DownloadTextCommand { get; }

 

    private Task<string> DownloadText()

    {

        return WebService.LoadMyTextAsync();

    }

}

 

Le code d’UI serait le suivant :

<Page

    x:Class="MyApp.Views.MyPage"

    xmlns:viewModels="using:MyApp.ViewModels"

    xmlns:converters="using:Microsoft.Toolkit.Uwp.UI.Converters">

    <Page.DataContext>

        <viewModels:MyViewModel x:Name="ViewModel"/>

    </Page.DataContext>

    <Page.Resources>

        <converters:TaskResultConverter x:Key="TaskResultConverter"/>

    </Page.Resources>

 

    <StackPanel Spacing="8" xml:space="default">

        <TextBlock>

            <Run Text="Task status:"/>

            <Run Text="{x:Bind ViewModel.DownloadTextCommand.ExecutionTask.Status, Mode=OneWay}"/>

            <LineBreak/>

            <Run Text="Result:"/>

            <Run Text="{x:Bind ViewModel.DownloadTextCommand.ExecutionTask, Converter={StaticResource TaskResultConverter}, Mode=OneWay}"/>

        </TextBlock>

        <Button

            Content="Click me!"

            Command="{x:Bind ViewModel.DownloadTextCommand}"/>

        <ProgressRing

            HorizontalAlignment="Left"

            IsActive="{x:Bind ViewModel.DownloadTextCommand.IsRunning, Mode=OneWay}"/>

    </StackPanel>

</Page>


Lorsque vous cliquez sur le bouton, la commande est appelée et la tâche d’exécution mise à jour. Une fois l’opération terminée, la propriété déclenche une notification qui est reflétée dans l’interface utilisateur. Dans ce cas, l’état de la tâche et le résultat actuel de la tâche sont affichés. Notez que pour afficher le résultat de la tâche, il est nécessaire d’utiliser la méthode TaskExtensions.GetResultOrDefault - cela permet d’accéder au résultat d’une tâche qui n’est pas encore terminée sans bloquer le thread (et éventuellement provoquer un blocage).

Conclusion


Ces cinq longues parties vous ont présenté le WCT MVVM Toolkit appelé aussi "Microsoft MVVM" même si ce n'est pas un produit Microsoft mais une partie du WCT qui est communautaire et open-source, bien qu'au sein de la fondation DotNet qui est soutenue par Microsoft.
Ce Toolkit a été pensé au départ pour remplacer le plus utilisé de tous, MVVM Light, sachant que son concepteur en a arrêté le support, tout comme Caliburn.Micro qui fut aussi un grand toolkit.
Tout a été fait et pensé pour simplifier l'utilisation du toolkit par ceux qui connaissent déjà MVVM Light et Caliburn.Micro mais le code de ce nouveau toolkit a été entièrement repensé et écrit depuis zéro avec les méthodes les plus modernes.
De fait il fonctionne sous .NET Core, donc sous .NET 5 et 6 (dont le mot "Core" a disparu du nom mais qui sont bien des évolutions de .NET CORE).
Le toolkit sera parfait pour des applications Desktop aussi bien que mobiles car il sait rester léger, comme MVVM Light, sans vous imposer quoi que ce soit. Notamment au niveau de l'IOC vous faites comme vous préférez. Je reviendrai sur cet aspect dans un autre article à part car il y a forcément beaucoup à dire sur ce sujet.
Le WCT MVVM Toolkit est donc un choix intelligent pour toute nouvelle application, notamment sous MAUI !
Mais ça aussi c'est un "gros morceau" et l'année à venir y sera principalement consacrée je pense... Donc ...

Stay Tuned !

blog comments powered by Disqus