O2 - Une app offline-first déclinable

Ce post fait suite à un webinar qui présente les enjeux business et l'architecture du projet que vous pourrez trouver ici. Il a pour but de donner plus de précisions sur les aspects techniques du projet.

O2, société du groupe Oui Care, leader français des services à la personne, a demandé à Ippon de l’accompagner dans la dématérialisation de la Visite.

o2

Concrètement, la Visite correspond au déplacement réalisé chez le client, par un Chargé de Clientèle (CC), dans le but de qualifier son besoin (Ménage-Repassage, Jardinage, Garde d’enfant, etc.), récolter les informations de contact, consignes et souhaits particuliers d’entretien, les matériels et produits à utiliser, risques, etc., afin de générer une Feuille de Route (FdR) à destination du futur intervenant responsable de l’intervention.

La digitalisation de ce parcours commercial, savoir-faire du groupe O2, représentait plusieurs enjeux :

  • Accroître l’efficacité opérationnelle,
  • Améliorer l’expérience du CC, et l’image renvoyée au client,
  • Guider le CC dans son RDV,
  • Réduire les erreurs de saisies et la ressaisie dans le CRM,
  • Favoriser le cross selling,
  • etc.

O2 a saisi cette opportunité pour refondre son SI Legacy avec une nouvelle architecture à l’état de l’art, basée sur une stack microservice événementielle.

Dans cet article, nous allons faire un focus sur le développement de l’application mobile qui sera utilisée pendant les visites. Tout d’abord, nous présenterons l'initialisation de la stack. Puis, nous décrirons comment nous avons géré la synchronisation des données avec une approche offline-first. Nous terminerons par la présentation des décisions prises au cours du projet qui permettent d’avoir aujourd’hui une app déclinable pour l’ensemble des prestations du groupe.

Initialisation de la Stack Mobile

Historiquement, lorsqu’un CC vient chez son client, il recueille le besoin via des formulaires papiers. Vient ensuite une ressaisie dans le CRM interne d’O2. Notre accompagnement a débuté par la réalisation d’un MVP en 3 mois. Son objectif était de valider cette digitalisation du parcours, avec un Time To Market réduit pour limiter les hypothèses et profiter des retours terrain.

Cible

Quasiment tous les projets mobiles commencent par les mêmes questions :

  • Est-ce que la flotte d’appareils est connue et maîtrisée ? Les questions sous-jacentes étant : quelle version maximale du système pourra-t-on cibler ? Comment déployer ?
  • Doit-on faire du natif (ie: utiliser Kotlin / Swift) ou plutôt se diriger vers les frameworks crossplatform / hybrides du moment (Flutter ?) ?

Dans notre cas, l'objectif de cette application étant restreint à un public B2B : les salariés du groupe. Le choix fut vite guidé par la flotte de devices déjà déployés par O2 via leur MDM (Mobile Device Management), à savoir des téléphones Android en version 8.0 (API 26). Dans un soucis de pérennité de l'application et de faisabilité technique quant aux accès bas niveau, nous nous sommes dirigés vers de l’Android natif. Et en Kotlin, s’il vous-plaît !

Kotlin est aujourd’hui LE langage poussé par Google sur cette plateforme. On peut avoir quelques appréhensions quand on est habitué à Java. On est en réalité très vite surpris de la rapidité avec laquelle on le prend en main… et surtout par sa richesse. On pense souvent à la null-safety. Mais cela serait réducteur de s’arrêter sur ce point. Son écosystème est riche et dynamique ; les Coroutines et Kotlin/Native sont déjà à elles seules deux révolutions.

Jetpack

Jetpack est un ensemble de librairies développées par Google qui a pour but d’aider les développeurs à suivre les bonnes pratiques de développement, et qui leur permet de produire du code maintenable, et évolutif. Elles fournissent aussi plusieurs niveaux d’abstraction, pour aider à se concentrer sur la création de valeur ajoutée métier. On y retrouve des composants tels que :

