Lorsque votre application Android commence à grossir ou lorsque vous avez des portions que vous souhaitez isoler fortement les unes des autres, il y a de fortes chances que vous vous dirigiez vers une architecture multi-module. Si on se réfère à la documentation Android, on trouvera parmi les bénéfices recherchés :
Bénéfice | Description |
---|---|
Réutilisabilité | La modularisation ouvre des possibilités de partage de code et de création de plusieurs applications à partir de la même base. Par exemple, une bibliothèque d’authentification pourrait être réutilisée dans une autre application. |
Strict contrôle de la visibilité | Les modules, en particulier en kotlin avec le mot clé internal, vous permettent de contrôler facilement ce que vous souhaitez exposer. |
Livraison personnalisée | Play Feature Delivery est une fonctionnalité qui vient avec les app bundles. Elle permet de livrer certaines fonctionnalités de votre application de manière conditionnelle ou à la demande. Par exemple, un utilisateur téléchargera un niveau d’un jeu vidéo seulement une fois qu’il sera débloqué. L’avantage étant de réduire la taille de l’application lors du premier téléchargement. |
Comme l’indique aussi la documentation officielle, modulariser est une manière d’atteindre ces autres bénéfices :
Bénéfice | Description |
---|---|
Découplage / Encapsulation | Modulariser pousse à séparer le code en fonction de ses responsabilités, réduisant le risque de code spaghetti. |
Facilitation de la gouvernance | Il est plus facile de faire travailler plusieurs équipes sur la même application en ayant des modules distincts. Un module A peut être affecté à une équipe X et un module B à une équipe Y. |
Testabilité | Puisque l’isolation des modules est censée être plus grande, c’est aussi censé être plus facile de tester en isolation ces mêmes modules. |
Temps de build | Gradle, qui est l’outil le plus utilisé pour construire des applications Android, se marie très bien avec la modularisation. Gradle utilise de la compilation incrémentale, du cache de build et de la parallélisation de build, ce qui est idéal avec des modules isolés. |
Passage à l’échelle | Tout cela contribue à aider le passage à l’échelle de votre application, là où sinon des problèmes organisationnels et / ou de temps de compilation peuvent survenir. |
Voici un exemple d’app multi modules que je décrivais sur le blog Ippon dans cet article :

Chaque bloc est un module. Le découpage choisi à l’époque en 2020 est perfectible, mais l’idée étant de donner un exemple de ce que cela peut donner !
Module Android et module Gradle
Un module Android se traduit donc par un module Gradle. Lorsque vous en créez un via Android Studio (clic droit sur le dossier parent du projet -> new -> Module), on découvre qu’il en existe plusieurs types :

Votre choix déterminera la structure des dossiers généré sera et le type de plugin gradle importé. Par exemple, si votre module est une application, com.android.application sera importé.
Ci-dessous un exemple pour le module app, dans le fichier build.gradle.kts, qui déclare dans ses plugins alias(libs.plugin.android.application).

C’est une instruction qui correspond à une entrée dans nos version catalogs :

Si votre module est une bibliothèque, alors ça sera le même principe mais avec en plugin com.android.library de déclaré.
Version catalogs
Les version catalogs sont une fonctionnalité de gradle qui enrichit la manière de gérer les dépendances. Dans une manière plus classique, une dépendance est importée comme cela dans un fichier build.gradle.kts :
dependencies {
implementation("<group>:<name>:<version>")
}
La problématique, c’est que si la dépendance doit être réutilisée ailleurs dans un autre module, il faut exactement écrire la même chose pour chaque module consommateur. On prend donc le risque d’avoir une version différente ou même un artefact différent. À la lecture, on ne peut pas déterminer que les dépendances doivent être les mêmes.
Les version catalogs apportent des réponses en externalisant ces déclarations dans un ou plusieurs fichiers .toml visibles de tous les modules du projet. Ce fichier se situe par défaut dans le répertoire gradle à la racine du projet :

