Sharing Build Logic: Composite and Convention
Modularization has been gaining a lot of traction in the Android development scene, and more and more developers and companies are using this approach to build their apps. Organizing a codebase into modules can help improve maintainability, scalability, build times, and encourage reusability.
However, adding modules can easily lead to a lot of boilerplate code, especially in each module’s configuration. Therefore, it’s important to define a place to share common logic and configuration. To do so, it is recommended to use Gradle Composite Builds along with convention plugins.
Case Study #
Let’s consider the following Android project’s structure (some files and directories are omitted for brevity):
.
├── app
│ ├── build.gradle.kts
│ └── src
├── core
│ ├── bluetooth
│ │ ├── build.gradle.kts
│ │ └── src
│ ├── common
│ │ ├── build.gradle.kts
│ │ └── src
│ └── database
│ ├── build.gradle.kts
│ └── src
└── settings.gradle.kts
In this example, we have our app
module, and three other modules (regrouped within the core
directory).
The three library modules (bluetooth
, common
, and database
) all have a very similar configuration in their respective build.gradle.kts
:
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
// ...
}
android {
namespace = "org.cosmosapps.example.core.bluetooth"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs = listOf("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
}
dependencies {
// ...
}
Sharing the Logic #
In order to create reusable build logic and avoid boilerplate configuration, we can create a new module, based on the principle of Gradle’s Composite Builds.
New Module: build-logic #
Let’s add a module called build-logic
at the root of the project. Inside this module, we first create the settings.gradle.kts
file with the following content:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
// Needed to access version catalog in our plugins
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
Then, we need a build.gradle.kts
:
plugins {
`kotlin-dsl`
}
group = "org.cosmosapps.example.build.logic"
dependencies {
compileOnly(libs.android.build.gradle)
compileOnly(libs.kotlin.gradle)
}
Lastly, we need to include this module to our build using includeBuild
in the project root’s settings.gradle.kts
:
// ...
includeBuild("build-logic")
include(":app")
include(":core:common")
include(":core:bluetooth")
include(":core:database")
Convention Plugin #
As you may already know, Gradle lets you write your own plugins. Creating a plugin that encapsulates build logic common to several modules is called a convention plugin.
Once we have configured our build logic module, we can create our own convention plugin. The plugin will be located in build-logic/src/main/kotlin
and will be used to configure a Library module.
class LibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.run {
pluginManager.run {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<LibraryExtension> {
configureAndroid(this)
}
}
}
}
This plugin will apply the Android Library plugin and Kotlin Android plugin to our module.
It will also configure the android
block; we use the configureAndroid
extension function for that (that is defined in a separate ProjectExt.kt
file).
internal fun Project.configureAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
configure<KotlinAndroidProjectExtension> {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
freeCompilerArgs = listOf("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
}
}
}
Registering the Plugin #
To use our plugin, we need to register it and add it to our version catalog.
We have a dedicated block in our build-logic
module’s build.gradle.kts
:
// ...
gradlePlugin {
plugins {
register("libraryPlugin") {
id = "example.library"
implementationClass = "LibraryPlugin"
}
}
}
We also have to declare it in the plugins
section of our catalog:
[plugins]
# ...
ex-library = { id = "example.library" }
Applying the Plugin #
In each of our Library modules, we can now remove the boilerplate code and apply our plugin instead:
plugins {
alias(libs.plugins.ex.library)
// ...
}
android {
namespace = "org.cosmosapps.example.core.bluetooth"
}
dependencies {
// ...
}
Better, right?
What About buildSrc? #
buildSrc
is often mentioned when talking about reusable build logic. It is a Gradle-recognized and protected directory that can be used for custom tasks, plugins, and shared code.
However, buildSrc
comes with a few drawbacks. The most significant one is that any change to it will invalidate cache and cause the whole project to become out-of-date.
Using Composite Builds and convention plugins will help mitigate those issues.
Wrapping Up #
This article is just a starting point to convention plugins; of course the logic and needs will differ from project to project.
A good demonstration of convention plugins in action is the Now in Android App; you can check the code on GitHub.
Thank you for reading!