Architecture à plugins avec Koin

Littéralement, en anglais, on parle de plug une clé USB. Cela se fait grâce à des ports qui sont exposés par l’ordinateur. Le principe de l’architecture à plugins dans le logiciel est le même. Une application expose des ports pour ajouter, modifier ou supprimer des comportements via des composants externes. Ce sont les plugins.

pasted-image-0-1

Si vous êtes dev Android, ou plus largement utilisateur d’un IDE basé sur IntelliJ IDEA, vous devez être familiarisé avec cette architecture. En effet, on peut étendre les usages de ce logiciel via la marketplace et l’installation de plugins tiers : linters (Detekt), support de framework (Flutter) ou encore passage de certification (Google Developers Certification Plugin).

On peut schématiser le principe sous forme de puzzle, où les plugins sont des pièces qui viennent se greffer à l’IDE :

pasted-image-0--1--1

Une expérience récente d’implémentation de cette architecture sur Android

Récemment, j’ai travaillé sur un projet de transformation numérique pour une entreprise de service à la personne. Elle propose des prestations diverses, du ménage / repassage au jardinage, en passant par l’accompagnement des seniors.

Historiquement, le chargé de clientèle récupérait le besoin à l'aide de formulaires papiers imprimés, nécessitant une ressaisie côté backoffice. Nous avons accompagné ses équipes dans le but de dématérialiser ce parcours avec une application mobile Android. Elle offrait une synchronisation automatique des données (moins d'erreurs de recopie), un accompagnement lors de la visite via des contrôles / suggestions, et enfin la possibilité de contractualiser directement sur mobile, le tout, en mode offline.

La première version de l’application prenait en compte une seule prestation. L’objectif à long terme était d’en ajouter petit à petit. C’était dans un premier temps plutôt simple. En fait, la loi de Pareto s’appliquait : 80% des fonctionnalités étaient les mêmes, 20% devaient être ajoutées ou modifiées.

Avec l'introduction d'une deuxième prestation, c’était donc facile de réutiliser le code implémenté. Nous nous en sortions avec de la séparation par package, et l’utilisation de branches conditionnelles aux endroits clés.

À trois prestations, on se rapprochait de la pente qui mène aux enfers. L’architecture devait évoluer.

Navigation dans l’application

En tant qu’utilisateur de l’application, il faut d’abord renseigner son identifiant / mot de passe. Ensuite, on retrouve l’ensemble des visites commerciales à effectuer sous forme de planning. Bien sûr, chaque visite peut correspondre à un type de prestation différent. Au clic sur le détail d’une visite, l’application doit être personnalisée en fonction de ce type de prestation. Cela comprend les couleurs, logos et labels, mais aussi des ajouts de nouveaux écrans.

Finalement, si on recontextualise par rapport au précédent projet, ce sont des plugins de prestation que nous voulons ajouter à l’application.

pasted-image-0--2-

Ce que je vous propose, c’est de suivre l’implémentation d’une architecture à plugins.

Implémenter sa propre architecture à plugins

Objectif

Je vous propose de vous référer au GitHub exemple : https://github.com/Thomas-Boutin/PluginArchitectureSample

C’est une application Android qui utilise le principe d’architecture à plugins :
Un écran d’accueil qui liste des animaux
Un écran de détail, personnalisé en fonction de l’animal sélectionné

Le détail affiche :
Le nom de l’animal
Une couleur pour le texte
Une image

Ici, l’extension de l’application se fait via l’exposition d’interfaces. Ce sont les ports sur lesquels se branchent les plugins. La configuration des propriétés (nom, couleur et image) se fait via ces interfaces.

Première implémentation sans plugin

J’ai décidé de faire la partie UI avec Jetpack Compose. Créer les interfaces de manière déclarative est plus sympa pour moi, et sans doute pour vous, que le XML. Pour la navigation, j’ai utilisé Compose Navigation.

Concrètement, j’ai défini mes routes Home et Detail dans une sealed class, NavigationContract :