Nous avons aujourd’hui sur Android des outils de plus en plus opinionated, et c’est tant mieux quand il s’agit de développer, maintenir et faire évoluer ses apps.

Asynchrone

Si le premier cauchemar de tout dev Android est le crash à cause d’un NullPointerException, le second est probablement d’avoir une app qui freeze, lente et désagréable à utiliser.

Pour palier ce dernier point, différentes solutions existent pour gérer efficacement ses threads en faisant de l’asynchrone. On pensera notamment à :

  • Asynctask,
  • Executors,
  • Runnable,
  • RxJava,
  • Coroutines,
  • etc.

C’est au final assez facile de s’y perdre alors que c’est quelque chose d’absolument primordial pour avoir une appli du tonnerre. La bonne nouvelle, c’est que les librairies de Jetpack intègrent quasiment systématiquement l’une d’entre elles. Nous avons choisi les coroutines en raison de leur simplicité d’utilisation et leur versatilité. D’ailleurs, elles sont depuis peu en vedette dans les guidelines de l’asynchrone sur Android.

Concrètement, vous pouvez les utilisez simplement, sur le thread de votre choix, comme ceci :

viewModelScope.launch(Dispatchers.IO) {
   visiteRepository.getDoneVisites()
}

Ici, on lance une coroutine sur le Thread IO, optimisée pour traiter les entrées / sorties. Rattachée au viewModelScope, un attribut disponible dans un ViewModel, elle est automatiquement tuée si le ViewModel associé meurt.

CI/CD

Dans un souci d'industrialisation, nous avons intégré le projet dans une CI/CD (intégration continue / déploiement continu). Le code source étant hébergé sur Gitlab, nous avons utilisé Gitlab-CI, avec un déploiement cible sur Firebase App Distribution une fois la bascule Fabric → Firebase réalisée.
Voici un schéma résumant le principe de développement de l’app :

ci-dev

  1. Le développement se fait en local sur sa machine, avec Android Studio ;
  2. Le code est mis en ligne sur Gitlab avec git ;
  3. Différents pipelines s’exécutent, selon si on est sur une feature branch, sur develop ou sur une release branch. Les tests sont exécutés à chaque fois.
    a. un build ok sur develop conduit automatiquement à la transmission de l’apk sur Firebase App Distribution,
    b. un build ok sur une release branch, après validation manuelle, conduit à la transmission de l’apk sur Firebase App Distribution ;
  4. L’app rentre en beta, est recettée. Elle est déployée sur les téléphones selon ce qu’elle embarque, via le MDM O2.

Nécessité d’une UI claire et adaptative

Pour permettre à O2 de garder le choix du device utilisé lors de la Visite (mobile / tablette), non tranché lors des phases de test utilisateur, nous avons décliné l'UI pour s'adapter aux différentes résolutions d'écran. Nous nous sommes aussi efforcés de respecter au maximum les guidelines du material design. Afin d'éviter la duplication de code et ainsi limiter la dette technique, nous nous sommes appuyé sur deux composants :

  • Le ConstraintLayout, qui est maintenant le layout par défaut dans Android Studio. L’utilisation de contraintes pour définir son UI améliore la lisibilité du code en réduisant l’imbrication de composants, avec une gestion fine des éléments présents à l’écran.
  • Le kit UI material dédié à Android.

Saisie de la donnée sur le mobile

La multitude de champs à saisir lors de la qualification de la visite nous a amené à repenser notre manière de récupérer de la donnée sur Android. Prenons par exemple le cas classique d’un champ texte :

  • Le créer dans notre fichier xml,
  • Côté Kotlin, le récupérer,
  • Lui affecter un Listener qui se déclenche à la saisie de l’utilisateur,
  • Récupérer la donnée modifiée et la persister dans notre modèle.

