Modèles de modularisation courants

Il n'existe pas de stratégie de modularisation unique adaptée à tous les projets. En raison de la nature flexible de Gradle, il existe peu de contraintes concernant l'organisation d'un projet. Cette page présente quelques règles générales et modèles courants que vous pouvez utiliser lorsque vous développez des applications Android multimodules.

Principe de cohésion élevée et de couplage faible

Pour caractériser un codebase modulaire, vous pouvez utiliser les propriétés de couplage et de cohésion. Le couplage mesure à quel point les modules dépendent les uns des autres. Dans ce contexte, la cohésion mesure les relations fonctionnelles entre les éléments d'un module. En règle générale, vous devez essayer de créer un couplage faible et une cohésion élevée :

  • Un couplage faible signifie que les modules doivent être aussi indépendants que possible les uns des autres, de sorte que les modifications apportées à un module n'ont aucun impact sur les autres, ou un impact minimal. Les modules ne doivent pas connaître le fonctionnement interne des autres modules.
  • Une cohésion élevée signifie que les modules doivent comprendre un ensemble de code faisant office de système. Ils doivent avoir des responsabilités clairement définies et rester dans les limites de certaines connaissances du domaine. Prenons l'exemple d'un e-book. Il peut s'avérer inapproprié de combiner des codes associés à des livres et des paiements au sein du même module, car il s'agit de deux domaines fonctionnels différents.

Types de modules

La façon dont vous organisez vos modules dépend principalement de l'architecture de votre application. Vous trouverez ci-dessous des types de modules courants que vous pouvez introduire dans votre application tout en suivant notre architecture d'application recommandée.

Modules de données

Un module de données contient généralement un dépôt, des sources de données et des classes de modèle. Les trois principales responsabilités d'un module de données sont les suivantes :

  1. Encapsuler toutes les données et la logique métier d'un domaine donné : chaque module de données doit être responsable de la gestion des données représentant un domaine donné. Il peut traiter de nombreux types de données tant qu'ils sont similaires.
  2. Exposer le dépôt en tant qu'API externe : l'API publique d'un module de données doit être un dépôt, car elle est chargée d'exposer les données au reste de l'application.
  3. Masquer toutes les informations concernant l'implémentation et les sources de données pour l'extérieur : les sources de données ne doivent être accessibles qu'aux dépôts du même module. Elles ne sont pas visibles depuis l'extérieur. Pour ce faire, utilisez le mot clé de visibilité private ou internal de Kotlin.
Figure 1 : Exemples de modules de données et leur contenu

Modules de fonctionnalités

Une fonctionnalité est une partie isolée du fonctionnement d'une application correspondant généralement à un écran ou à une série d'écrans étroitement liés, comme un processus d'inscription ou de règlement. Si votre application dispose d'une barre de navigation inférieure, il est probable que chaque destination soit une fonctionnalité.

Figure 2 : Chaque onglet de cette application peut être défini comme une fonctionnalité.

Les fonctionnalités sont associées aux écrans ou aux destinations de votre application. Par conséquent, il est probable qu'elles soient associées à une interface utilisateur et à une ViewModel pour gérer leur logique et leur état. Une fonctionnalité ne doit pas nécessairement être limitée à une seule vue ou destination de navigation. Les modules de fonctionnalités dépendent des modules de données.

Figure 3 : Exemples de modules de fonctionnalités et leur contenu

Modules d'applications

Les modules d'application constituent un point d'entrée de l'application. Ils dépendent de modules de fonctionnalités et fournissent généralement une navigation racine. Un module d'application unique peut être compilé pour plusieurs binaires grâce à des variantes de compilation.

Figure 4 : Graphique de dépendance des modules de type de produit *version de démo* et *version complète*

Si votre application cible plusieurs types d'appareils (automobile, Wear ou téléviseur, par exemple), définissez un module d'application pour chacun d'entre eux. Cela permet de séparer les dépendances spécifiques à la plate-forme.

Figure 5 : Graphique des dépendances de l'application Wear

Modules courants

Les modules courants, également appelés modules principaux, contiennent du code que d'autres modules utilisent fréquemment. Ils réduisent la redondance et ne représentent aucune couche spécifique dans l'architecture d'une application. Voici des exemples de modules courants :

  • Module d'interface utilisateur : si vous utilisez des éléments d'interface utilisateur personnalisés ou un branding élaboré dans votre application, vous devez envisager d'encapsuler la collection de widgets dans un module afin que toutes les fonctionnalités puissent être réutilisées. Cela peut vous aider à rendre votre interface utilisateur cohérente entre les différentes fonctionnalités. Par exemple, si votre thème est centralisé, vous pouvez éviter une refactorisation contraignante lorsqu'un rebranding a lieu.
  • Module d'analyse : le suivi est souvent dicté par les exigences de l'entreprise sans tenir compte de l'architecture logicielle. Les outils de suivi Analytics sont souvent utilisés dans de nombreux composants non associés. Dans ce cas, nous vous conseillons de créer un module d'analyse dédié.
  • Module réseau : lorsque de nombreux modules nécessitent une connexion réseau, vous pouvez envisager de disposer d'un module dédié à la proposition d'un client HTTP. Ceci est particulièrement utile lorsque votre client nécessite une configuration personnalisée.
  • Module utilitaire : les utilitaires, également appelés assistants, sont généralement de petits morceaux de code qui sont réutilisés dans l'application. Exemples d'utilitaires : assistants de test, fonction de mise en forme des devises, validateur d'adresse e-mail ou opérateur personnalisé.