sealed class NavigationContract {
   abstract val route: String

   @Composable
   abstract fun content(backStackEntry: NavBackStackEntry)

   open val arguments: List<NamedNavArgument> = emptyList()

   class Home(
       private val goToDetail: (Animal.AnimalType) -> Unit
   ) : NavigationContract() {
       override val route = "home"

       @Composable
       override fun content(backStackEntry: NavBackStackEntry) {
           HomeScreen(goToDetail = goToDetail)
       }
   }

object Detail : NavigationContract() {
   private const val ANIMAL_TYPE = "ANIMAL_TYPE"
   private const val ANIMAL_TYPE_PARAMETER = "{$ANIMAL_TYPE}"

   override val route = "detail/$ANIMAL_TYPE_PARAMETER"

   override val arguments = listOf(
       navArgument(ANIMAL_TYPE) { type = NavType.StringType }
   )

   fun asDirection(animalType: Animal.AnimalType) = route.replace(ANIMAL_TYPE_PARAMETER, animalType.name)

   @Composable
   override fun content(backStackEntry: NavBackStackEntry) {
       val arguments = requireNotNull(backStackEntry.arguments)
       val animalType = requireNotNull(arguments.getString(ANIMAL_TYPE))
           .let(Animal.AnimalType::valueOf)

       when (animalType) {
           Animal.AnimalType.DOG -> DetailScreen(detailConfiguration = DogConfiguration)
           Animal.AnimalType.CAT -> DetailScreen(detailConfiguration = CatConfiguration)
       }
   }
}

Chaque NavigationContract possède :

  • Une route qui est à la fois un identifiant et une URI d’accès au composable
  • Des arguments, paramètres contenus dans la route
  • Une fonction optionnelle asDirection, pour créer une route avec les paramètres remplis
  • Une fonction content, pour créer le Composable à partir (éventuellement) de la backStackEntry. Utiliser une fonction est un moyen de faire une initialisation paresseuse

J’ai choisi d’ajouter une couche d’abstraction à Compose Navigation. En effet, il utilise par défaut une string pour identifier une route. À l’utilisation, je trouve que c’est plus sûr de travailler avec un object et ses propriétés :

@Composable
fun MainScreen(navController: NavHostController) {
   val detail = NavigationContract.Detail
   val home = NavigationContract.Home { animalType ->
       navController.navigate(detail.asDirection(animalType))
   }

   NavHost(navController = navController, startDestination = home.route) {
       addDestination(home)
       addDestination(detail)
   }
}

private fun NavGraphBuilder.addDestination(
   navigationContract: NavigationContract,
) = composable(
   route = navigationContract.route,
   arguments = navigationContract.arguments
) { backStackEntry ->
   navigationContract.content(backStackEntry)
}

J’ai choisi aussi d’ajouter la fonction addDestination au NavGraphBuilder pour l’ajout d’un composable. Fait notable : passer une fonction @Composable par référence n’est pas possible. Impossible donc de noter en paramètre de addDestination content = navigationContract::content

L’application suit le pattern Single Activity par souci de simplicité. J’ai donc défini une MainActivity qui initialise la navigation :

class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           MainScreen(rememberNavController())
       }
   }
}

Le Composable est paramétrable via une interface DetailConfiguration :

@Composable
fun DetailScreen(
   modifier: Modifier = Modifier,
   detailConfiguration: DetailConfiguration = DefaultDetailConfiguration
) {
   Column(
       modifier = modifier.fillMaxSize(),
       verticalArrangement = Arrangement.Center,
       horizontalAlignment = Alignment.CenterHorizontally
   ) {
       Text(
           modifier = modifier.padding(10.dp),
           text = stringResource(detailConfiguration.nameResource),
           textAlign = TextAlign.Center,
           color = detailConfiguration.color
       )
       Image(
           painter = painterResource(detailConfiguration.imageResource),
           contentDescription = stringResource(R.string.animal_image)
       )
   }
}

