Dot.Blog

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

Gestion avancée des dépendances dans MAUI : Surmonter les défis avec Shell et les ViewModel Transients ?

Dans le développement d'applications modernes avec .NET MAUI, la gestion efficace des dépendances est cruciale pour maintenir une architecture solide et flexible. MAUI, avec son système intégré d'injection de dépendances, facilite la construction d'applications robustes et testables. Cependant, lorsqu'il s'agit d'utiliser Shell pour la navigation et l'organisation des pages, un défi spécifique émerge : le maintien du caractère transient des ViewModel. Normalement, les services transients sont créés à chaque demande, mais avec Shell, ils peuvent agir comme des singletons. Cet article explore des stratégies pour tenter de garantir que les services et ViewModel restent vraiment transients, même dans le contexte de navigation complexe offert par Shell.

Le Problème des ViewModel Transients avec Shell

Lorsque vous utilisez Shell pour gérer la navigation dans votre application MAUI, vous pouvez rencontrer une situation où les ViewModel, supposés être transients, ne sont pas recréés à chaque navigation. Cela peut conduire à des états incohérents au sein de l'application, car les ViewModel peuvent conserver des états d'une page à l'autre, ce qui est contraire à l'intention initiale. Je vous propose ici d'étudier quelques solutions qui semblent raisonnables - mais qui ne règlent pas tout (mais sont intéressantes pour d'autres raisons) - puis nous verrons une astuce qui fonctionne, en attendant une meilleure prise en charge de ce problème par Microsoft.

Stratégie de Résolution : Utilisation d'une Factory pour les ViewModel, ça marche ?

Une approche pour surmonter ce défi implique l'utilisation d'une factory pour la création des ViewModel. Cette méthode consiste à injecter une factory qui est responsable de fournir une nouvelle instance de ViewModel à chaque fois que cela est nécessaire, plutôt que de les injecter directement dans les pages. On injecte donc la Factory qui recrée une instance du ViewModel (ou du service mais en général on les préfère en singleton) à chaque fois au lieu d'injecter le ViewModel. Et là cela ne pose aucun problème puisque la factory est enregistrée comme un Singleton, donc tout est parfait ! En tout cas on pourrait le croire...

Implémentation de la Factory

Voici un exemple de mise en œuvre d'une interface IViewModelFactory et de sa concrétisation ViewModelFactory :

public interface IViewModelFactory
{
    T Create<T>() where T : ViewModelBase;
}
public class ViewModelFactory : IViewModelFactory
{
    private readonly IServiceProvider _serviceProvider;
    public ViewModelFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    public T Create<T>() where T : ViewModelBase
    {
        return _serviceProvider.GetRequiredService<T>();
    }
}

Enregistrement et Utilisation

Enregistrez la factory dans votre conteneur de services et continuez à enregistrer vos ViewModel comme transients :
builder.Services.AddSingleton<IViewModelFactory, ViewModelFactory>();
builder.Services.AddTransient<MyViewModel>();

Dans vos pages, utilisez cette factory pour obtenir les instances de ViewModel :
public partial class MyPage : ContentPage
{
    public MyPage(IViewModelFactory viewModelFactory)
    {
        InitializeComponent();
        BindingContext = viewModelFactory.Create<MyViewModel>();
    }
}

Bon, cela est séduisant, pas trop complexe à mettre en oeuvre et la logique semble implacable : à chaque fois que la page est créée, son constructeur, au lieu de recevoir une "vieille" instance de son ViewModel, va réceptionner l'instance singleton de la Factory qui refabrique systématiquement le ViewModel.

Mais si cela peut convenir dans d'autres contexte, avec le Shell, ça ne marche pas...

Pourquoi ? Prosaïquement, en traçant sous debugger le constructeur de la Page, on voit bien vite le problème : il n'est pas appelé ! De fait, quoi qu'on fasse dans ce constructeur pour recréer le ViewModel, vu qu'on ne passe plus par lui, tout cela ne sert à rien.

