Astuces de developpement MAUI

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.

Sommaire

  1. Utilisation des services
  2. Gestion du mode déconnecté
  3. Appel aux WS REST
  4. Permissions spécifiques fichiers externes Android

Utilisation des services

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.

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:

A garder en tête lorsqu'on débug ! 😉

Gestion du mode déconnecté

À 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.

graph mode deco

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.

graph synchro

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.

Appel aux WS Rest

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 :

Instanciation

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

Récupérer des données

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);
}

Envoyer des données

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);

Permissions spécifiques fichiers externes Android

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.

capture permission