DetailConfiguration contient les trois paramètres à afficher : un nom, une image et une couleur de texte :

interface DetailConfiguration {
   val nameResource: Int
   val imageResource: Int
   val color: Color
}

Enfin, les animaux présents sont affichés sur le HomeScreen :

private val animals = listOf(
   Animal(AnimalName("Rex"), Animal.AnimalType.DOG),
   Animal(AnimalName("Garfield"), Animal.AnimalType.CAT)
)

@Composable
fun HomeScreen(modifier: Modifier = Modifier, goToDetail: (Animal.AnimalType) -> Unit) {
   LazyColumn(
       modifier = modifier.fillMaxSize(),
       verticalArrangement = Arrangement.Center,
       horizontalAlignment = Alignment.CenterHorizontally
   ) {
       items(animals) { animal ->
           Card(
               modifier = modifier
                   .height(100.dp)
                   .width(200.dp)
                   .padding(top = 10.dp)
                   .clickable { goToDetail(animal.type) },
           ) {
               Text(
                   modifier = modifier
                       .wrapContentHeight(),
                   text = animal.format(),
                   textAlign = TextAlign.Center
               )
           }
       }
   }
}

Au clic, c’est là qu’on va passer le type d’animal sélectionné et changer l’affichage en fonction; via le modifier .clickable { goToDetail(animal.type) }

Le type est ensuite passé au callback goToDetail: (Animal.AnimalType) -> Unit. Il a été défini à la création de la route Home.

val home = NavigationContract.Home { animalType ->
   navController.navigate(detail.asDirection(animalType))
}

Enfin, pour boucler la boucle, c’est dans la fonction content du DetailScreen que le type sera utilisé pour paramétrer DetailConfiguration.

@Composable
override fun content(backStackEntry: NavBackStackEntry) {
   val arguments = requireNotNull(backStackEntry.arguments)
   val animalType = requireNotNull(arguments.getString(ANIMAL_TYPE))
       .let(Animal.AnimalType::valueOf)

   when (animalType) {
       Animal.AnimalType.DOG -> DetailScreen(detailConfiguration = DogConfiguration)
       Animal.AnimalType.CAT -> DetailScreen(detailConfiguration = CatConfiguration)
   }
}

Le type d’animal est passé en argument. Je le récupère en passant par la backstack. En fonction, je passe une configuration dédiée chat ou chien pour l’écran de détail. Par exemple :

object CatConfiguration : DetailConfiguration {
   override val nameResource = R.string.cat
   override val imageResource = R.drawable.cat
   override val color: Color = Color.Red
}

En termes d’enchaînement d’écrans, le résultat est le suivant :-)

Untitled

Refactoring step 1 : isolation

Jusqu’ici, je n’ai pas parlé de modularisation du projet. Pourtant, c’est un bon moyen d’isoler les responsabilités, et surtout dans notre cas de mettre en lumière quels sont les plugins de l’application.

La première étape du refactoring est donc de répartir le code dans des modules que nous allons créer. Cela garantira une isolation forte et donc zéro triche pour la démonstration à venir. La structuration cible est la suivante :

pasted-image-0--3-

App, le point d’entrée de l’application contiendra d’abord un plugin chien et un plugin chat. Ces derniers implémenteront l’interface DetailConfiguration placée dans le module Detail. Ainsi, Le module Dog et le module Cat contiendront leurs configurations (nom de l’animal, image, et couleur du nom). La migration concerne donc les fichiers DogConfiguration, CatConfiguration et leurs ressources respectives (drawables et strings).

Refactoring step 2 : utilisation de Koin pour inverser les dépendances

L’implémentation actuelle fait porter la logique de choix de la configuration à la route Detail. Je pense que c’est lui accorder trop de responsabilités. Le but de ce refactoring est de pousser les dépendances hors des routes. Koin est un service locator qui répond à ce besoin. Mieux, il embarque des extensions pour Compose.

Pour commencer, il faut intégrer Koin aux dépendances du module App (dans build.gradle).