Dans ce fichier on définit tout d’abord des versions, ensuite des libraries et enfin des plugins :
[versions]
activityComposeVersion = "1.10.1"
agpVersion = "8.12.0"
...
[libraries]
android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agpVersion" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityComposeVersion" }
...
[plugins]
android-application = { id = "com.android.application", version.ref = "agpVersion" }
android-library = { id = "com.android.library", version.ref = "agpVersion" }
Des accesseurs sont générés automatiquement à la compilation. Il y a alors de l’autocomplétion lorsque les dépendances sont importées et la réutilisabilité est facile. Pour android-activity-compose on passerait alors de :
dependencies {
implementation("android.activity:activity-compose:1.10.1")
}
À :
dependencies {
implementation(libs.androix.activity.compose)
}
Note : libs est le préfixe utilisé par défaut comme répertoire de nommage pour tout ce qui est généré depuis le bloc libraries. Pour les plugins gradle, un suffixe plugins est ajouté à libs. On a alors pour un plugin android-library :
dependencies {
alias(libs.plugins.android.library)
}
Gestion des dépendances
Depuis l’introduction de AndroidX, les dépendances d’une application Android sont bien plus atomiques. C’est bien pour alléger l’application. En revanche, cela augmente le nombre de dépendances à spécifier dans les fichiers de configuration. Pour donner un ordre de grandeur, j’ai plus de 60 entrées (versions + dépendances) dans le fichier TOML de mon application, alors qu’elle ne se compose que de 3 écrans.
Le constat, c’est qu’un module de notre application a souvent entre 80 et 100% de dépendances communes avec un autre module d’un même archétype. J’entends par là deux modules dédiés à la création d’écrans, ou deux modules plutôt orientés métier. Le challenge est alors d’aller un cran plus loin dans la gestion des dépendances pour factoriser leurs déclarations.
Notes :
- L’environnement utilisé pour les tests ci-dessous est AGP 8.12.1, Gradle 8.14.3, Java 21 et Android Studio Narwhal Feature Drop 2025.1.2 Patch 1.
- Attention : sur Gradle 9.0.0 gradle essaie de résoudre des dépendances à chaque build, ce qui le ralentit de plusieurs secondes. C’est un bug ouvert sur GitHub.
Bundle
Les version catalogs offrent une offre fonctionnalité intéressante : les bundles. Comme son nom laisse supposer, il s’agit là de regrouper des dépendances ensemble pour les importer ensemble en une seule fois. Si vous connaissez Maven, cela vous rappellera les BOM. Voici l’exemple de la documentation :
[versions]
groovy = "3.0.9"
[libraries]
groovy-core = { group = "org.codehaus.groovy", name = "groovy", version.ref = "groovy" }
groovy-json = { group = "org.codehaus.groovy", name = "groovy-json", version.ref = "groovy" }
groovy-nio = { group = "org.codehaus.groovy", name = "groovy-nio", version.ref = "groovy" }
[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]
Le bundle groovy est ensuite importé dans les dépendances :
dependencies {
implementation(libs.bundles.groovy)
}
Malheureusement, cela ne concerne pas les dépendances définies dans le bloc plugins. Impossible alors en une seule instruction de définir qu’un module importe x plugins et y dépendances.
Plugins gradle
Gradle fonctionne avec des plugins. Il en existe 4 grandes familles :
- Scripts plugins
- Precompiled script plugin
- BuildSrc / Convention plugin
- Binary Plugin
Script plugin (apply from file)
Une ancienne manière de faire était d’importer tout un fichier de configuration dans les modules où on souhaite le partager.
Prenons par exemple un fichier appelé configuration.gradle qui contient :
dependencies {
implementation(libs.bundles.groovy)
}
Pour appliquer ces dépendances dans chaque build.gradle.kts de nos modules, il faut alors écrire en haut du fichier :
apply("$rootDir/configuration.gradle")
plugins {
/* ... */
Notez bien ici que configuration.gradle est un fichier gradle simple, pas en kotlin script. Avec un fichier gradle.kts on ne peut pas faire cette même “magie” simplement.
Il s’agit d’une manière legacy de faire. On perd en performance puisqu’il faut fusionner les instructions spécifiées dans notre nouveau fichier avec celles contenues dans chaque module. Le script n’est pas compilé mais réinterprété à chaque fois. Il n’y a enfin pas non plus de sécurité à la compilation puisqu’on importe le script via une chaîne de caractères.
Script plugin (apply from file) - variante
La documentation de gradle propose aussi de définir un plugin comme ceci :
class CommonDependenciesPlugin : Plugin<Project> {
override fun apply(project: Project) {
val libs = the<LibrariesForLibs>()
project.dependencies {
add("implementation", platform(libs.androidx.compose.bom))
}
}
}
apply<CommonDependenciesPlugin>()
Malheureusement, malgré toutes mes tentatives, si le script kotlin est en dehors de certains dossiers spéciaux, la classe n’est pas reconnue correctement :
Caused by: java.lang.IllegalArgumentException: Class Common_gradle.CommonDependenciesPlugin is a non-static inner class
En fait, lors de la compilation kotlin, chaque script est relié à une classe kotlin. Dans notre exemple, gradle va créer une classe pour common-dependencies-plugin.gradle.kts, et va donc définir comme classe interne CommonDependenciesPlugin. Or, il semblerait que Gradle ait besoin de static pour travailler avec des plugins. Les classes internes ne sont pas static par défaut.
Precompiled Script Plugins
D’après la documentation, ce sont des scripts Groovy ou Gradle compilés et distribués au travers de bibliothèques. Ils sont appliqués via le bloc plugins d’un projet.
Par exemple, si un plugin contient dans un fichier plugin/src/main/kotlin/my-plugin.gradle.kts :
// This script is automatically exposed to downstream consumers as the `my-plugin` plugin
tasks {
register("myCopyTask", Copy::class) {
group = "sample"
from("build.gradle.kts")
into("build/copy")
}
}
Alors la tâche myCopyTask sera accessible dans tous les projets qui intégreront my-plugin comme ceci :
plugins {
id("my-plugin") version "1.0"
}
Gradle détermine automatiquement qu’il s’agit d’un plugin à partir du fichier .gradle.kts
BuildSrc et Convention Plugins
Il s’agit d’un format intermédiaire entre les Binary Plugins et les Precompiled Script Plugins. buildSrc est un module spécial qui est automatiquement compilé et inclus au projet si votre répertoire source a cette structure :
root/
├─ build.gradle (or build.gradle.kts)
├─ settings.gradle (or settings.gradle.kts)
├─ buildSrc/
│ ├─ src/main/kotlin (or java)
│ │ └─ ... Kotlin/Java classes
│ └─ build.gradle.kts
Le dossier buildSrc a besoin d’un fichier build.gradle.kts minimal comme celui-ci :
plugins {
`kotlin-dsl`
}
repositories {
google()
mavenCentral()
}
Ensuite, l’idée est de définir ensuite une structure que l’on souhaite voir partagée. Prenons en exemple un fichier buildSrc/src/main/kotlin/common-dependencies.gradle.kts :
val libs = project.extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("implementation", libs.findLibrary("androidx-activity-compose").get())
}
Ici, on récupère dynamiquement les versions définies dans les versions catalogs. Il serait aussi envisageable de se passer des versions catalogs et de définir les versions et dépendances à ajouter directement en Kotlin. On ajoute aussi dynamiquement des entrées aux dépendances avec la méthode add. L’inconvénient c’est que l’on perd la sécurité à la compilation.
Pour factoriser aussi la déclaration de plugins, on les ajoute de manière programmatique :
val libs = project.extensions.getByType<VersionCatalogsExtension>().named("libs")
project.pluginManager.apply(libs.findPlugin("android-application").get().get().pluginId)
dependencies {
add("implementation", libs.findLibrary("androidx-activity-compose").get())
}
Par défaut, un bloc plugins sera chargé avant l’évaluation des version catalogs. buildSrc n’a pas la vue non plus sur les plugins définis ailleurs dans le projet. Si on veut alors plutôt que charger le plugin en déclaratif que de manière programmatique :
val libs = project.extensions.getByType<VersionCatalogsExtension>().named("libs")
plugins {
id("com.android.application")
}
dependencies {
add("implementation", libs.findLibrary("androidx-activity-compose").get())
}
Ceci conduit à cette erreur :
* Exception is:
org.gradle.api.plugins.UnknownPluginException: Plugin [id: 'com.android.application'] was not found in any of the following sources:
- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Included Builds (No included builds contain this plugin)
- Plugin Repositories (plugin dependency must include a version number for this source)
À l’usage dans un module, il faut appliquer le plugin comme ceci :
plugins {
id("common-dependencies")
}
Le plugin est automatiquement référencé en fonction du nom du fichier dans lequel il est déclaré.
Binary Plugins
Ce sont des plugins compilés et distribués en tant que JAR dans le projet. L’objectif ici est de publier en local des plugins qui contiennent toute la factorisation de dépendances. C’est la manière la plus performante de distribuer des plugins gradle en local. En effet, il s’agit de code compilé, mis donc en cache entre deux builds.
La déclaration de ce plugin va suivre à peu près ce qu’il se fait pour buildSrc. Il faut commencer par créer un répertoire build-logic (ou qui porte le nom que vous voulez) dans votre projet. Puisqu’un JAR sera distribué, il aura un nom de package. La structure des sources change alors aussi pour avoir le package : src/main/kotlin/my/package si le package est my.package.
root/
├─ build.gradle (or build.gradle.kts)
├─ settings.gradle (or settings.gradle.kts)
├─ build-logic/
│ ├─ src/main/kotlin/my/package (or java)
│ │ └─ ... Kotlin/Java classes
│ └─ build.gradle.kts
│ └─ settings.gradle.kts
Ce module n’est pas compilé automatiquement par gradle. Il faut alors l’enregistrer dans les settings.gradle.kts parent. Puisqu’on distribue des plugins, cela se fait au niveau du bloc pluginManagement :
pluginManagement {
includeBuild("build-logic") // Inclusion du plugin
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
Ce module doit aussi contenir un fichier settings.gradle.kts local pour activer les version catalogs et récupérer les dépendances du module :
dependencyResolutionManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
Le fichier build.gradle.kts de build-logic change aussi :
import org.gradle.initialization.DependenciesAccessors
import org.gradle.kotlin.dsl.support.serviceOf
plugins {
`kotlin-dsl`
}
dependencies { compileOnly(files(gradle.serviceOf<DependenciesAccessors>().classes.asFiles))
}
gradlePlugin {
plugins {
register("moduleAndroidPresentation") {
id = "com.cheatshqip.module.android.presentation"
implementationClass = "com.cheatshqip.build_logic.AndroidPresentationModulePlugin"
}
}
}
Il y a ici en dépendances les classes utilisées par DependenciesAccessor. C’est un service interne de gradle qui est utilisé avec les version catalogs. Il expose notamment la classe LibrariesForLibs. Elle représente le mapping Kotlin du contenu du fichier toml des versions catalogs.
Le bloc gradlePlugin sert à enregistrer le plugin que nous sommes en train de créer. On spécifie son identifiant (id) et la classe qui est le point d’entrée du plugin. Ici AndroidPresentationModulePlugin.
Enfin, justement cette classe contient le code suivant :
class AndroidPresentationModulePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
val libs = the<LibrariesForLibs>()
with(pluginManager) {
apply(libs.plugins.kotlin.compose.get().pluginId)
}
dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.material3)
implementation(libs.androidx.ui.tooling.preview)
}
}
}
}
private fun DependencyHandler.implementation(dependencyNotation: Any) {
add("implementation", dependencyNotation)
}
private fun DependencyHandler.testImplementation(dependencyNotation: Any) {
add("testImplementation", dependencyNotation)
}
private fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) {
add("androidTestImplementation", dependencyNotation)
}
Les fonctions privées implementation, testImplementation et androidTestImplementation servent à copier le DSL que nous utilisons habituellement dans un bloc dependencies pour déclarer nos dépendances.
Grâce à LibrariesForLibs nous avons accès aux dépendances utilisées lors de la compilation. C’est un vrai confort d’avoir de l’autocomplétion dans cette situation.
La classe AndroidPresentationModulePlugin implémente l’interface Plugin<Project>. En surchargeant la méthode apply, on a alors accès au Project sur lequel le plugin est appliqué. Via le pluginManager, on ajoute le plugin gradle jetpack compose et via le bloc dependencies on ajoute toutes les bibliothèques graphiques nécessaires pour travailler avec Jetpack Compose.
À l’usage, on importe le plugin par son id. On peut ajouter au préalable une entrée dans les version catalogs si on ne souhaite pas travailler avec des chaînes de caractères :
[plugins]
/* */
cheatshqip-module-android-presentation = { id = "com.cheatshqip.module.android.presentation" }
Puis, le plugin est ajouté dans le bloc dédié des modules, dans leurs fichiers build.gradle.kts :
plugins {
/* */
alias(libs.plugins.cheatshqip.module.android.presentation)
/* */
}
Conclusion
buildSrc (convention plugins) et les binary plugins sont les moyens préconisés par gradle pour partager de la logique de configuration. Si les binary plugins sont un peu plus compliqués à mettre en place, ils offrent plus de possibilités et surtout de meilleures performances. buildSrc présente par exemple comme limite le fait que le moindre changement en son sein conduit à l’invalidation de l’ensemble du paramétrage du projet, et donc à sa recompilation. Amusez-vous bien !
Sources
- Modularisation Android : https://developer.android.com/topic/modularization
- Play Feature Delivery : https://developer.android.com/guide/playcore/feature-delivery
- Code Spaghetti : https://en.wikipedia.org/wiki/Spaghetti_code
- Gradle Version Catalogs : https://docs.gradle.org/current/userguide/version_catalogs.html
- Maven BOM : https://www.baeldung.com/spring-maven-bom
- Kotlin Visibility Modifiers : https://kotlinlang.org/docs/visibility-modifiers.html
- O2, une App Offline-First déclinable : https://blog.ippon.fr/2020/06/24/o2-une-app-offline-first-declinable
- AndroidX : https://developer.android.com/jetpack/androidx
- Plugins gradle : https://docs.gradle.org/current/userguide/plugins.html