Modules de test

Les modules de test sont des modules Android utilisés uniquement à des fins de test. Les modules contiennent du code, des ressources et des dépendances de test qui ne sont nécessaires que pour l'exécution de tests et qui ne sont pas requis pendant l'exécution de l'application. Les modules de test sont créés pour séparer le code spécifique au test de l'application principale, ce qui facilite la gestion et la maintenance du code du module.

Cas d'utilisation des modules de test

Les exemples suivants illustrent les situations dans lesquelles l'implémentation de modules de test peut être particulièrement utile :

  • Code de test partagé : si votre projet comporte plusieurs modules et qu'une partie du code de test s'applique à plusieurs modules, vous pouvez créer un module de test pour partager le code. Cela peut réduire le nombre de doublons et faciliter la gestion de votre code de test. Le code de test partagé peut inclure des classes utilitaires ou des fonctions, telles que des assertions ou des outils de mise en correspondance personnalisés, ainsi que des données de test, comme des réponses JSON simulées.

  • Configurations de compilation plus propres : les modules de test vous permettent de disposer de configurations de compilation plus propres, car elles peuvent avoir leur propre fichier build.gradle. Vous n'avez pas besoin d'encombrer le fichier build.gradle de votre module d'application avec des configurations uniquement pertinentes pour les tests.

  • Tests d'intégration : les modules de test peuvent être utilisés pour stocker les tests d'intégration servant à tester les interactions entre les différentes parties de votre application, y compris l'interface utilisateur, la logique métier, les requêtes réseau et les requêtes de base de données.

  • Applications à grande échelle : les modules de test sont particulièrement utiles pour les applications à grande échelle avec un codebase complexe et plusieurs modules. Dans ce cas, les modules de test peuvent contribuer à améliorer l'organisation et la gestion du code.

Figure 6 : Les modules de test peuvent être utilisés pour isoler des modules qui, autrement, dépendraient les uns des autres.

Communication de module à module

Les modules se trouvent rarement dans des espaces totalement séparés et s'appuient souvent sur d'autres modules pour communiquer avec eux. Il est important de limiter le couplage même lorsque les modules fonctionnent ensemble et échangent fréquemment des informations. Parfois, une communication directe entre deux modules n'est pas souhaitable, comme dans le cas de contraintes d'architecture. Cela peut également être impossible, comme avec des dépendances cycliques.

Figure 7 : Communication directe bidirectionnelle est impossible entre les modules en raison de dépendances cycliques Un module de médiation est nécessaire pour coordonner le flux de données entre deux autres modules indépendants.

