Dot.Blog

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

AutoInpc pour Mvvm Light et UWP – comment ça marche

Hier j’ai releasé la librairie AutoInpc sur CodePlex et en ai présenté l’utilité, j’avais promis d’expliquer comment ça marche. Voici donc les explications sur le code (Open Source) de cette petite extension bien pratique…

AutoInpc – Rappel

Comme expliqué hier, AutoInpc est une petite librairie qui vient étendre les possibilités de Mvvm Light, principalement dans les ViewModels mais aussi dans les Models. Elle permet en effet de façon purement déclarative via un attribut de gérer automatiquement l’envoi de l’évènement PropertyChanged par les champs calculés lorsque les champs standards dont ils dépendent sont modifiés.

Sans cette extension il faut ajouter en fin de Setter de chaque champ “maitre” un appel à RaisePropertyChanged pour chaque champ calculé qui en dépend.

D’une part c’est une programmation brouillon de type code spaghetti difficile à maintenir, et d’autre part cela éclate la responsabilité d’INPC à tous les champs maitres ce qui n’aide pas à clarifier le code. Pire, au bout d’un moment ce code de notification pollue tellement les Setters des champs maitres qu’on n’y comprend plus rien et que la moindre maintenance évolutive ou corrective risque d’oublier un RaisePropertyChanged, cause de bogues pas toujours évidents à trouver.

Bref, on place un attribut sur un champ calculé et on déclare les noms des champs maitres et c’est tout. Un peu magique.

Pour une présentation plus complète lisez l’article d’hier, il vous dira tout ce qu’il faut savoir pour utiliser AutoInpc.

Aujourd’hui c’est le “comment ça marche ?” qui nous intéresse, connaissance inutile (ou presque) pour utiliser AutoInpc mais indispensable pour qui aime coder !

Deux fichiers de code

AutoInpc est vraiment une petite librairie, elle ne viendra pas gonfler votre application de mégaoctets inutiles. Elle se réduit à deux fichiers de code :

  • La déclaration de l’attribut
  • La classe AutoInpc elle-même

L’attribut ComputedField

C’est lui qui sert au marquage des champs calculés. On l’utilise de la façon suivante :

 [ComputedField(new []{"FirstName","LastName"})]
 public string FullName => FirstName + " " + LastName;

Ici le champ FullName est décoré par l’attribut ComputedField qui déclare que le champ calculé dépend des deux champs FirstName et LastName. Lorsque l’un de ces deux champs sera modifié un évènement INPC sera lancé via RaisePropertyChanged pour le champ calculé. De ce fait si le champ est affiché par un objet XAML, ce qui est généralement le cas des propriétés d’un ViewModel, son affichage sera bien mis à jour.

Comment est déclaré l’attribut ?

Voici le code :

using System;


namespace DotBlog.MvvmTools
{
    /// <summary>
    /// Defines a special attribute for computed field in an ObservableObject (Mvvm Light)
    /// </summary>
    [AttributeUsage(AttributeTargets.Property,AllowMultiple = false, Inherited = false)]
    public class ComputedFieldAttribute : Attribute
    {
        public ComputedFieldAttribute(string[] triggerFields)
        {
            TriggerFields = triggerFields;
        }

        /// <summary>
        /// List of all fields that must trigger an INPC for the given property using this attribute.
        /// Names are unfortunately given as string so take care when renamming properties...
        /// </summary>
        public string[] TriggerFields { get; set; }
    }
}

 

La classe ComputedFieldAttribute descend directement de Attribute fournie par System.

Elle est elle-même décorée par un AttributeUsage qui fixe les possibilités d’utilisation de l’attribut. Ici on précise que notre attribut ne sera utilisable que sur des propriétés, qu’il ne sera pas possible de cumuler plusieurs fois l’attribut sur la même propriété et qu’il ne concerne pas les champs hérités.

En dehors de ces déclarations le code de l’attribut déclare une unique propriété de type tableau de string, TriggerFields. Cette liste contiendra les noms des champs maitres. On retrouve l’initialisation de cette propriété dans le constructeur de l’attribut ce qui explique la façon dont il est appelé dans l’exemple de code précédent.

Rien de spécial à dire sur ce code donc, que du super simple. C’est ailleurs que tout se passe mais c’est l’attribut qui du point de vue du développeur est visible et fait tout le travail…

AutoInpc

