Easy modularity: Keeping your Gradle build scripts clean and eliminating duplication in your multi-module projects

autonomousapps

Tony Robalik

Posted on August 10, 2020

Easy modularity: Keeping your Gradle build scripts clean and eliminating duplication in your multi-module projects

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")
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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`
}
Enter fullscreen mode Exit fullscreen mode

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")))
}
Enter fullscreen mode Exit fullscreen mode

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")))
}
Enter fullscreen mode Exit fullscreen mode

And applied as before:
java-lib-module/build.gradle.kts

plugins {
  `java-library-convention`
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

And applied as before:
kotlin-lib-module/build.gradle.kts

plugins {
  `kotlin-library-convention`
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

java-library-convention.gradle.kts

...

tasks.register("mainTest") {
  dependsOn("test")
}
Enter fullscreen mode Exit fullscreen mode

(And the same for kotlin-library-convention.)

Now you can easily take advantage of one of Gradle's conveniences2 and execute

./gradlew mainTest
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Sad dog

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

💖 💪 🙅 🚩
autonomousapps
Tony Robalik

Posted on August 10, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related