Comment cela se fait-il ? Si on peut imaginer une Factory pour contrôler la création des ViewModels hélas on ne peut pas en créer une autre pour gérer la création des Pages ! C'est le Shell qui s'en occupe et aucun moyen (à ma connaissance) ne permet d'interférer dans ce mécanisme. Il se passe alors ce qui doit arriver : malgré tous nos efforts pour recréer le ViewModel, c'est la Page elle-même qui n'est pas recrée par le Shell !

On peut essayer toutes les combines qu'on veut pour recréer le ViewModel ça ne marche pas. On peut tenter toutefois dans des cas pas trop complexes d'utiliser le code-behind de la Page en surchargeant OnNavigateTo et recréer le ViewModel à ce moment, sans oublier de l'affecter au BindingContext. C'est une parade intéressante et, là encore, pas trop complexe à mettre en oeuvre. On peut même se passer de la Factory et créer directement une instance du ViewModel. Mais dans ce cas c'est tout le principe de l'injection de dépendances qui tombe à l'eau !

Faut-il alors Changer le Moteur d'IoC ?

Bien que changer de moteur d'IoC puisse sembler une solution tentante, le problème avec Shell et les ViewModel transients n'est pas nécessairement lié au moteur d'IoC lui-même mais à la façon dont le Shell gère le cycle de vie des objets pages et ViewModels. Il n'est donc pas utile de changer le moteur d'IoC pour régler ce problème précis. Ni même de mettre en place une Factory comme étudié plus haut. Cependant, certains moteurs offrent des fonctionnalités qui peuvent aider à mieux gérer le cycle de vie des services et ViewModels. On peut donc être amené à préférer et installer un autre moteur d'IoC, au moins pour les services supplémentaires qu'il peut offrir. Et MAUI le permet. Alors puisqu'on en parle et à défaut de régler le problème des ViewModels Transients qui ne le sont pas...

Exemple avec DryIoc

DryIoc, reconnu pour sa performance et sa flexibilité, permet par exemple la création de scopes liés à la navigation. 
Pour intégrer DryIoc dans une application .NET MAUI et le configurer en tant que système de gestion des dépendances par défaut, il faut suivre plusieurs étapes (ce qui reste valable pour d'autres moteurs d'IoC). Ces étapes impliquent l'installation du package NuGet nécessaire, la création d'un conteneur DryIoc, et l'ensuite, l'utilisation de ce conteneur pour remplacer le système d'injection de dépendances par défaut fourni par .NET MAUI. Voici comment procéder :

1. Installation du Package DryIoc

La première étape consiste à ajouter DryIoc à votre projet MAUI. Vous pouvez le faire via l'interface de gestion des packages NuGet dans votre IDE ou en utilisant la ligne de commande. Le package à rechercher est DryIoc.dll.
dotnet add package DryIoc.dll

2. Configuration du Conteneur DryIoc

Après avoir installé le package DryIoc, l'étape suivante consiste à créer et configurer un conteneur DryIoc. Ce conteneur sera utilisé pour enregistrer et résoudre les dépendances dans votre application.
Dans .NET MAUI, cette configuration se fait généralement dans le fichier MauiProgram.cs, où vous configurez et initialisez votre application.
using DryIoc;
using Microsoft.Maui.Hosting;
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });
        // Configuration de DryIoc
        var container = new Container();
        container.UseInstance<IHostEnvironment>(new HostingEnvironment { EnvironmentName = Environments.Development });
        // Enregistrement des services et des ViewModel comme transients
        container.Register<MyViewModel>(Reuse.Transient);
        // Autres enregistrements...
        // Configuration de DryIoc comme fournisseur de services par défaut
        builder.Services.AddSingleton<IContainer>(container);
        builder.Services.AddSingleton<IServiceProvider>(new DryIocServiceProvider(container));
        return builder. Build();
    }
}

3. Utilisation de DryIoc pour la Résolution de Dépendances