C’est ici que tout le travail est effectué. Mais avant de regarder le code je vais essayer d’expliquer l’idée que ce code concrétise.

Le concept

L’idée de AutoInpc était de rendre le plus simple possible ce problème de gestion des champs calculés. J’ai toujours trouvé la façon classique de le gérer lourde et ouverte à plein d’erreurs ou oublis. Mais comment rendre cela simple ?

J’ai eu d’autres idées mais celle de l’attribut s’est vite imposée parce que c’est ce qu’il y a de plus simple pour le développeur et que j’aime surtout la lisibilité de l’intention que cette écriture permet. De plus cela centralise sur le champ calculé la responsabilité de déclarer de quels autres champs il dépend et j’aime que les responsabilités soient clairement identifiables dans ou près de l’objet concerné. S’il faut aller lire à l’autre bout d’un code pour en comprendre un morceau c’est du code spaghetti.

Reste un défaut à l’utilisation de l’attribut, les champs maitres passés le sont sous la forme de string. Je n’aime pas ça. On a tous galéré et Laurent le premier dans Mvvm Light pour tenter de supprimer les noms de propriété en string. Refaire la même chose me gêne. Mais alors il faudrait abandonner l’idée de l’attribut et cela ouvrait la voie à une programmation de type appel de méthodes dans le constructeur par exemple. Encore une fois la responsabilité s’éloignait de l’objet intéressé. Par exemple si un champ FullName constitué du prénom et du nom venait à devoir supporter aussi le titre ou autre (madame, monsieur…) le développeur devrait ne pas oublier de revenir généralement tout en haut de son ViewModel dans le constructeur pour penser à ajouter la dépendance à ce nouveau champ. Avec l’attribut c’est là, sous son nez, difficile malgré tout de l’oublier. C’est ce qui a pesé dans la balance. Comme tout choix on peut en discuter des heures, mais ce fut le mien…

Donc pour l’attribut c’était acheté, j’étais convaincu.

Mais comment s’en servir…

Comme il n’y a pas de magie hélas, il fallait un bout de code pour faire marcher l’ensemble donc une classe. Et comme il peut y avoir plusieurs ViewModels ou Models dans une application, chacun devrait posséder sa propre instance. Classe, instance, donc instanciation. Et comme il faudrait analyser l’objet dont le fonctionnement allait être étendu (le ViewModel, le Model…) il serait intelligent de tout faire en une fois. D’où la présence d’une méthode statique Initialize(…) qui créée une instance de AutoInpc et qui conserve la référence vers le ViewModel (Model…) tout en retournant l’instance toute fraiche de AutoInpc qui pourra être conservée. Le constructeur de AutoInpc est marqué private pour en interdire l’utilisation mais attention cette construction n’en fait pas pour autant un Singleton (puisque chaque appel à Initialize créée une nouvelle instance).

Le code d’initialisation de AutoInpc devait être l’endroit où tout le travail est préparé : recenser les propriétés qui portent l’attribut, construire une liste des champs déclencheurs, etc, et surtout se brancher sur le PropertyChanged de l’objet principal pour déclencher celui des champs calculés !

Tout cela s’est avéré facile à faire.

Là où les choses se sont corsées c’est lorsqu’il a fallu déclencher l’INPC des champs calculés.

En effet, ce déclenchement ne peut pas se faire en implémentant INPC dans AutoInpc, toute instance de cet utilitaire est totalement différente de celle du ViewModel (Model…) qu’il “décore”. Envoyer un signal INPC depuis l’utilitaire ne sera pas pris en compte par les bindings XAML sur l’objet principal, c’est une évidence.

Il faut donc “pirater” le PropertyChanged de l’objet principal pour s’en servir dans AutoInpc et simuler l’appel pour que tout objet abonné à l’INPC de l’objet principal ne puisse pas voir la différence avec un RaisePropertyChanged émis depuis ce dernier. Donc il faut notamment penser à simuler le sender. Mais ce n’était pas bien compliqué.

La vraie difficulté vient dans le fait que l’objet principal doit nous offrir un accès à RaisePropertyChanged.

Or le support se trouve dans la classe ObservableObject (qui sert de classe mère à ViewModelBase mais aussi à tout objet devant supporter INPC quand on se sert de Mvvm Light). De fait AutoInpc prévu pour fonctionner au départ sur les ViewModels fonctionne en réalité sur tout ObservableObject ce qui en étend encore plus l’utilité. C’est super. Mais il y a un os…

