Cette documentation résume les astuces de développement des applications mobiles MAUI que j'ai apprises lors de mes developpements pour les applications SicpaExpe
et SIPMobile
. Elle est à destination des developpeurs initiés à la technologie MAUI, qui cherchent a approfondir leurs connaissances.
Si une information est floue, difficile à comprendre, ou qu'il manque des détails, n'hésitez pas à m'envoyer un mail à martin.toutant1@gmail.com
et j'essaierai d'y répondre le plus rapidement possible.
Les services en MAUI sont des classes instanciées une ou plusieurs fois et gardée en mémoire, et qui peuvent être récupérées et utilisées par les ViewModels
par l'injection de dépendances.
Plus d'infos sur :
Dans les applications SicpaExpe
et SIPMobile
on retrouve des services communs aux deux applications.
ServerDAO
et LocalDAO
qui contiennent les fonctions d'accès aux données sur les différentes bases (voir section sur WS REST).ConnexionService
qui gère l'état de la connexion.LoadingIndicatorService
qui permet de gérer l'affichage de l'indicateur de chargement. Permet de n'avoir qu'une seule classe qui gère la propriété IsBusy
plutot que de l'avoir dans chaque ViewModel
. Permet aussi que des classes hors ViewModels
puissent utiliser l'indicateur de chargement.SettingsService
qui permet de garder quelques variables globales. Par exemple l'unité actuelle dans l'application SIPMobile
MouvementService
sur SIPMobile
et DataSenderService
sur SicpaExpe
. Cela permet de séparer et de factoriser les fonctions d'envoi des données que je laissais auparavant dans les ViewModels
.De plus, MAUI recommande que chaque View
et chaque ViewModel
soient aussi enregistrés en tant que service. Cela permet de profiter de l'injection de dépendance en laissant l'application instancier les classes avec les services dont elles ont besoin.
Chaque service doit être enregistré dans le MauiProgram
, point d'entrée du code de l'application. En étant enregistré, il peut ensuite être injecté dans chaque classe qui le demande.
Exemple dans l'application SicpaExpe
:
public static MauiApp CreateMauiApp() { //... // Enregistrement des services builder.Services.AddSingleton<LocalDAO>(); builder.Services.AddSingleton<ServerDAO>(); builder.Services.AddSingleton<ConnexionService>(); builder.Services.AddSingleton<SettingsService>(); builder.Services.AddSingleton<DataSenderService>(); builder.Services.AddSingleton<LoadingIndicatorService>(); // Enregistrement des pages builder.Services.AddSingleton<SelectionExpePage>(); builder.Services.AddSingleton<ExpeMainPage>(); builder.Services.AddTransient<SaisieVariablePage>(); builder.Services.AddSingleton<SettingsPage>(); builder.Services.AddTransient<OfflineDataPage>(); // Enregistrement des viewmodels builder.Services.AddSingleton<SelectionExpeViewModel>(); builder.Services.AddSingleton<ExpeMainViewModel>(); builder.Services.AddTransient<SaisieVariableViewModel>(); builder.Services.AddSingleton<SettingsViewModel>(); builder.Services.AddTransient<OfflineDataViewModel>(); // ... return builder.Build(); }
Exemple d'un ViewModel
demandant six services différents :
public class ExpeMainViewModel : ViewModelBase, IRecipient<ServerResultsLoadedMessage> { private readonly SettingsService settingsService; private readonly LocalDAO localDAO; private readonly ServerDAO serverDAO; private readonly ConnexionService connService; private readonly DataSenderService dataSenderService; private readonly LoadingIndicatorService loadingIndicatorService; // ... public ExpeMainViewModel( LocalDAO localDAO, ServerDAO serverDAO, SettingsService settingsService, ConnexionService connService, DataSenderService dataSenderService, LoadingIndicatorService loadingIndicatorService) { this.localDAO = localDAO; this.serverDAO = serverDAO; this.settingsService = settingsService; this.connService = connService; this.dataSenderService = dataSenderService; this.loadingIndicatorService = loadingIndicatorService; // ... } // ... }
Ce ViewModel
est instancié par l'application, et l'application lui injecte tous les services donnés en arguments. Le Binding du ViewModel
à la Page
se fait ainsi de la manière suivante :
public ExpeMainPage(ExpeMainViewModel vm) { InitializeComponent(); BindingContext = vm; // ... }
Comme le ViewModel
est enregisté en tant que service, il sera aussi injecté dans le constructeur de la page, au moment de l'instanciation.
NB: Dans le MauiProgram.cs
chaque Service peut être enregistré comme Singleton
ou Transient
:
Singleton
, il ne sera instancié qu'une seule fois et c'est cette seule et même instance qui sera utilisée à chaque fois quelle sera demandée.Transient
, il sera instancié à chaque fois qu'il sera demandé.A garder en tête lorsqu'on débug ! 😉
À la suite du hackhaton MAUI qui a eu lieu fin mars, j'ai (re-)réfléchi sur la mise en place du mode déconnecté dans les applications SicpaExpe puis SIPMobile.
Dans cette section, je décris l'utilisation d'une base de donner locale SQLite en utilisant la librairie sqlite-net-pcl
, recommandé par la documentation officielle Microsoft. Plus d'informations sur https://learn.microsoft.com/en-us/dotnet/maui/data-cloud/database-sqlite?view=net-maui-7.0
Pour des souci de simplicité et pour éviter les problèmes de désynchronisation je me suis fixé les règles de développement suivantes :
Cela donne le workflow suivant lors de l'enregistrement d'un résultat sur SicpaExpe
, par exemple.
Pour synchroniser les données, au lancement de l'application et lors du rétablissement de la connexion, l'application vérifie s'il existe des données en attente d'envoi (i.e. Status == 1
) et les envoie. Puis toutes les données locales sont supprimées (fonction ClearDataFromTable<T>
) et ensuite re-téléchargées depuis le serveur.
Exemple de fonction de récupération des résultats en attente d'envoi dans la base SQLite de l'application SicpaExpe
:
public async Task<List<Resultat>> GetToSendResultats() { await Init(); return await conn.Table<Resultat>() .Where(r => r.Status == 1) .ToListAsync(); }
Exemple de resynchronisation des données dans SicpaExpe
(et de l'utilisation du LoadingIndicatorService
):
// Récupération des résultats locaux List<Resultat> toSendResults = await localDAO.GetToSendResultats(); if (toSendResults.Any()) { loadingIndicatorService.LoadingMessage = "Envoi des résultats locaux..."; await SendResultatsToServerAsync(toSendResults); } loadingIndicatorService.LoadingMessage = "Sauvegarde des résultats de la base..."; List<Resultat> allResults = await serverDAO.GetAllResultats(); await localDAO.ClearDataFromTableAsync<Resultat>(); await localDAO.SaveItems(allResults); List<int> concernedAnimals = allResults.Select(r => r.AnimalID).ToList(); WeakReferenceMessenger.Default.Send(new ServerResultsLoadedMessage(concernedAnimals)); Toast.Make("Synchronisation des résultats avec le serveur effectuée."); loadingIndicatorService.IsBusy = false;
Dans l'application SicpaExpe
, les données sont synchronisées systématiquement à chaque reconnexion, en utilisant l'évènement Connectivity.ConnectivityChanged
et l'outil Messagerie de la librairie MVVM Toolkit
de microsoft.
Plus d'infos sur :
Un exemple de code tiré de l'application SicpaExpe
:
Dans l'implémentation du ConnexionService
:
public class ConnexionService : INotifyPropertyChanged, IRecipient<ConnectionSuccessMessage> { // ... public async Task SetIsConnected(ConnectivityChangedEventArgs e ) { bool internetAccess = e.NetworkAccess == NetworkAccess.Internet; // Test de l'accès a la BDD wi-fi if (internetAccess) { try { await Task.Run(() => IsReseauLocal = new Ping().Send(settingsService.ServerInfo.Address).Status == IPStatus.Success ); } // Cas ou VPN : une exception peut être levée par le ping catch (Exception) { IsReseauLocal = false; } } else { IsReseauLocal = false; } // Envoi d'un message que la connexion a changé WeakReferenceMessenger.Default.Send(new ConnectionChangedMessage(IsReseauLocal)); } public ConnexionService(...) { // ... Connectivity.ConnectivityChanged += Connectivity_ConnectivityChanged; WeakReferenceMessenger.Default.Register(this); } // ... }
L'application tente ensuite de se reconnecter à la base de données du serveur, et si c'est le cas, un message ConnectionSuccessMessage
est émis :
public class ServerDAO : IRecipient<ConnectionChangedMessage> { // ... public ServerDAO(...) { // ... WeakReferenceMessenger.Default.RegisterAll(this); } // ... // Fonction déclenchée à la reception du message ConnectionChangedMessage public void Receive(ConnectionChangedMessage message) { // On se reconnecte if (message.Value) { loadingIndicatorService.LoadingMessage = "Tentative de connexion à " + settingsService.ServerInfo.Address; OpenConnection(); } // On perd la connexion à la base else { loadingIndicatorService.LoadingMessage = "Passage en mode hors ligne..."; // On ferme la connexion a la base pour éviter de timeout. if (connection is not null && connection.State == System.Data.ConnectionState.Open) connection.CloseAsync().Await(); } } // Fonction qui sert à l'ouverture de la connexion à la base MySQL du serveur // Envoi un message ConnectionSuccessMessage si la connexion à réussi ou non private void OpenConnection() { if (connService.IsReseauLocal) { MySqlConnectionStringBuilder builder = new() { // Les données de connexion sont stockées dans le SettingsService Server = settingsService.ServerInfo.Address, Database = "sidexwifi_test", UserID = settingsService.ServerInfo.Username, Password = settingsService.ServerInfo.Password }; connection = new(builder.ConnectionString); try { connection.Open(); WeakReferenceMessenger.Default. Send(new ConnectionSuccessMessage(true)); } catch (Exception) { WeakReferenceMessenger.Default. Send(new ConnectionSuccessMessage(false)); } } } }
Reception du message ConnectionSuccessMessage
par le Service DataSenderService
:
public class DataSenderService : IRecipient<ConnectionSuccessMessage> { // ... // Fonction déclenchée à la reception du message ConnectionSuccessMessage public void Receive(ConnectionSuccessMessage message) { if (message.Value) // Fonction de synchronisation // NB: la fonction Await() vient de la librairie Prism.Core // --> permet d'appeler des fonctions asynchrones dans des procédures synchrones SyncResultsAsync().Await(); } }
Dans l'application SicpaExpe
la synchronisation est également faite à chaque envoi de donnée en ligne, car plusieurs utilisateurs peuvent être en cours d'utilisation et il est éssentiel que la synchronisation soit faite le plus souvent possible. De plus, le serveur étant hebergé sur le réseau local, les interruptions de connexions sont plus rare qu'une connexion passant par des WebServices, sur un serveur distant, par exemple.
Dans l'application SIPMobile
, cette synchronisation n'est pas aussi cruciale et la connexion aux WebServices étant quelquefois aléatoire dans les UEs. Le système que j'ai adopté est un système de synchronisation manuelle, avec lequel l'utilisateur choisi d'envoyer ses données en attente d'envoi, et de re-télécharger les données de la base, dans un onglet spécial nommé "Synchronisation".
La gestion des modes en ligne et hors ligne est spécifique à l'utilisation des applications, ainsi que du type de BDD distante avec laquelle l'application intéragit. Plusieurs implémentations sont possibles et je recommande de se fixer des règles à l'avance pour rester cohérent dans le stockage des données, et éviter la désynchronisation.
L'application SIPMobile
a été créée comme projet de migration de l'application SIPXami, développée en Xamarin, vers la technologie MAUI. Nous avons profité de cette migration pour remplacer l'utilisation des WS SOAP + XML
par l'utilisation des WS REST + JSON
.
Pour faire les différents appels aux WS, j'ai décidé d'utiliser la librairie RestSharp
, soutenue par la fondation .NET :
Pour utiliser la librairie, il faut tout d'abord instancier un objet RestClient
de la façon suivante :
public class ServerDAO { private readonly RestClient client; private readonly string baseUrl = //... private readonly JsonSerializerOptions serializerOptions; public ServerDAO() { // baseUrl est l'url de base du WS Rest client = new(new RestClientOptions(baseUrl)); // options qui servent pour créer "parser" les objets // arrivants/sortants et sérialisés en JSON serializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; } }
Pour plus d'infos sur le JSONSerializerOptions
:
https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/configure-options?pivots=dotnet-7-0
Pour récupérer les données, il suffit de créer un objet RestRequest
avec en options le endpoint de l'API REST que l'on souhaite attaquer. Cet objet est ensuite utilisé dans la fonction GetAsync
proposé par notre objet client
(Type: RestClient
)
public async Task<List<Categorie>> GetCategories() { // Attention à l'URL qui est sensible aux majuscules !! RestRequest restRequest = new("categorieService/listeCategories"); return await client.GetAsync<List<Categorie>>(restRequest); }
Sauf ajout d'annotations pour la désérialisation ou "parsing" des objets en Json, la conversion des objets se fait en fonction de leur nom de propriétés, et sans contraintes liée aux majuscules. Par exemple pour un objet Categorie
venant de l'API suivant :
Categorie { idCategorie integer($int32) nomCategorie string }
L'objet correspondant dans l'application SIPMobile
est :
public class Categorie { public int IDCategorie { get; set; } public string NomCategorie { get; set; } }
Et ainsi le parsing et donc l'affectation des valeurs se font automatiquement.
Exemple d'une fonction avec ajout d'un argument uniteID
:
public async Task<List<Zone>> GetZonesFromUnite(int uniteID) { RestRequest restRequest = new("zoneService/listeZonesByUnite?idUnite=" + uniteID); return await client.GetAsync<List<Zone>>(restRequest); }
L'envoi des données est un petit plus complexe, car cela demande de créer une méthode POST et de sérialiser en JSON les objets à envoyer.
Dans l'application SIPMobile
, l'envoi de données se fait dans l'url de la requête, ce qui donne le code suivant pour l'envoi de plusieurs objets Mouvement
(une liste), par exemple.
public async Task<bool> SaveMouvementList(List<Mouvement> mouvementList) { RestRequest restRequest = new("mouvementService/insertMouvementsTotalByIndividu", Method.Post); // Serialisation au format JSON du mouvement string mouvementsJson = JsonSerializer.Serialize(mouvementList, serializerOptions); // Ajout de l'objet sérialisé en argument // NB: les noms d'arguments soivent respecter les majuscules! restRequest.AddParameter ("mouvementsJson", // le nom du paramètre (attention à la case!) mouvementsJson, // l'objet sérialisé en json ParameterType.QueryString ); // le paramètre est dans l'url (=query) try { // Envoi de la requête await client.PostAsync(restRequest); return true; } catch (Exception) { return false; } }
À noter l'utilisation de l'argument ParameterType.QueryString
dans la fonction AddParameter
pour répondre à la nécessité d'avoir les arguments dans l'url de la requête. Sans ajout de ce paramètre, les objets à envoyer sont ajoutés au champ <body>
de la requête.
NB: sur SIPMobile, j'ai recontré un problème ou le format DateTime
était donné avec le fuseaux horaires (heure d'été = +2h) et la sérialisation en JSON comprenait ce paramètre. Lors de l'enregistrement sur le serveur, la date était convertie avec 2h de moins...
Pour contourner le problème je créé les objets Datetime
de la façon suivante :
// Problème avec fuseaux horaire : DateTime dateChangement = DateTime.Now; // Sans le fuseaux horaire : DateTime dateChangement = new(DateTime.Now.Ticks, DateTimeKind.Utc);
Pour certaines applications, il peut être indispensable d'accéder aux fichiers "externes" de l'appareil, c'est-à-dire les fichiers qui ne sont pas propre à l'application et donc communs à toutes les applications. Parmi ces fichiers, on peut par exemple trouver les fichiers issus des téléchargements.
Avant Android 11, leur accès demandaient la simple addition au Manifest et acceptation des permissions StorageRead
et StorageWrite
, mais depuis, c'est devenu un peu plus compliqué...
Plus d'info sur https://developer.android.com/training/data-storage?hl=fr#permissions (Documentation Android officielle).
Méthode sous Android inférieur à 11 :
Déclarer les permissions dans le Manifest Android : Nom_du_projet > Platforms > Android > AndroidManifest.xml > Clique-droit > Ouvrir Avec > Editeur XML.
<manifest ...> <!-- ... --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> </manifest>
Puis au lancement de l'application, vérifier si les permissions sont allouées :
await Permissions.RequestAsync<Permissions.StorageRead>(); await Permissions.RequestAsync<Permissions.StorageWrite>();
Sur les version d'android supérieures ou égales à 11, une seule permissions doit être ajoutée au Manifest:
<manifest ...> <!-- ... --> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> </manifest>
Mais en outre, l'utilisateur doit cocher une demande de permissions dans les paramètres du système qui autorise l'application à accéder à tous les fichiers de l'appareil.
Pour cela, créer une classe que l'on peut nommer ManageStoragePerm
, par exemple, et qui hérite de Permissions.BasePlatformPermission
:
public class ManageStoragePerm : Permissions.BasePlatformPermission { #if ANDROID // Fonction qui permet de vérifier si la permission a été allouée public override Task<PermissionStatus> CheckStatusAsync() { if (Android.OS.Environment.IsExternalStorageManager) { return Task.FromResult(PermissionStatus.Granted); } else { return Task.FromResult(PermissionStatus.Unknown); } } // Fonction qui permet d'ouvrir la fenêtre dans les paramètres de l'appareil public override Task<PermissionStatus> RequestAsync() { try { Android.Net.Uri uri = Android.Net.Uri.Parse("package:" + AppInfo.PackageName); Android.Content.Intent intent = new(Android.Provider.Settings.ActionManageAppAllFilesAccessPermission, uri); Platform.CurrentActivity.StartActivity(intent); } catch (Exception) { Android.Content.Intent intent = new(); intent.SetAction(Android.Provider.Settings.ActionManageAllFilesAccessPermission); Platform.CurrentActivity.StartActivity(intent); } return Task.FromResult(PermissionStatus.Unknown); } #endif }
Exemple de code de vérification des permissions pour la gestion du stockage externe sur l'application SIPMobile
, utilisée sur des appareils cibles dont la version Android peut aller de 7.1 à 13 (à l'heure de la rédaction de ce document) :
if (DeviceInfo.Version.Major >= 11) { if (await Permissions.CheckStatusAsync<ManageStoragePerm>() == PermissionStatus.Unknown) { await Application.Current.MainPage .DisplayAlert( "Information", "Veuillez cocher l'option d'accès aux fichier dans la fenêtre suivante.", "Suivant"); await Permissions.RequestAsync<ManageStoragePerm>(); } // On ne "catch" pas le retour de l'activité donc on attend avec une boucle de 15 secondes int count = 0; while (await Permissions.CheckStatusAsync<ManageStoragePerm>() == PermissionStatus.Unknown && count < 15) { loadingIndicatorService.Message = "Attente des autorisations..."; await Task.Delay(1000); count++; } // Si à la fin des 15 secondes, on a toujours pas l'autorisation // Demande à l'utilisateur de relancer l'appli if (await Permissions.CheckStatusAsync<ManageStoragePerm>() != PermissionStatus.Granted) { await Application.Current.MainPage .DisplayAlert( "Erreur", "L'autorisation d'accès aux fichiers est requise pour le bon fonctionnement de l'application. Relancez l'application.", "OK"); loadingIndicatorService.IsBusy = false; return; } } // Version en dessous de 11 else { await Permissions.RequestAsync<Permissions.StorageRead>(); await Permissions.RequestAsync<Permissions.StorageWrite>(); }
On y voit l'appel à 3 reprises des fonctions de notre classe ManageStoragePerms
. La fenêtre des paramètres ou il est demandé de cocher une option ressemble à la capture d'écran suivante sur un appareil avec Android 12.