Avec le conteneur DryIoc configuré et enregistré comme fournisseur de services, vous pouvez maintenant l'utiliser pour résoudre les dépendances dans votre application. Cela signifie que lorsque vous naviguez vers une page ou créez une instance de ViewModel, DryIoc sera utilisé pour fournir les instances nécessaires.

4. Avantages de DryIoc

L'utilisation de DryIoc offre plusieurs avantages, notamment une plus grande flexibilité dans la gestion des dépendances et des performances potentiellement améliorées. DryIoc permet également une configuration plus avancée du cycle de vie des services, ce qui peut être particulièrement utile pour les applications complexes où le contrôle fin des instances de services est crucial.

Faut-il abandonner le Shell ?

A quoi bon tout ce que nous avons vu jusqu'ici ? En effet, aucune de ces astuces ne règle véritablement le problème des ViewModels qui ne sont pas Transients même en les enregistrant comme tels...
D'abord il faut convenir que la Factory de ViewModel est une idée séduisante qui peut être utilisée dans bien d'autres contextes, et l'étudier peut vous donner des idées. Ensuite, il peut être intéressant de suivre des pistes de résolution car je ne suis pas le seul à avoir ce problème. En vous présentant le résultat de mes recherches je vous évite de vous creuser la tête vous aussi alors que cela ne sert à rien en l'état.
Donc, faut-il utiliser le Shell ?
J'avoue que j'ai un avis mitigé. Si l'App est mono page la question ne se pose pas. Si elle gère quelques pages dont le comportement peut s'accommoder de ViewModels non Transient, c'est à dire Singleton, alors oui, le Shell est une solution de navigation tout à fait pertinente. Mais il faut dans ce cas être cohérent et créer ses ViewModels dans l'optique qu'ils seront des singletons, et pour aller au bout de la démarche, il faudra les enregistrer dans l'IoC comme des singletons et non des Transients.
Mais si les ViewModels ne peuvent pas s'accommoder d'un fonctionnement en Singleton, alors oubliez le Shell... Au moins tant qu'il ne sera pas possible de contrôler via de nouveaux paramètres le cycle de vie des Pages elles-mêmes. Car tant que les pages sont cachées par le Shell, on peut tenter plein de choses, les mêmes vieilles instances des ViewModels seront resservies... D'autant qu'il existe des liens forts entre Page et ViewModel ne serait-ce que le BindingContext et tous les objets de Binding créés dans le code XAML. Ils maintiennent un lien vivant entre la page et le ViewModel et si la page n'est pas détruite, ces liens empêcheront le ViewModel de l'être.

Bref, il faut s'y résoudre : pour contrôler le cycle de vie des ViewModels et des Pages, le Shell n'est pas le droïd que nous cherchons... Mais soyons justes, les autres systèmes de navigation cachent aussi les instances (dans la pile de navigation) et selon comment cela est implémenté le même problème va se poser.

Le mieux serait ainsi que Microsoft règle ce problème en exposant un paramètre permettant de dire si le Shell doit ou non recréer les pages lors de la navigation... Mais cela arrivera-t-il un jour ? Alors en attendant regarder où vous nager lorsque vous naviguez !

Une lueur d'espoir ?

J'aime bien terminer sur une note d'espoir car il faut toujours rester positif. Le monde n'est pas parfait et il ne le sera jamais très certainement. Mais il peut s'arranger ou devenir moins pénible à supporter. Tout est question de posture intellectuelle. Sombrer dans le nihilisme n'arrange rien pas plus que de s'arrêter sur le bord de la route pour pleurer n'a jamais permis d'avancer plus vite...
Alors voici une solution qui me semble la moins mauvaise en l'état. Elle n'est pas parfaite, elle ne règle pas tous les problèmes, mais elle reste simple à implémenter et tient en quelques lignes de code. Ce code provient des nombreux fils de discussions sur StackOverflow, GitHub ou d'autres sites où les développeurs viennent exposer leurs problèmes. Je l'ai testé dans une App réelle où le problème des ViewModel non Transient se posait. Et cela fonctionne.
Mais encore une fois ce n'est qu'un bout de scotch, une rustine, cela ne vaudra jamais une correction (tant attendue depuis des années maintenant) de la part de Microsoft. Mais là voici :
Il suffit d'ajouter ces quelques lignes dans la classe AppShell de votre application.