RaisePropertyChanged dans ObservableObject existe en 4 surcharges, et toutes sont toutes protected.

L’histoire se termine ici puisque “la” méthode dont nous avons besoin pour faire marcher notre joli montage est à jamais enfouie et protégée par le langage et l’encapsulation. Bricoler le source de Mvvm Light est possible, le code est librement accessible. Mais fabriquer sa propre version divergente d’une telle librairie est vraiment une mauvaise idée. Déjà pour soi-même mais l’imposer en quelque sorte à ceux qui utiliseraient AutoInpc n’était tout simplement pas concevable.

Alors fin de l’histoire pour de vraie ?

Si tel était le cas vous ne seriez pas en train de lire le second article sur AutoInpc vous vous en doutez !

Dans l’attente d’un passage en public de RaisePropertyChanged, ce que Laurent ne fera vraisemblablement jamais, ou l’intégration de AutoInpc à Mvvm Light (ou d’un procédé équivalent) il faut ruser. La ruse sous .NET à souvent pour petit nom “réflexion”…

Dans l’initialisation de AutoInpc il y a donc une séquence qui pourrait être évitée mais qui pour l’instant est obligatoire et qui scanne la classe ObservableObject pour obtenir l’objet MethodInfo de la surcharge de RaisePropertyChanged qui nous intéresse. Dans cette version de AutoInpc je me suis arrêté à la version simple, l’envoi de INPC seul. Le système de broadcast des messages Mvvm Light qui existe dans certaines surcharges n’est pas pris en compte.

Une fois les informations de la méthode en la possession de AutoInpc l’affaire est dans le sac…

On dispose de l’instance de l’ObservabelObject principal et de la méthode à invoquer. Quand l’un des champs maitres change (généralement via la méthode Set() dans le Setter) RaisePropertyChanged est appelé et AutoInpc est au courant puisqu’il s’est lui-même abonné à PropertyChanged de cet objet… A ce moment il est facile de vérifier si le changement a lieu sur l’un des champs “déclencheurs” puis de répercuter l’évènement sur tous les champs calculés ayant déclaré dépendre de celui-ci. L’appel se fait via la réflexion en invoquant la méthode RaisePropertyChanged(string) dont nous avons conservé le MethodInfo en utilisant le nom du champ calculé intéressé et dans le sender l’instance de l’ObservableObject que l’évènement nous passe !

Se passer de la réflexion serait un must mais ici elle ne pénalise pas trop le fonctionnement puisque le Methodinfo est obtenu dans l’initialisation de AutoInpc, une seule fois. C’est ensuite cet objet qui est utilisé directement pour invoquer la méthode ce qui est très rapide. Il n’y a donc pas d’analyse via la réflexion à chaque envoi d’un signal INPC ce qui là serait un peu pénalisant (En réalité c’est un débat d’experts mais pour la majorité des applications cela n’a aucun impact visible).

Arriver à ce stade le projet était bouclé, au moins dans ma tête, mais restait une fonctionnalité que je voulais ajouter : le mode suspendu. Toutefois sa réalisation ne posait aucun problème spécifique. Via une simple propriété IsSuspended il serait possible de mettre en suspens les notifications sur les champs calculés. Pendant la suspension ces notifications seraient enregistrées en supprimant les doublons. En fin de suspension tous les évènements conservés seraient alors “rejouer”. Cela est utile lorsque plusieurs champs déclencheurs sont manipulés par code par exemple. Si je change FirstName et LastName (suite à une lecture base de données par exemple), inutile d’envoyer deux fois INPC pour FullName. Une seule fois à la fin est suffisant… Les performances sont optimisées puisque les bindings ne sont pas sollicités inutilement et en surnombre. Cette option est un peu la cerise sur le gâteau il faut avouer mais elle a son utilité.

Donc voilà comment AutoInpc fonctionne et qu’elles ont été les étapes de sa construction intellectuelle avant de devenir du code sous VS 2015.

Pour tout vous avouer j’ai “codé” AutoInpc il a quelques jours alors que j’avais une insomnie et que le coq du voisin chantait trop fort et trop tôt. Dans ces cas là plutôt que de perdre mon temps à rêvasser, ce n’est pas productif, ou pire à m’énerver, ce qui éloigne à tout jamais le sommeil, et aussi étrange que cela pourrait vous paraitre, oui je l’avoue j’aime programmer de tête… Et cette nuit là j’ai programmé AutoInpc Sourire En quelque sorte AutoInpc est le fruit d’un rêve à demi réveillé. C’est joli et poétique je trouve…