def koin_version = '3.2.0'
implementation "io.insert-koin:koin-android:$koin_version"
implementation "io.insert-koin:koin-androidx-compose:$koin_version"

Ensuite, il faut définir les dépendances qui seront manipulées. Ici, DogConfiguration et CatConfiguration. Koin fonctionne avec la définition de modules :

val appModule = module {
   single<DetailConfiguration>(named(Animal.AnimalType.DOG.name)) {
       DogConfiguration
   }
   single<DetailConfiguration>(named(Animal.AnimalType.CAT.name)) {
       CatConfiguration
   }
}

Le bloc ci-dessus définit deux implémentations de DetailConfiguration. Normalement, single interdit de charger deux instances d’un même type. L’astuce est d’ajouter un qualifier à la dépendance. C’est fait avec la fonction named. Puisque la configuration est liée au type de l’animal, ce dernier est un qualifier tout trouvé.

On rattache le module Koin au contexte Android avec l’instruction startKoin. Le cas général est de prendre le contexte le plus global comme référence. Chez Android, il s’agit de Application. Il n’est pas possible de modifier l’Application utilisée par défaut. La méthode consiste à étendre cette classe pour définir sa propre Application :

class PluginArchitectureSampleApplication: Application() {
   override fun onCreate() {
       super.onCreate()

       startKoin {
           androidContext(this@PluginArchitectureSampleApplication)
           androidLogger()
           modules(appModule)
       }
   }
}

On indique au démarrage que nous utilisons appModule avec modules(...).

Ensuite, il faut indiquer dans le manifest qu’on utilise cette implémentation

<application
   android:name=".PluginArchitectureSampleApplication"
//…

Enfin, il faut mettre à jour la route Detail pour pousser la configuration vers l’extérieur :

class Detail(
   private val detailConfigurationFrom: @Composable (Animal.AnimalType) -> DetailConfiguration
) : NavigationContract() {
   override val route = ROUTE

   override val arguments = listOf(
       navArgument(ANIMAL_TYPE) { type = NavType.StringType }
   )

   @Composable
   override fun content(backStackEntry: NavBackStackEntry) {
       val arguments = requireNotNull(backStackEntry.arguments)
       val animalType = requireNotNull(arguments.getString(ANIMAL_TYPE))
           .let(Animal.AnimalType::valueOf)

       DetailScreen(detailConfiguration = detailConfigurationFrom(animalType))
   }

   companion object {
       private const val ANIMAL_TYPE = "ANIMAL_TYPE"
       private const val ANIMAL_TYPE_PARAMETER = "{$ANIMAL_TYPE}"
       const val ROUTE = "detail/$ANIMAL_TYPE_PARAMETER"

       fun asDirection(animalType: Animal.AnimalType) = ROUTE.replace(
           ANIMAL_TYPE_PARAMETER,
           animalType.name
       )
   }
}

Detail est passé d’object à class. C’était requis pour prendre en paramètre de constructeur une fonction qui récupère une DetailConfiguration à partir d’un type d’animal. Par conséquent, les constantes et la fonction asDirection ont dû être déplacées dans un companion object.

Le lien entre la route, la configuration et Koin se fait dans le MainScreen avec la fonction get :