ShellContent _previousShellContent;
protected override void OnNavigated(ShellNavigatedEventArgs args)
{
base.OnNavigated(args);
if (CurrentItem?.CurrentItem?.CurrentItem is not null &&
_previousShellContent is not null)
{
var property = typeof(ShellContent)
.GetProperty("ContentCache", BindingFlags.Public |
                                 BindingFlags.NonPublic | BindingFlags.Instance |
                                 BindingFlags.FlattenHierarchy);

property.SetValue(_previousShellContent, null);
}
_previousShellContent = CurrentItem?.CurrentItem?.CurrentItem;
}

Puisque c'est le Shell qui pose problème c'est là qu'il faut focaliser les efforts. Et c'est ici que ce code entre en scène. A chaque fois qu'une navigation par le Shell a lieu, OnNavigated (qui constate que la navigation s'est déroulée) recherche la page précédente ou plutôt le ShellContent précédent. Et de là il va tenter de trouver le ContentCache par Réflexion. Une fois trouvé il lui assigne la valeur Null. Plus de cache, le Shell, lorsqu'il reviendra à ce ShellContent, sera bien obligé de recréer la page qui recréera alors son ViewModel surtout si vous utilisez la technique de la Factory exposée plus haut (ou la création directe de l'instance puisqu'après tout cela revient un peu au même).
Bien entendu cela utilise des propriétés auxquelles on n'est pas censé avoir accès, comme ContentCache, et dont le nom ou l'existence même peuvent varier selon les versions de MAUI, il faut utiliser la Réflexion qui n'est pas spécialement rapide, des noms de champs sous forme de string ce qui n'est pas prudent, etc.

Totalement imparfait, comme notre monde. Mais qui l'espace d'un instant et pour une durée non prévisible va pourtant l'améliorer...

Combien de temps exactement ? Peut-être toujours si Microsoft reste aussi peu réactif à corriger les bugs de MAUI, peut-être moins s'ils nous surprennent par une release expurgée des bugs restants. Cette solution règle-t-elle le problème à 100% ? Non plus. C'est un code propre et court, mais qui utilise des techniques risquées et de plus rien n'indique qu'il couvre toutes les situations ou ne pose pas de problèmes dans d'autres (je pense au Shell avec des Tabs par exemple où je ne conseillerai pas cette solution sans des tests poussés).

Mais voilà, nous terminons sur une note d'espoir et un petit bout de solution, c'est déjà beaucoup mieux !

Une autre idée...

L'idée présentée ci-dessus à l'avantage de fonctionner tel quel sans avoir besoin de Page de base ni d'interfaces spécialises, ni même de ViewModel de base. Un seul code à un seul emplacement. Son problème principal reste le traitement des Tabs mais sinon c'est vraiment le meilleur moyen que j'ai trouvé pour que les ViewModels enregistrés en Transient soient réellement Transients.

Mais une idée, c'est bien, mais deux idées, c'est mieux !

Partons du principe que vous disposez d'une Page de base (et d'un ViewModel de base car vous être prévoyant mais cela ne jouera pas ici).

Imaginez maintenant qu'à la différence de la destruction "aveugle" de l'idée précédente vous préféreriez une technique plus souple, par exemple qu'elle n'effectue la destruction des instances que si celles-ci implémentent une interface particulière. Appelons-là IDestructible afin de ne pas la mélanger avec d'autres interfaces aux noms assez proches et de bien différencier notre mécanisme de IDisposable.