Le code

Il est disponible sur codeplex, sur autoinpc.codeplex.com. Mais puisque j’ai présenté le concept et le cheminement pour concevoir le code, il serait dommage de ne pas le commenter rapidement. Je ne passerai que sur les parties les plus intéressantes vous laissant tout de même le soin de regarder le code complet tranquillement chez vous après l’avoir téléchargé.

Initialize

public static AutoInpc Initialize(ObservableObject vm)
        {
            if (vm == null) return null;
            var auto = new AutoInpc();
            auto.checkAttributes(vm);
            auto.getMethodInfo();
            auto.hackInpc(vm);
            return auto;
        }

Comme on le voit AutoInpc fonctionne sur des ObservableObject, c’est à dire la classe mère de ViewModelBase dans Mvvm Light mais aussi celle de classes du Model car très souvent ces dernières doivent implémenter INPC et ObservableObject le fait très bien.

La méthode est static et il est impossible de créer une instance par un autre moyen. En revanche chaque appel créée une nouvelle instance, ce n’est pas un singleton.

La séquence réalisée par l’initialisation consiste :

  1. A trouver les propriétés portant l’attribut CoomputedField  et à construire un dictionnaire utilisable facilement ensuite
  2. A scanner ObservableObject par la Réflexion pour obtenir et conserver le MethodInfo de RaisePropertyChanged(string) qu’on utilisera ensuite
  3. Enfin à ce connecter sur le PropertyChanged de l’objet principal pour réagir aux changements des propriétés maitres.

L’objet AutoInpc ainsi initialisé est retourné à l’appelant notamment pour utiliser le mode “suspendu”.

Le contrôle de l’attribut

Il s’agit ici de balayer les propriétés publiques de l’ObservableObject passé en référence à Initialize() et uniquement celles déclarée là (et non pas celle héritée) et de vérifier si elles ont été décorées ou non par notre attribut. Dans l’affirmative on obtient une instance de celui-ci et on peut alors utiliser la liste des champs pour construire un dictionnaire inversé qui sera utilisé ensuite pour savoir quand envoyer un signal INPC.

Je dis que le dictionnaire est inversé car nous obtenu le nom du champ calculé et son attribut donc la liste des champs dont il dépend, mais le dictionnaire créé n’est pas construit dans ce sens là. On repart des champs déclencheurs qui deviennent des entrées d’un dictionnaire dont la clé est le champ déclencheur et la valeur une liste de noms, ceux des champs calculés impactés. Lorsque le PropetyChanged de l’ObservableObject se déclenche il est très rapide de contrôle ce dictionnaire pour savoir s’il faut faire quelque chose. Et dans l’affirmative il est très rapide de balayer la liste des champs impactés (les champs calculés) pour envoyer un nouveau signal INPC pour chacun d’eux.

Tout ceci se traduit par le code suivant, au moins dans la partie analyse des attributs :

 private void checkAttributes(ObservableObject vm)
        {
            var t = vm.GetType();
            var pi = t.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public);
            foreach (var propertyInfo in pi)
            {
                ComputedFieldAttribute att;
                if (!tryGetAttribute(propertyInfo, out att)) continue;
                foreach (var field in att.TriggerFields)
                {
                    vm.VerifyPropertyName(field);
                    var targets = new List<string>();
                    if (triggerDefs.ContainsKey(field)) targets = triggerDefs[field];
                    else triggerDefs.Add(field, targets);
                    if (targets.IndexOf(propertyInfo.Name) == -1)
                        targets.Add(propertyInfo.Name);
                }
            }
        }

Le champs triggerDefs est le dictionnaire qui est déclaré comme suit :

private readonly Dictionary<string, List<string>> triggerDefs = new Dictionary<string, List<string>>();

Chercher la méthode RaisePropertyChange