Pour résoudre ce problème, vous pouvez utiliser un troisième module pour la médiation entre deux autres modules. Le module de médiation peut être à l'écoute des messages des deux modules et les transférer si nécessaire. Dans notre exemple d'application, l'écran de paiement doit savoir quel livre acheter, même si l'événement provient d'un écran distinct qui fait partie d'une autre fonctionnalité. Dans ce cas, le médiateur correspond au module qui gère le graphique de navigation (généralement un module d'application). Dans l'exemple, nous utilisons la navigation pour transmettre les données de la fonctionnalité de la page d'accueil à la fonctionnalité de règlement à l'aide du composant Navigation.

navController.navigate("checkout/$bookId")

La destination de règlement reçoit un ID de livre comme argument, qu'elle utilise pour extraire des informations sur le livre. Vous pouvez utiliser l'identifiant d'état enregistré pour récupérer les arguments de navigation dans le ViewModel d'une fonctionnalité de destination.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

Vous ne devez pas transmettre d'objets en tant qu'arguments de navigation. Utilisez plutôt des identifiants simples que les fonctionnalités peuvent utiliser pour accéder aux ressources souhaitées et les charger à partir de la couche de données. De cette façon, vous maintenez un couplage faible et n'enfreignez pas le principe de la référence unique.

Dans l'exemple ci-dessous, les deux modules de fonctionnalités dépendent du même module de données. Cela permet de minimiser la quantité de données que le module de médiation doit transférer et de limiter le couplage entre les modules. Au lieu de transmettre des objets, les modules doivent échanger des identifiants primitifs et charger les ressources depuis un module de données partagé.

Figure 8 : Deux modules de fonctionnalité reposant sur un module de données partagé

Inversion des dépendances

L'inversion des dépendances consiste à organiser votre code de sorte que l'abstraction soit distincte d'une implémentation concrète.

  • Abstraction : un contrat qui définit la façon dont les composants ou les modules de votre application interagissent entre eux. Les modules d'abstraction définissent l'API de votre système et contiennent des interfaces et des modèles.
  • Implémentation concrète : les modules qui dépendent du module d'abstraction et implémentent le comportement d'une abstraction.

Les modules qui s'appuient sur le comportement défini dans le module d'abstraction doivent dépendre de l'abstraction elle-même uniquement, et non des implémentations spécifiques.

Figure 9 : Au lieu des modules de haut niveau qui dépendent directement des modules de bas niveau, les modules de haut niveau et d'implémentation dépendent du module d'abstraction.

Exemple

Imaginez un module de fonctionnalité qui a besoin d'une base de données pour fonctionner. Le module de fonctionnalité ne concerne pas la mise en œuvre de la base de données, qu'il s'agisse d'une base de données Room locale ou d'une instance Firestore distante. Il suffit de stocker et de lire les données de l'application.

Pour ce faire, le module de fonctionnalité dépend du module d'abstraction plutôt que d'une implémentation de base de données spécifique. Cette abstraction définit l'API de base de données de l'application. En d'autres termes, elle définit les règles d'interaction avec la base de données. Cela permet au module de fonctionnalité d'utiliser n'importe quelle base de données sans avoir à connaître ses détails d'implémentation sous-jacents.

Le module d'implémentation concret fournit l'implémentation réelle des API définies dans le module d'abstraction. Pour ce faire, le module d'implémentation dépend également du module d'abstraction.

Injection de dépendances

Vous vous demandez peut-être comment le module de fonctionnalité est connecté au module d'implémentation. La réponse est Injection de dépendances. Le module de fonctionnalité ne crée pas directement l'instance de base de données requise. Au lieu de cela, il spécifie les dépendances dont il a besoin. Ces dépendances sont ensuite fournies en externe, généralement dans le module d'application.

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

Avantages

Les avantages de la séparation de vos API et de leurs implémentations sont les suivants :

  • Interchangeabilité : avec une séparation claire entre l'API et les modules d'implémentation, vous pouvez développer plusieurs implémentations pour une même API et passer de l'une à l'autre sans modifier le code qui utilise l'API. Cela peut être particulièrement utile dans les cas où vous souhaitez fournir des fonctionnalités ou des comportements différents selon le contexte. Par exemple, une implémentation fictive des tests par rapport à une implémentation réelle pour la production.
  • Découplage : la séparation signifie que les modules utilisant des abstractions ne dépendent d'aucune technologie spécifique. Si vous choisissez ultérieurement de remplacer votre base de données Room par une base de données Firestore, il sera plus facile de le faire, car seules les modifications se produiront dans le module spécifique qui effectue la tâche (module d'implémentation) et n'affecteront pas les autres modules basés sur l'API de votre base de données.
  • Facilité des tests : séparer les API de leurs implémentations peut considérablement faciliter les tests. Vous pouvez écrire des scénarios de test pour les contrats d'API. Vous pouvez également utiliser différentes implémentations pour tester différents scénarios et cas particuliers, y compris des implémentations fictives.
  • Amélioration des performances de compilation : lorsque vous séparez une API et son implémentation dans différents modules, les modifications apportées au module d'implémentation ne forcent pas le système de compilation à recompiler les modules en fonction du module d'API. Cela permet de réduire les durées de compilation et d'augmenter la productivité, en particulier pour les projets de grande envergure où les durées de compilation peuvent être importantes.

Quand séparer

Il est utile de séparer vos API de leurs implémentations dans les cas suivants :

  • Diversité des fonctionnalités : si vous pouvez implémenter certaines parties de votre système de différentes manières, une API claire permet d'interchanger différentes implémentations. Par exemple, vous pouvez disposer d'un système de rendu utilisant OpenGL ou Vulkan, ou d'un système de facturation fonctionnant avec Play ou votre API de facturation interne.
  • Multiplicité des applications : si vous développez plusieurs applications qui partagent des fonctionnalités pour différentes plates-formes, vous pouvez définir des API courantes et développer des implémentations spécifiques pour chaque plate-forme.
  • Indépendance des équipes : la séparation permet à différents développeurs ou différentes équipes de travailler simultanément sur différentes parties du codebase. Les développeurs doivent s'assurer de comprendre les contrats d'API et de les utiliser correctement. Ils ne doivent pas se soucier des détails de l'implémentation des autres modules.
  • Codebase volumineux : lorsque le codebase est volumineux ou complexe, la séparation de l'API de l'implémentation facilite la gestion du code. Il vous permet de décomposer le codebase en unités plus précises, compréhensibles et faciles à gérer.