La problématique n’est pas dans son fonctionnement, mais dans la quantité de code à écrire pour récupérer la valeur d’un champ.

Nous avons donc décidé de partir sur un des composants de Jetpack : Data Binding (en two way). Ainsi, nous étions à la fois réceptif aux modifications de données de la propriété et en mesure d’écouter les mises à jour utilisateur sans implémenter des méthodes rébarbatives de sauvegarde d’écran, à l’origine d’une dette technique souvent complexe.

Fonctionnement en mode déconnecté

Une des principales contraintes liée à la mobilité du CC au domicile du client, c'est la capacité de fonctionner avec ou sans Internet, sans blocage côté utilisateur ; ce besoin revient maintenant régulièrement dans l'écosystème mobile.

Pour le gérer, nous avons imaginé le scénario d’utilisation suivant :

  • Le CC prépare sa journée à l’agence (ou à son domicile),
  • Le mobile importe les données du jour du CC (via Internet),
  • Le CC effectue ses visites du jour (en utilisant les données synchronisées),
  • Le CC retourne le soir à l’agence pour finaliser les saisies des visites du jour,
  • Le mobile publie les mises à jour des données dans le Backend.

Construction de la solution

Pour implémenter ce mécanisme de synchronisation des données, nous avons envisagé deux solutions :

  • Implémenter la synchronisation via des services REST,
  • Déléguer la synchronisation à une base de données distribuée (qui synchronise les données des mobiles avec un backend).

Comme décrit dans cet article, la première solution nécessite, entre autres, de traiter les points suivants :

  • Détection du mode offline,
  • Gestion de la persistance en mode déconnecté,
  • Gestion des ID des objets construits en mode offline,
  • Gestion des conflits d’écriture,
  • etc.

Afin de nous concentrer sur la valeur métier apportée à l'application, nous avons vite écarté cette solution (principe classique de ne pas tout réinventer, utiliser ce qui fonctionne, robuste et sécurisé).

La deuxième solution nécessite de trouver une base de données distribuée performante qui s’installe à la fois sur un serveur Linux et sur des mobiles Android (et iOS dans un soucis de pérennité). Pour des raisons de coût nous nous sommes limités aux bases de données open source.

Au moment où nous avons effectué notre comparatif de bases de données distribuées, nous avons étudié les solutions suivantes : Couchdb, Couchbase et Realm. Depuis notre étude, d’autres produits intéressants sont apparus comme la version MongoDB de realm.

Nous avons choisi d’utiliser Couchbase qui répond à nos besoins d’un point de vue développement :

  • Solution NoSQL (important car forte variabilité de la structure des données),
  • Langage de requête N1QL similaire au SQL,
  • Gestion du Full Text Search,
  • Bonne intégration à notre stack (module spring pour Couchbase).

Et aussi d’un point de vue technique :

  • Synchronisation entre le mobile et le backend réalisée via le protocole de communication HTTP/HTTPS,
  • Authentification des utilisateurs mobile via OpenID Connect,
  • Gestion de la tolérance aux pannes du serveur Couchbase via une architecture “cluster” et des mécanismes de réplication entre les noeuds du cluster.

Nous nous sommes inspirés de ces articles pour démarrer le développement de notre application.

Principe de fonctionnement de la synchronisation Couchbase

archi-couchbase

La synchronisation entre le mobile et les serveurs Couchbase est réalisée directement des mobiles vers le serveur. Nous avons donc implémenté toutes les règles de validation des documents côté mobile.

La synchronisation mobile / serveur Couchbase est basée sur l’utilisation de trois briques :

  • Couchbase Lite : une base de données embarquée sur mobile,
  • SyncGateway : un middleware (redondé) de synchronisation des bases de données mobiles avec la base de données centrale (synchronisation via les protocoles HTTP & WebSocket),
  • Un cluster Couchbase.