Il faut ensuite chercher dans l’objet principal ObservableObject (ou un descendant comme ViewModelbase) la bonne surcharge de RaisePropertyChanged qui est protected et donc inaccessible autrement. On obtient une instance de MethodInfo que l’on va conserver pour les invocations plus tard.

      private void getMethodInfo()
        {
            var t = typeof(ObservableObject);
            var methods = t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
            simpleRaiseInpcMethod =
                (from methodInfo in methods
                 where methodInfo.Name == "RaisePropertyChanged"
                 let pp = methodInfo.GetParameters()
                 where (pp.Length == 1) && (pp[0].ParameterType.Name == "String")
                 select methodInfo).FirstOrDefault();
            if (simpleRaiseInpcMethod == null) throw new Exception("RaisePropertyChanged(String) method can't be found is sender.");
        }

La recherche est rendue plus ardue puisqu’il existe plusieurs signatures (surcharges) de la méthode qui nous intéresse et qu’il faut sélectionner la bonne…

Surveiller les changements de valeurs

Le décor est planté ne reste plus telle l’araignée dans sa toile à attendre qu’un champ de l’objet principal déclenche sans le savoir INPC dans son Setter pour lui tomber dessus !

L’abonnement à PropertyChanged ne mérite pas de commentaire mais voici le code de ce qui se passe quand un tel évènement est détecté :

         private void vmPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (!triggerDefs.ContainsKey(e.PropertyName)) return;
            if (sender==null) throw new Exception("Sender of INPC event is null.");
            var vm = sender as ObservableObject;
            if (vm == null) throw new Exception(
                string.Format("Sender of INPC event is not an ObservableObject. Property: {0}. Sender type: {1}",e.PropertyName,sender.GetType().Name));
            foreach (var field in triggerDefs[e.PropertyName])
            {
                if (IsSuspended)
                {
                    if (!waitingList.ContainsKey(field))
                    {
                        waitingList.Add(field, sender);
                    }
                    continue;
                }
                simpleRaiseInpcMethod?.Invoke(sender, new object[] {field});
            }
        }

En réalité pour ne pas conserver une référence inutile sur l’objet principal le code retrouve l’instance de l’ObservableObject dans le sender de l’évènement. je n’aime pas conserver des références qui créent des dépendances lorsque cela peut être évité.

Selon si le mode suspendu est actif ou non le code stockera l’évènement (le nom du champ déclencheur en fait) dans une liste ou bien invoquera grâce à l’objet MethodInfo obtenu lors de l’initialisation la méthode RaisePropertyChanged bien qu’elle soit protected et inaccessible. C’est une violation du principe d’encapsulation, c’est mal, mais comme on le voit si c’est utilisé en l’assumant cela permet de faire des choses impossibles…

Le stockage du sender avec le nom du champ pourrait paraitre superflu puisqu’il n’y a qu’un seul sender, l’ObservableObject qui a appelé l’initialisation de l’AutoInpc. Mais comme je ne garde pas de référence “durable” sur cet objet il faut bien le stocker temporairement (le mode suspendu n’est pas fait pour durer plus de quelques millisecondes dans les faits). Le fait de dépendre de toute façon de l’ObservableObject pour répondre à son PropertyChanged fait qu’il sera possible dans une version à venir de finalement stocker une référence et de la réutilisée. C’est un point de détail.

Gérer le mode Suspendu

Ce n’est pas la partie la plus intéressante et je vous la laisse découvrir seul. La propriété IsSuspended permet à l’appelant de basculer en mode suspendu et d’en sortir. Durant la suspension on l’a vu ci-dessus les évènements sont conservés (et dédoublonnés) et lors de la sortie de la suspension la liste est “rejouée” comme une sorte de séquenceur musical. une séquence jouée une seule fois puis vidée de ses éléments.

Plus loin

Plus loin c’est l’inconnu… C’est le futur et il dépendra du succès de ce petit morceau de code… Mais en dehors de débogue et de quelques petites améliorations il n’a pas vocation à beaucoup évoluer puisque son but est simple. Mais il ne faut jamais dire jamais ! Qui sait, par une nouvelle nuit d’insomnie peut-être me viendra-t-il en songe à demi-réveillé l’idée d’une nouvelle fonctionnalité…

Sinon vous avez la possibilité via codeplex de faire partie de l’équipe de développement et d’apporter vos rêves et votre expertise pour améliorer AutoInpc ! Je l’ai rêvé (au sens propre) et je l’ai fait, mais il est Open Source et vous pouvez l’améliorer et même le forker ! It’s up to you…

Conclusion

J’ai de drôles de rêves je sais, mais ils sont utiles Sourire

Stay Tuned !

blog comments powered by Disqus