Refactor your Gradle build configuration with convention plugins | Anton Danshin

Refactor your Gradle build configuration with convention plugins

December 10, 2021, updated December 11, 2021 | 7 min read

Many Android projects contain more than one module (subproject). In a product oriented project, it will be one app module and many library modules. When the team is developing white-label apps, it will be many apps on top of many library modules.

Here is what I often see in our project. When creating a new module, some developers would use Android Studio GUI and then adjust it according to what they need. Others would simply copy-paste some existing module and change it. Some people would even write build configuration from scratch. Generally, there is nothing wrong with this. Bu over time, this practice had caused us several issues:

  • Inconsistencies. Build configuration might differ from module to module (e.g., could have different compileSdkVersion).
  • Poor maintainability. If we need to apply some new setting to all modules, we will have to change them all.
  • Redundant logic. Build configuration may contain redundant parameters / code, developers are afraid to remove it to break something.

Since we are making wite-labels apps, we have created a script that automates some of the copy-paste we have to do in order to bootstrap the boilerplate for new apps. The amount of duplication in the build logic grows even faster.

Sharing external dependencies and versions accross Gradle modules

One of the common things developers do, is introducing constants for external libraries. In many projects I see on Github, all versions and library locators are placed to versions.gradle in the project’s root directory. The file typically declares version of different libraries and plugins, and also groups all libraries by vendor Then, all the versions and dependencse are placed into Gradle’s ExtraPropertiesExtension, aka ext.

I’ve heard that using ext to keep common parameters across subprojects is not a very good practice. I don’t know why exactly… I could certainly find some of its drawbacks if I try. However, our project is currenly using this approach.

Gradle team noticed the trend and came up with a solution called Version Catalogs, which is currently a feature preview. The feature allows you to define versions and libraries in a *.toml file. Gradle will generate type-safe accessors based on the declarations in the file.

You could try using it for new projects. However, if you already have your own “version catalog” that works for you, I don’t really see a need to migrate to the Gradle solution. Compared to defining versions in a Gradle or Kotlin script with IDE support, I found it quite annoying that you have to edit *.toml. Apart from syntax highlighing, IDE doesn’t help you with the code completion (at least as of the time of writing this post there was an open issue about that), and you can easily mess it up.

Sharing common build logic accross Gradle modules

Common pattern that people do here, is either putting the code into subprojects {} block of root build.gradle file, or put configuration logic into a separate gradle file and apply it to specific module via apply from: 'file.gradle'.

This is a solution, but because you cannot often split root build.gradle into separate files, the file becomes inflated. This also causes subprojects to implicitly rely on configuration logic written in the root build.gradle.

Fortunately, now there is an easy way split the common build logic into separate gradle files (plugins) that also make subprojects explicitely declare that they are using this configuration. This approach is based on a Gradle feature Precompiled Script Plugins and this is now a recommend way for sharing build logic accorss modules in larger projects. Such precompiled plugins that contain pieces of shared build logic are often referred to as Convention plugins.

If you don’t have many modules, I think it still OK writing your common build logic the old way. But as project grows, you may not notice how your 10 modules became 30, and 30 become 50… So, why not invest a little bit of time and set up a future proof structure at the early?

Before applying it to your project, always check whether the benefits that the convention plugins bring outweigh the drawbacks (e.g. potentially slower build or some other issue).

The benefits of using convention plugins are:

  1. Just like with subprojects { } configuration block, you will be able to extract common logic in one place.
  2. You will be able separate common logic into completely independent bits, which should be easier to maintain.
  3. You explicitly apply different convention plugins to different modules.

By the way! It is possible to migrate to the new approach slowly. I am currently implementing it in our project and have only applied the plugins to a subset of modules. Seems to be working fine.

The drawbacks that I noticed are:

  1. It requires you to use newer Gradle, as well as new pluginManagement {}, which has some issues when used together with the older builscript. Ideally, all projects should migrate to new approach.
  2. It will take a little extra time to compile included build.
  3. This is not the default way, so when you introduce new team members to the project, they will have to learn your new “conventions”.

How to add simple convention plugin

Convention plugins are placed into an included build.

The idea is to put all your build logic into special project, which you can call build-logic. It is going to be a standalone gradle project, with its own settings.gradle and build.gradle scripts and submodules. It can then be declared as an included build in root project’s settings.gradle. Alternatively, it can also be included at build time via command line parameter: ./gradlew --include-build build-logic.

Project structure

Here is how the project structure would look like:

root-project
├── build.gradle
├── gradle.properties
├── settings.gradle
├── build-logic
│   ├── build.gradle.kts
│   ├── convention-plugins
│   │   ├── build.gradle.kts
│   │   └── src
│   └── settings.gradle.kts
├── app
│   ├── build.gradle
│   └── src
└── library-module
    ├── build.gradle
    └── src

Root project config

In root-project’s build.gradle we need to add the following lines:

pluginManagement {
    repositories {
        gradlePluginPortal()
        google()
    }
}

includeBuild('build-logic')

The pluginManagement block is important. Without it convention plugins won’t work.

There is an issue with IDEA that causes wrong gradle wrapper to be used when running tests from IDE. It can be fixed by setting version of Gradle Wrapper in the root build.gradle script:

tasks.withType(Wrapper.class).configureEach {
    distributionType = Wrapper.DistributionType.BIN
    gradleVersion = "7.3.1" // set the same version as in gradle/wrapper/gradle-wrapper.properties
}

Included build: build-logic

In the build-logic/settings.gradle.kts you should have the following lines:

pluginManagement {
    repositories {
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        gradlePluginPortal()
        mavenCentral()
    }
}

include("convention-plugins")

Here we define repositories where to find plugins and dependencies for our included build. We also declare a subproject that will contain our convention plugins.

build.gradle can be empty.

Simple convention plugin

Inside build-logic/convention-plugins/build.gradle.kts we need to apply kotlin-dsl plugin and add a depdendency on Android Gradle Plugin.

plugins {
    `kotlin-dsl`
}

dependencies {
    implementation("com.android.tools.build:gradle:7.0.3")
}

If we would like to use third-party plugins in our pre-compiled script, we must add all of them in the dependencies block. Here, for example, we are adding the latest Android Gradle Plugin.

Then in build-logic/convention-plugins/src/main/kotlin we can create a convention plugin, that does some set up of AGP. The id of the plugin will be derived from file name (without extension). Let’s define convention.android-app plugin in convention.android-app.gradle.kts:

plugins {
    id("com.android.application")
}

android {
    defaultConfig {
        minSdk = 21
        targetSdk = 31
    }
}

Now any usage of com.android.application plugin in our project can be replaced with our convention plugin

plugins {
    id 'convention.android-app'
}

The convention plugin will apply Android Aplication plugin and will also set defaultConfig that we defined.

Resources

Before you start digging into this topic, I recommend reading the intro to Precompiled Script Plugins from Gradle. Then explore the examples below:

  1. Convention plugins – Oficial sample from Gradle.
  2. Android Sample – More realistic sample with of Android project.
  3. Real project usage

Also, I would highly recommend reading Gradle Task Configuration Avoidance guidelines. Might be useful when writing custom plugins.

Special thanks to Dmitriy Voronin for inspiration and samples.


Profile picture

Written by Anton Danshin 🧑‍💻 Android developer, ☕️ Starbucks coffee addict

© 2022, Built with Gatsby