Côté mobile, nous créons un replicator, chargé de se connecter à la websocket exposée par la SyncGateway. Cette approche se nomme “offline-first” : nous écrivons dans notre base locale et une brique externe. Puis, le replicator, synchronise la donnée avec le monde extérieur. C’est une solution optimale pour une réplication assurée des données stockées localement.

La gestion des droits d’accès aux données grâce aux channels

Cette synchronisation, bien qu'idéale, entraîne deux obligations rapidement identifiées lors de nos développements :

  • Comment gérer les droits des utilisateurs sur les documents,
  • Comment éviter qu’un utilisateur ait une copie locale de toute la base de données.

Couchbase SyncGateway fournit une solution pour cela, les channels.

Un channel n’est pas un objet en tant que tel, il n’alloue pas de ressource, il ne se crée pas, ne se supprime pas. Il faut plutôt le voir comme une métadonnée, une étiquette apposée à un document. L’utilisateur peut décider d’écouter les channels qu’il souhaite. Ainsi, seuls les documents appartenant aux channels écoutés par l’utilisateur seront synchronisés ; si un channel est retiré d’un document, il sera alors supprimé des bases de données locales des terminaux l’ayant synchronisé. Il faut donc plus voir un channel comme une convention, entre un document et les utilisateurs cibles.

Bien sûr, il est possible de configurer dans la SyncGateway, les droits des utilisateurs sur les channels, et ainsi de dire que certains utilisateurs ne peuvent en voir qu’une partie.

Cette fonctionnalité permet donc une gestion fine des droits d’accès aux documents par les utilisateurs, et de ne pas synchroniser systématiquement l’intégralité de la base de données globale dans l’ensemble des terminaux connectés.

Unidirectional Data Flow

unidirectional-data-flow

Ce SDK offre les Live Queries. Ces requêtes sont exécutées automatiquement à chaque changement du dataset. On évite ainsi toute action explicite de refresh. La vue est avertie du changement de la donnée et se change en conséquence. On a alors plus de contrôle sur la data, donc moins de risque d’erreur.

Vers une app déclinable

Après un premier succès avec le MVP, nous avons continué notre accompagnement pendant plusieurs autres mois afin d'intégrer les retours terrains et autres fonctionnalités du Backlog. La v1.0 concernait uniquement les prestations de ménage / repassage. La version suivante devait embarquer les prestations de jardinage. Le projet étant maintenant maîtrisé et spécifié pour une autre prestation, nous avons pu entamé sa restructuration.

Enjeux

Le parcours d'une visite de type “Ménage/Repassage”, réalisé dans les différentes pièces d'une maison, n'a pas forcément de lien avec celle de type “Jardinage” ; pour autant, la qualification du client reste identique. La première étape fut donc d'identifier les parties communes de celles à décliner :

  • Un thème différent (pictos, couleurs, libellés),
  • Des données possiblement différentes,
  • Des étapes différentes de parcours,
  • Des règles de gestion propres et partagées.

Il nous fallait donc une solution permettant :

  • D’isoler les spécificités de chacune des fonctionnalités dans des unités logiques,
  • De partager du code commun entre les différentes fonctionnalités,
  • De créer ou enlever une fonctionnalité sans avoir à impacter l'existant (moins on a à y toucher mieux c'est).

En somme, nous souhaitions mettre en place une solution répondant à cette catégorisation par domaine fonctionnel.

Solutions

Rangement via des modules Gradle

L’utilisation des modules gradle est une première étape.

Cela nous permet de créer rapidement différents "espaces de rangement" dans lesquels mettre nos sources. Chacun de ces espaces couvrant, dans l'idéal, une fonctionnalité ou un ensemble de fonctionnalités.
Ainsi, pour un projet dont la masse de fichiers, de sources, est amenée à être importante, on subdivise notre projet en sous-unités plus faciles à appréhender et à maintenir.