Comment procéder à la mise en œuvre ?

Pour implémenter l'inversion des dépendances, procédez comme suit :

  1. Créer un module d'abstraction : ce module doit contenir des API (interfaces et modèles) qui définissent le comportement de votre caractéristique.
  2. Créer des modules d'implémentation : les modules d'implémentation doivent s'appuyer sur le module d'API et implémenter le comportement d'une abstraction.
    Au lieu des modules de haut niveau qui dépendent directement des modules de bas niveau, les modules de haut niveau et d&#39;implémentation dépendent du module d&#39;abstraction.
    Figure 10 : Les modules d'implémentation dépendent du module d'abstraction.
  3. Rendre les modules de haut niveau dépendants des modules d'abstraction : au lieu de dépendre directement d'une implémentation spécifique, faites en sorte que vos modules dépendent des modules d'abstraction. Les modules de haut niveau n'ont pas besoin de connaître les détails de l'implémentation, mais uniquement le contrat (API).
    Les modules de haut niveau dépendent d&#39;abstractions, et non d&#39;une implémentation.
    Figure 11 : Les modules de haut niveau dépendent d'abstractions, et non d'une implémentation.
  4. Fournir le module d'implémentation : enfin, vous devez fournir l'implémentation réelle de vos dépendances. L'implémentation spécifique dépend de la configuration de votre projet, mais le module d'application est généralement un bon choix pour ce faire. Pour fournir l'implémentation, spécifiez-la en tant que dépendance pour la variante de compilation sélectionnée ou pour un ensemble de sources de test.
    Le module d&#39;application fournit une implémentation réelle.
    Figure 12 : Le module d'application fournit une implémentation réelle.

Bonnes pratiques générales

Comme nous l'avons mentionné au départ, il n'existe pas de méthode unique et adéquate pour développer une application multimodule. Tout comme il existe de nombreuses architectures logicielles, il existe de nombreuses façons de modulariser une application. Néanmoins, les recommandations générales qui suivent peuvent vous aider à rendre votre code plus lisible, plus facile à gérer et à tester.

Assurer la cohérence de votre configuration

Chaque module entraîne des frais généraux de configuration. Si le nombre de vos modules atteint un certain seuil, il sera difficile de gérer une configuration qui soit cohérente. Par exemple, il est important que les modules utilisent des dépendances de la même version. Si vous devez mettre à jour un grand nombre de modules simplement pour remplacer une version de dépendance, non seulement cela va demander des efforts, mais cela induira également des erreurs potentielles. Pour résoudre ce problème, vous pouvez utiliser l'un des outils de Gradle pour centraliser votre configuration :

  • Les catalogues de versions constituent une liste sécurisée des types de dépendances générés par Gradle lors de la synchronisation. Ces catalogues centralisent toutes vos dépendances et sont disponibles pour tous les modules d'un projet.
  • Utilisez des plug-ins de convention pour partager une logique de compilation entre les modules.

Exposer le moins possible d'informations

L'interface publique d'un module doit être minimale et n'exposer que les informations nécessaires. Elle ne doit exposer aucune information sur l'implémentation. Limitez le niveau d'accès autant que possible. Utilisez le champ d'application de visibilité private ou internal de Kotlin pour restreindre l'accès aux déclarations à certains modules. Lorsque vous déclarez des dépendances dans votre module, privilégiez l'implementation plutôt que l'api. Cette dernière divulgue les dépendances transitives aux utilisateurs de votre module. L'utilisation d'une implémentation peut optimiser la durée de compilation, car elle réduit le nombre de modules à recompiler.

Favoriser les modules Kotlin et Java

Android Studio est compatible avec trois types de modules essentiels :

  • Les modules d'application constituent un point d'entrée dans votre application. Ils peuvent contenir du code source, des ressources, des assets et un AndroidManifest.xml. La sortie d'un module d'application est un Android App Bundle ou un Android Application Package.
  • Le contenu des modules de la bibliothèque est identique à celui des modules de l'application. Ils sont utilisés par d'autres modules Android en tant que dépendance. La sortie d'un module de bibliothèque est une archive Android (AAR) identique aux modules d'application d'un point de vue structurel, mais compilée dans un fichier d'archive Android (AAR) qui peut ensuite être utilisé par d'autres modules en tant que dépendance. Un module de bibliothèque permet d'encapsuler et de réutiliser la même logique et les mêmes ressources dans de nombreux modules d'application.
  • Les bibliothèques Kotlin et Java ne contiennent aucune ressource, aucun asset ni aucun fichier manifeste Android.

Étant donné que les modules Android entraînent des frais généraux, il est préférable d'utiliser autant que possible le genre Kotlin ou Java.