@Composable
fun MainScreen(navController: NavHostController) {
   val detail = NavigationContract.Detail { animalType -> get(qualifier = named(animalType.name)) }

Koin utilise l’inférence de type pour aller chercher dans ses modules une DetailConfiguration. Comme indiqué précédemment, on choisit la bonne configuration avec animalType.name

Refactoring step 3 : création de module Koin dans chaque plugin

Désormais, la configuration est déléguée à Koin. En revanche, on peut se demander si ce n’est pas plus pertinent que chaque plugin vienne avec sa propre configuration.

Il y a un problème : Animal et AnimalType ne sont pas accessibles dans les plugins puisqu’ils sont définis dans App. On peut considérer que ces deux classes appartiennent à une couche d’accès aux données (data layer). L’idée est donc de les déplacer dans un nouveau module Data. Il sera accessible dans les plugins :

pasted-image-0--4-

La prochaine étape est la création d’un module Koin par plugin (après ajout des dépendances Koin dans le build.gradle) pour déplacer la déclarations des configurations :

Dans le plugin Dog :

val dogModule = module {
   single<DetailConfiguration>(named(Animal.AnimalType.DOG.name)) {
       DogConfiguration
   }
}

Dans le plugin Cat :

val catModule = module {
   single<DetailConfiguration>(named(Animal.AnimalType.CAT.name)) {
       CatConfiguration
   }
}

La dernière étape est l’import de ces modules dans PluginArchitectureSampleApplication :

startKoin {
   androidContext(this@PluginArchitectureSampleApplication)
   androidLogger()
   modules(catModule, dogModule)
}

Refactoring step 4 (optionnel) : charger et décharger des modules dynamiquement

Dans notre projet, nous avions fait le choix de charger et décharger des modules Koin de manière dynamique. En effet, cet outil ne nous borne pas au chargement avec startKoin.

Nous avons choisi ce moyen pour ne pas devoir utiliser de qualifier. À la place, nous avons maintenu un mapper discriminant -> module Koin pour chaque plugin. Ainsi, le get() de Koin suffisait pour obtenir une dépendance.

Je vais vous présenter comment l’implémenter dans ce projet.

Il faut commencer par enlever les qualifiers des modules :

Dans le plugin Dog :

val dogModule = module {
   single<DetailConfiguration> {
       DogConfiguration
   }
}

Dans le plugin Cat :

val catModule = module {
   single<DetailConfiguration> {
       CatConfiguration
   }
}

Ensuite, enlever ces modules du chargement initial :

startKoin {
   androidContext(this@PluginArchitectureSampleApplication)
   androidLogger()
}

À partir de ce point, il faut modifier la récupération de la configuration dans le MainScreen :

val detail = NavigationContract.Detail { animalType ->
   setupDetailModuleWith(animalType)

   get()
}

Avec dans la fonction setupDetailModuleWith :

@Composable
private fun setupDetailModuleWith(animalType: Animal.AnimalType) {
   val animalsModules = Animal.AnimalType.values().map(::detailModuleFor)
   val detailModule = detailModuleFor(animalType)

   with(getKoin()) {
       unloadModules(animalsModules)
       loadModules(modules = listOf(detailModule))
   }
}

Tous les modules d’animaux sont déchargés, puis on charge le module de détail voulu.

Une autre manière de faire est de spécifier le paramètre allowOverride à true :

@Composable
private fun setupDetailModuleWith(animalType: Animal.AnimalType) {
   getKoin().loadModules(modules = listOf(detailModuleFor(animalType)), allowOverride = true)
}

Cette notation est plus concise mais il faut veiller à ne pas écraser des modules hors du scope voulu.

Conclusion

Ce n’est pas un hasard si je vous ai présenté plusieurs étapes de refactoring. En effet, c’était précisément le cheminement que nous avions suivi pour notre projet client.

Au travers de l’architecture à plugins, nous réalisons que l’inversion de dépendances est une fois encore primordiale. Dans ce contexte, je trouve que Koin est un super outil pour réduire l’adhérence entre dépendances.

On peut émettre des réserves sur cette solution. En effet, créer des plugins amène beaucoup de configuration additionnelle. Sur ce petit projet ou sur un autre, on peut alors légitimement considérer que c’est de l’overengineering. Il peut alors être tentant de se lancer dans de la duplication de code. Voire même de faire porter aux vues la logique de configuration.

En revanche, lorsque votre produit grossira (spoiler : 100% des projets faits chez Ippon ont eu besoin de plus de 2 modules), réutiliser du code ne sera plus un choix mais un prérequis. On pensera à la maintenabilité et à la séparation claire des responsabilités. Y veiller, ce n’est pas uniquement pour la beauté du code, c’est surtout pour continuer à délivrer efficacement de la valeur !