Easy modularity: Keeping your Gradle build scripts clean and eliminating duplication in your multi-module projects
Tony Robalik
Posted on August 10, 2020
I recently started breaking up the monolith I inherited at my current place of work. To make it easier for myself and for the rest of the team that will have to maintain it, I created three convention plugins in buildSrc
. Gradle calls these precompiled script plugins, and they can be written in either Groovy or Kotlin. The examples below are in Kotlin. Let's take a look!
Enabling precompiled script plugins
buildSrc/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
jcenter()
google()
}
dependencies {
implementation("com.android.tools.build:gradle:4.0.1")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72")
}
This is an expansion upon the bare minimum that you will need if you have convention plugins that apply any of the Android or Kotlin plugins.
The android-library-convention
plugin
buildSrc/src/main/kotlin/android-library-convention.gradle.kts
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdkVersion(30)
defaultConfig {
minSdkVersion(21)
targetSdkVersion(30)
versionCode = 1
versionName = "1"
}
compileOptions {
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
implementation(platform(project(":platform")))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
Any Android developer would recognize this script, as it is exactly what they'd use if they were using the Kotlin DSL and had simply created the script in place, at something like android-lib-module/build.gradle.kts
. You can now apply this like so:
android-lib-module/build.gradle.kts
plugins {
`android-library-convention`
}
And that's it! You can of course customize it as you like, for example by adding dependencies needed by this module (but not needed by all modules, and therefore not part of the convention plugin).
Careful readers will have seen something slightly unusual about the plugin:
dependencies {
implementation(platform(project(":platform")))
}
This refers to a Java Platform project, and is the current best-practice for managing and centralizing dependency versions. Discussion of the Java Platform plugin is outside of the scope of this article, but I include it here as an example of how you can use this convention plugin approach to reduce boilerplate — without this, you would need to add implementation(platform(project(":platform")))
to every Android library build script, which is easy to forget.
The java-library-convention
plugin
This is very straightforward:
buildSrc/src/main/kotlin/java-library-convention.gradle.kts
plugins {
`java-library`
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
implementation(platform(project(":platform")))
}
And applied as before:
java-lib-module/build.gradle.kts
plugins {
`java-library-convention`
}
What I particularly like about this is not having to remember to add sourceCompatibility
and targetCompatibility
to every Java library module I have.
The kotlin-library-convention
plugin
This is also very straightforward:
buildSrc/src/main/kotlin/kotlin-library-convention.gradle.kts
plugins {
id("org.jetbrains.kotlin.jvm")
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(platform(project(":platform")))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
And applied as before:
kotlin-lib-module/build.gradle.kts
plugins {
`kotlin-library-convention`
}
I honestly don't even know how many times I've created a new Kotlin module and forgotten to add the stdlib. Everything looks fine in the IDE but good luck getting it to compile!
Why differentiate?
The reason I have decided to show three separate examples is due to the weight I place upon the values of specificity and minimalism.1 We could reduce boilerplate even more if we had stopped after writing our android-library-convention
plugin. It already knows how to compile Android, Java library, and Kotlin library projects, after all.
Probably the biggest problem with simplifying to that extent is the negative impact it would have on build performance. Building Android libraries is much harder and slower than building Java libraries. Building Kotlin libraries is also slower than building Java libraries. And annotation processing is much slower than not annotation processing! (Please do not apply kotlin-kapt
unless you're actually in need of Kotlin annotation processing.) Apply only what you need and no more: plugins are not free.
From a software architecture perspective, it's also useful to be specific. By applying only a JVM plugin, you are indicating that this is intended as a JVM library, and you make it harder (a good thing!) for the next developer swinging through to import android.content.Context
.
From convention plugins to convention tasks
Why stop there? I regularly see people asking about the following scenario:
I have a heterogeneous build, with Android application modules, Android library modules, and JVM (Java/Kotlin) modules. I want to be able to run something like
./gradlew test
and run all the important test tasks in all the modules. How can I do that?
Let's update our convention plugins to show how to make this scenario easier to handle.
android-library-convention.gradle.kts
...
tasks.register("mainTest") {
dependsOn("testDebugUnitTest") // for example
}
java-library-convention.gradle.kts
...
tasks.register("mainTest") {
dependsOn("test")
}
(And the same for kotlin-library-convention
.)
Now you can easily take advantage of one of Gradle's conveniences2 and execute
./gradlew mainTest
When you do this, Gradle will execute the mainTest
task in every module that has it. If a module doesn't have it, it's fine; there will only be an error if the task isn't present in any module.
It's worth noting that the following would not be advisable:
./gradlew test testDebugUnitTest
You might think that accomplishes the same thing at the cost of only one more word on the command line, without the trouble of adding the task to each module. Unfortunately, you'll run into the fact that Android projects do have a task named "test"! Here's a portion of the output from executing ./gradlew app:test --dry-run
against my small project (with two build types and two product flavors):
...
:app:preProductionReleaseBuild SKIPPED
:app:compileProductionReleaseAidl SKIPPED
:app:compileProductionReleaseRenderscript SKIPPED
:app:generateProductionReleaseBuildConfig SKIPPED
:app:generateProductionReleaseResValues SKIPPED
:app:generateProductionReleaseResources SKIPPED
:app:injectCrashlyticsMappingFileIdProductionRelease SKIPPED
:app:processProductionReleaseGoogleServices SKIPPED
:app:mergeProductionReleaseResources SKIPPED
:app:createProductionReleaseCompatibleScreenManifests SKIPPED
:app:extractDeepLinksProductionRelease SKIPPED
:app:processProductionReleaseManifest SKIPPED
:app:processProductionReleaseResources SKIPPED
:app:kaptGenerateStubsProductionReleaseKotlin SKIPPED
:app:kaptProductionReleaseKotlin SKIPPED
:app:compileProductionReleaseKotlin SKIPPED
:app:javaPreCompileProductionRelease SKIPPED
:app:compileProductionReleaseJavaWithJavac SKIPPED
:app:kaptGenerateStubsProductionReleaseUnitTestKotlin SKIPPED
:app:kaptProductionReleaseUnitTestKotlin SKIPPED
:app:compileProductionReleaseUnitTestKotlin SKIPPED
:app:preProductionReleaseUnitTestBuild SKIPPED
:app:javaPreCompileProductionReleaseUnitTest SKIPPED
:app:compileProductionReleaseUnitTestJavaWithJavac SKIPPED
:app:mergeProductionReleaseShaders SKIPPED
:app:compileProductionReleaseShaders SKIPPED
:app:generateProductionReleaseAssets SKIPPED
:app:mergeProductionReleaseAssets SKIPPED
:app:packageProductionReleaseUnitTestForUnitTest SKIPPED
:app:generateProductionReleaseUnitTestConfig SKIPPED
:app:processProductionReleaseJavaRes SKIPPED
:app:processProductionReleaseUnitTestJavaRes SKIPPED
:app:testProductionReleaseUnitTest SKIPPED
:app:test SKIPPED
That is not even one-tenth of it. If you didn't already know it, you'd quickly learn that test
is basically an alias for every unit test variant.
As a final note, if you also want to somehow merge all the test reports, that's a slightly more complex problem that I might tackle in a future post.
I hope this small example is enough to show the power and utility of convention plugins and tasks.
Endnotes
1 Not in endnotes, though. The more the merrier! up
2 Gradle is famously convenient. up
Posted on August 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.