Dans notre conception, nous avons un module "orchestrateur", app, exposé, qui est chargé de rediriger vers la bonne vue et le bon service.

modules

Cette modularisation s'est opérée au fil de l'eau. Au départ nous n’avions qu’une séparation entre notre Core, notre orchestrateur App et nos prestations.
Depuis, deux niveaux supplémentaires ont été ajoutés. Chaque niveau a bien sûr ses responsabilités :

  • Applicatif. C'est là que nous allons initialiser entre autres Crashlytics et Couchbase lite.
  • Prestations. Ils contiennent les écrans et services de chaque prestation
  • Commun métier. Ils contiennent tout ce qui est commun aux prestations et qui touche à la visite : règles de gestion, écrans en commun...
  • Commun fonctionnel. Ils contiennent des fonctionnalités indépendantes, partagées par les prestations, qui n’ont aucune référence à la visite.
  • Core. Il contient des classes utilitaires et des composants graphiques utilisés partout dans l’application.

Par ce biais, on peut facilement paralléliser les développements… on profite également de la compilation incrémentale de Gradle… conduisant à un scaling horizontal de la base de code !
Prochaine étape : externaliser les deux derniers niveaux dans des librairies distinctes.

Séparation des responsabilités et injection

Grâce à l'IoC (inversion of control), on peut récupérer les instances qui implémentent une interface donnée. Puis parmi celles-ci, on veut prendre celle qui est compatible avec notre type de prestation. Il faut imaginer que chaque module de prestation contient des implémentations de ces interfaces.
Nous avons donc utilisé Koin, librairie dédiée à l’injection de dépendance. Elle est très facile d’accès et puissante.

Ressources

Toutes les ressources (traductions, images, formes, etc.) ont été rangées dans les modules associés.

Pour naviguer, nous avons largement utilisé le Navigation Component de Jetpack. Il embarque un nouveau type de ressources : les navigation graphs. L’idée est de pouvoir créer tout ou partie de sa navigation, uniquement à l’aide des actions, sans se soucier des transactions de fragments, ni d’activités. C’est “magique”. Voici un exemple de rendu de graph dans Android Studio :

navigation-graph-sample

On peut même aller plus loin en injectant dynamiquement un navgraph dans notre fragment. Avec notre système d’IoC, chaque prestation possède ses graphs de navigations et les exposent. Ils sont ensuite injectés dynamiquement selon le type de visite.

Conclusion

La dématérialisation d’un processus papier en application mobile, dans un Time To Market éduit, n’est pas une mince affaire.
La nécessité d’allier vitesse d’exécution et pérennité du code délivré nécessite de prendre les bonnes décisions au bon moment. Il est primordial de définir une architecture simple et évolutive, quitte à entamer une refonte si le besoin le nécessite. Tout est une histoire de timing.

Comme bien souvent, l'utilisation de patterns et bonnes pratiques de développement permettent de garantir la souplesse nécessaire : offline-first, IoC, reactive programming et consorts.

On pourrait tenter quand on commence une nouvelle app de directement faire du multi-module. Pourtant, le coût en temps n’est pas négligeable si votre app est modeste. En revanche, utiliser des outils d’IoC comme Koin et bien organiser votre code de manière générale simplifiera le refactoring le jour où vous voudrez le faire.

In fine, le développement efficace d’applications natives robustes est de plus en plus aisé. L’avènement de Kotlin ainsi que les mises à jours constantes de Jetpack nous tirent vers le haut et facilitent l’accès à la plateforme, dans un écosystème où d’autres technos comme Flutter ou React Native ont su s’affirmer comme de réelles options.

Article co-écrit avec :
- Willy Rouvre, IT manager et responsable de l'offre mobile chez Ippon
- Mehdi El Kouhen, Architecte Cloud Devops
- Hector Basset, Ingénieur fullstack et data
- Arnaud Charpentier, Expert Technique
- Erwann Plouhinec, Ingénieur fullstack