En gros, dans notre page de base, lorsque OnNavigatedFrom est appelé, on vérifie si la page est toujours présente dans les piles de navigation standard ou modales, si oui, on ne fait rien car cela signifie que l'on avance dans la navigation et qu'un retour arrière est possible (et devra retrouve la page et son ViewModel en bon état), sinon on appelle Destroy sur l'objet mais uniquement si la page et le ViewModel implémentent notre l'interface (un seul peut le faire ou les deux, ce qui est préférable).
Techniquement notre interface est juste un marqueur, pas besoin qu'elle implémente quoi que ce soit en fait. Juste qu'elle existe, un peu comme un attribut. On pourrait d'ailleurs utiliser une notation par attribut cela serait plus élégant.

Le code ressemble alors à cela :

protected override void OnNavigatedFrom( NavigatedFromEventArgs args )
{
    base.OnNavigatedFrom( args );
    
    // La page est-elle présente ?
    if (Shell.Current.Navigation.NavigationStack.Any( p => p == this ) ||
        Shell.Current.Navigation.ModalStack.Any( p => p == this ))
        return;

    var page = this as IDestructible; // est-elle marquée par notre interface ?
    var vm = _viewModel as IDestructible; // idem pour son viewModel ?
    
    // S'il n'y a rien à détruire
    if (page is null && vm is null)
        return;

    // Sinon on détruit
    vm?.Destroy(); // Le viewModel
    BindingContext = null; // remise à null du lien que crée BindingContext
    page?.Destroy(); // et enfin byebye la page !
}

L'avantage de cette approche est qu'on peut choisir les pages et les ViewModels qui seront détruit (il faut que cela reste en accord avec un enregistrement en mode Transient dans le moteur d'IoC bien entendu). Il faut aussi que pages et ViewModels qui supportent IDestructible. A la vue du code vous l'avez compris, cette interface n'est pas vide, elle définit la méthode Destroy() (à ne pas confondre justement avec Dispose de IDisposable). Et c'est cette méthode qui fera le ménage si besoin est au sein de chaque instance (en appelant Dispose si cela est supporté par les Pages et les ViewModels, sinon en s'occupant de tous les liens type messagerie non weak, références diverses remises à null, et surtout ressources externes).

En réalité, on constate que les idées ne manquent pour améliorer l'expérience Shell.  Il est donc d'autant plus étonnant que Microsoft, qui impose l'utilisation de Shell dans les Templates, n'ait pas encore proposé de correctif pour régler ces problèmes. Mais j'écris ce papier plusieurs mois avant sa parution et, espérons, que d'ici sa publication, une solution officielle soit proposée !

Conclusion

La gestion des dépendances dans les applications MAUI, en particulier en ce qui concerne les instances transientes de ViewModel avec Shell, requiert une attention particulière et aucune solution parfaite ne semble s'imposer. Bien que le système d'IoC intégré à .NET offre une base solide, l'adoption de stratégies avancées, comme l'utilisation de factories ou le choix d'un moteur d'IoC alternatif, peut offrir des solutions plus adaptées à des scénarios complexes, mais pas forcément à tous. Il est donc essentiel de comprendre non seulement les outils à votre disposition mais aussi les nuances de votre architecture d'application pour garantir une gestion des dépendances efficace et conforme à vos besoins. Et puis l'IoC, bien que très prisée, n'est pas l'alpha et l'omega de la programmation moderne. Elle facilite les tests mais peu d'apps sont dotées de tests sérieux et complets. Et puis l'IoC peut aussi fonctionner avec le pattern Factory, l'injection de dépendances dans les constructeurs n'a d'intérêt que pour les fameux tests qui n'existent presque jamais... Alors à chacun de voir jusqu'à quel point il doit suivre les modes et les diktats de l'instant selon la taille et la complexité de ses Apps, et surtout du budget qui leur sont allouées. Car oui, tout coûte de l'argent, les tests, une architecture complexe, tout. Et il est rare de travailler avec un budget sans limite !
Stay Tuned!

Le Guide Complet de.NET MAUI ! Lien direct Amazon : https://amzn.eu/d/95wBULD

Près de 500 pages dédiées à l'univers .NET MAUI !

Existe aussi en version Kindle à prix réduit !

Faites des heureux, PARTAGEZ l'article !