If you’re like me, Gradle and other build tools can seem like magic bundles of text that just happen to build a functioning application.
Over time, I’ve begun to better understand build tools like Gradle. As an Android project grows, you’ll likely need to separate code into modules to decouple dependencies and enable team members to work on different modules without conflicts.
In this blog, I’ll discuss how we can make the process smoother with gradle convention plugins.
Before and After #
When creating an Android module from scratch within Android Studio, you’ll end up with a Gradle build file that looks something like the below (I’m using Kotlin DSL .kts
files and a central 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, including many magic numbers for SDK versions and Java compatibility. One way to remove the hard coded constants and share them with other modules is to pull them out into variables through buildSrc
or into your libs.version.toml
. But what if we took it further? We could condense it down to something like:
plugins {
id("android-library-convention")
}
android {
namespace = "com.example.myexamplelibrary"
}
dependencies {
...any extra dependencies needed for this module
}
All of the repeated configuration we typically have to do inside of the android
configuration block is now gone. But, obviously it’s just moved elsewhere, right?
It is, but now we’re able to specify a convention plugin at the top of our library modules and inject the configuration we know we need.
How do we define the convention plugins? #
Below, I’ll list the steps I took to create convention plugins for my project.
- Create a directory at the base of your project called
build-logic
. - Create a kotlin/java module inside of that directory and name it
convention
(you can change the name of these but to match the directions below they’ll need to match). You should now have a source folder and abuild.gradle.kts
file inside of theconvention
directory. We’ll edit the build file in a bit. - Make sure you’re using a
libs.versions.toml
file in your root project’s gradle folder. Setup instructions for that can be found at the link above. - Add a
settings.gradle.kts
file to thebuild-logic
directory with contents 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
module, the jvmToolchain
version and dependencies for this module will change based on what JVM version your project needs and what dependencies you’re trying to reuse across the project.
In my project I am making reusable plugins for android
libraries and hilt
setup 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 specifying these as libraries, not plugins. That’s because these plugin libraries are dependencies required by the convention plugins, rather than being directly using them as plugins within our Android modules as we typically do.
Now go to the base settings.gradle.kts
at the root of your project and add build-logic
as an included build:
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
Time for the fun part: actually filling out the convention module with convention plugins. You could do this with just writing the plugins as Kotlin classes class AndroidLibraryConventionPlugin : Plugin<Project> {
, but I chose to use a mix of Kotlin extension syntax and Kotlin DSL syntax in gradle.
If you want to create a convention plugin for reuse in all of your Android libraries, you can create 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 plugin later).
Here is what my Android library plugin looks like:
plugins {
id("com.android.library")
kotlin("android")
}
android {
commonConfiguration(this)
}
kotlin {
configureKotlinAndroid(this)
}
It’s really simple, but what are these commonConfiguration
and configureKotlinAndroid
functions doing? They are just functions defined in an ext
directory alongside the plugin folders. I have an AndroidExt.kt
file with the contents 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 currently 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 repeated configuration code we normally write in our Gradle files and put them inside these reusable functions. The final piece to the puzzle is just declaring the id("android-library-convention")
plugin 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 flexible and move as much as you want into these convention plugins or just make them smaller so you can reuse configuration setup for things such as the compile-sdk
across modules to avoid manually creating each individual module.
Here is an example of a hilt convention 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 module that imports this plugin no longer needs to manually specify hilt plugins or hilt dependencies. This convention plugin will do that simply by importing it.
The final build-logic folder should look something like this:
Final thoughts #
Convention plugins take a small effort up front to put in place. But once your project supports them, they make creating additional modules a breeze and they are easier to maintain if you’re a feature developer looking to add dependencies.
Check out the NowInAndroid project as it includes convention plugins and is a great reference of modern Android best practices.
Looking for more like this?
Sign up for our monthly newsletter to receive helpful articles, case studies, and stories from our team.
What to know about the cost of custom app development
January 10, 2024We hear a lot of ideas for apps at MichiganLabs. People from large enterprises and small startups, located all over the world, call us to explore their mobile and web-based application ideas, and one of the first questions they ask is: How much is this app going to cost?
Read more3 tips for navigating tech anxiety as an executive
March 13, 2024C-suite leaders feel the pressure to increase the tempo of their digital transformations, but feel anxiety from cybersecurity, artificial intelligence, and challenging economic, global, and political conditions. Discover how to work through this.
Read more