If you’re like me, GraÂdle and othÂer build tools can seem like magÂic bunÂdles of text that just hapÂpen to build a funcÂtionÂing application.
Over time, I’ve begun to betÂter underÂstand build tools like GraÂdle. As an Android project grows, you’ll likeÂly need to sepÂaÂrate code into modÂules to decouÂple depenÂdenÂcies and enable team memÂbers to work on difÂferÂent modÂules withÂout conflicts.
In this blog, I’ll disÂcuss how we can make the process smoother with graÂdle conÂvenÂtion pluÂgÂins.
Before and After #
When creÂatÂing an Android modÂule from scratch withÂin Android StuÂdio, you’ll end up with a GraÂdle build file that looks someÂthing like the below (I’m using Kotlin DSL .kts
files and a cenÂtral libs.version.toml
file; they’re great):
plugins {
alias(libs.plugins.com.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
}
android {
namespace = "com.example.myexamplelibrary"
compileSdk = 34
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(libs.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
}
You’ll see that we already have 43 lines of code by default, includÂing many magÂic numÂbers for SDK verÂsions and Java comÂpatÂiÂbilÂiÂty. One way to remove the hard codÂed conÂstants and share them with othÂer modÂules is to pull them out into variÂables through buildSrc
or into your libs.version.toml
. But what if we took it furÂther? We could conÂdense it down to someÂthing like:
plugins {
id("android-library-convention")
}
android {
namespace = "com.example.myexamplelibrary"
}
dependencies {
...any extra dependencies needed for this module
}
All of the repeatÂed conÂfigÂuÂraÂtion we typÂiÂcalÂly have to do inside of the android
conÂfigÂuÂraÂtion block is now gone. But, obviÂousÂly it’s just moved elseÂwhere, right?
It is, but now we’re able to specÂiÂfy a conÂvenÂtion pluÂgÂin at the top of our library modÂules and inject the conÂfigÂuÂraÂtion we know we need.
How do we define the conÂvenÂtion pluÂgÂins? #
Below, I’ll list the steps I took to creÂate conÂvenÂtion pluÂgÂins for my project.
- CreÂate a direcÂtoÂry at the base of your project called
build-logic
.
- CreÂate a kotlin/​java modÂule inside of that direcÂtoÂry and name it
convention
(you can change the name of these but to match the direcÂtions below they’ll need to match). You should now have a source foldÂer and a build.gradle.kts
file inside of the convention
direcÂtoÂry. We’ll edit the build file in a bit.
- Make sure you’re using a
libs.versions.toml
file in your root project’s graÂdle foldÂer. SetÂup instrucÂtions for that can be found at the link above.
- Add a
settings.gradle.kts
file to the build-logic
direcÂtoÂry with conÂtents of:
dependencyResolutionManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":convention")
Now, we’ll fill out the build.gradle.kts
inside of convention
modÂule, the jvmToolchain
verÂsion and depenÂdenÂcies for this modÂule will change based on what JVM verÂsion your project needs and what depenÂdenÂcies you’re tryÂing to reuse across the project.
In my project I am makÂing reusable pluÂgÂins for android
libraries and hilt
setÂup currently:
plugins {
// Needed to define our future convention plugin files using kotlin DSL syntax
`kotlin-dsl`
}
group = "com.example.buildlogic"
kotlin {
jvmToolchain(17)
}
dependencies {
// Comments below indicate what the reference in `libs.versions.toml` look like, the first is from `[versions]` and the second is the listing in `[libraries]`
// agp = 8.1.4
// com-android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
implementation(libs.com.android.gradle.plugin)
// org-jetbrains-kotlin-android = "1.9.22"
// org-jetbrains-kotlin-android-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "org-jetbrains-kotlin-android" }
implementation(libs.org.jetbrains.kotlin.android.gradle.plugin)
// ksp = "1.9.22-1.0.17"
// ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
implementation(libs.com.google.devtools.ksp.plugin)
// hilt = "2.50"
// hilt-gradle-plugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
implementation(libs.hilt.gradle.plugin)
}
You’ll notice that we are specÂiÂfyÂing these as libraries, not pluÂgÂins. That’s because these pluÂgÂin libraries are depenÂdenÂcies required by the conÂvenÂtion pluÂgÂins, rather than being directÂly using them as pluÂgÂins withÂin our Android modÂules as we typÂiÂcalÂly do.
Now go to the base settings.gradle.kts
at the root of your project and add build-logic
as an includÂed build:
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
Time for the fun part: actuÂalÂly fillÂing out the conÂvenÂtion modÂule with conÂvenÂtion pluÂgÂins. You could do this with just writÂing the pluÂgÂins as Kotlin classÂes class AndroidLibraryConventionPlugin : Plugin<Project> {
, but I chose to use a mix of Kotlin extenÂsion synÂtax and Kotlin DSL synÂtax in gradle.
If you want to creÂate a conÂvenÂtion pluÂgÂin for reuse in all of your Android libraries, you can creÂate a android-library-convention.gradle.kts
file inside of src/main/kotlin
. (The file name is up to you, but will impact what name you need to use to import this pluÂgÂin later).
Here is what my Android library pluÂgÂin looks like:
plugins {
id("com.android.library")
kotlin("android")
}
android {
commonConfiguration(this)
}
kotlin {
configureKotlinAndroid(this)
}
It’s realÂly simÂple, but what are these commonConfiguration
and configureKotlinAndroid
funcÂtions doing? They are just funcÂtions defined in an ext
direcÂtoÂry alongÂside the pluÂgÂin foldÂers. I have an AndroidExt.kt
file with the conÂtents of:
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
fun org.gradle.api.Project.commonConfiguration(
extension: CommonExtension<*, *, *, *, *>
) = extension.apply {
compileSdk = versionCatalog.findVersion("compile-sdk").get().requiredVersion.toInt()
defaultConfig {
minSdk = versionCatalog.findVersion("min-sdk").get().requiredVersion.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
buildConfig = true
}
}
fun org.gradle.api.Project.configureKotlinAndroid(
kotlinAndroidProjectExtension: KotlinAndroidProjectExtension
) {
kotlinAndroidProjectExtension.apply {
jvmToolchain(17)
}
}
val org.gradle.api.Project.versionCatalog
get() = extensions.getByType(VersionCatalogsExtension::class.java)
.named("libs")
and a Utilities.kt
file that curÂrentÂly just has:
package ext
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
val Project.libs: VersionCatalog
get() {
val catalogs = extensions.getByType(VersionCatalogsExtension::class.java)
return catalogs.named("libs")
}
You can see that we’ve pulled a lot of the repeatÂed conÂfigÂuÂraÂtion code we norÂmalÂly write in our GraÂdle files and put them inside these reusable funcÂtions. The final piece to the puzÂzle is just declarÂing the id("android-library-convention")
pluÂgÂin like we did in the above example:
plugins {
id("android-library-convention")
}
android {
namespace = "com.example.myexamplelibrary"
}
dependencies {
...any extra dependencies needed for this module
}
You can be flexÂiÂble and move as much as you want into these conÂvenÂtion pluÂgÂins or just make them smallÂer so you can reuse conÂfigÂuÂraÂtion setÂup for things such as the compile-sdk
across modÂules to avoid manÂuÂalÂly creÂatÂing each indiÂvidÂual module.
Here is an examÂple of a hilt conÂvenÂtion plugin:
import ext.libs
plugins {
id("com.google.devtools.ksp")
id("dagger.hilt.android.plugin")
}
dependencies {
"implementation"(libs.findLibrary("hilt").get())
"ksp"(libs.findLibrary("hilt.android.compiler").get())
"implementation"(libs.findLibrary("hilt.viewmodel").get())
}
Now, any modÂule that imports this pluÂgÂin no longer needs to manÂuÂalÂly specÂiÂfy hilt pluÂgÂins or hilt depenÂdenÂcies. This conÂvenÂtion pluÂgÂin will do that simÂply by importÂing it.
The final build-logÂic foldÂer should look someÂthing like this:
Final thoughts #
ConÂvenÂtion pluÂgÂins take a small effort up front to put in place. But once your project supÂports them, they make creÂatÂing addiÂtionÂal modÂules a breeze and they are easÂiÂer to mainÂtain if you’re a feaÂture develÂopÂer lookÂing to add dependencies.
Check out the NowÂInAnÂdroid project as it includes conÂvenÂtion pluÂgÂins and is a great refÂerÂence of modÂern Android best pracÂtices.