Skip to main content

Sharing Build Logic: Composite and Convention

·4 mins

Modularization has been gaining a lot of traction in the Android development scene, and more and more developers and companies are using this approach to build their apps. Organizing a codebase into modules can help improve maintainability, scalability, build times, and encourage reusability.

However, adding modules can easily lead to a lot of boilerplate code, especially in each module’s configuration. Therefore, it’s important to define a place to share common logic and configuration. To do so, it is recommended to use Gradle Composite Builds along with convention plugins.

Case Study #

Let’s consider the following Android project’s structure (some files and directories are omitted for brevity):

.
├── app
│   ├── build.gradle.kts
│   └── src
├── core
│   ├── bluetooth
│   │   ├── build.gradle.kts
│   │   └── src
│   ├── common
│   │   ├── build.gradle.kts
│   │   └── src
│   └── database
│       ├── build.gradle.kts
│       └── src
└── settings.gradle.kts

In this example, we have our app module, and three other modules (regrouped within the core directory).

The three library modules (bluetooth, common, and database) all have a very similar configuration in their respective build.gradle.kts:

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
    // ...
}

android {
    namespace = "org.cosmosapps.example.core.bluetooth"
    compileSdk = 34

    defaultConfig {
        minSdk = 26

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }

    kotlinOptions {
        jvmTarget = "11"
        freeCompilerArgs = listOf("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
    }
}

dependencies {
    // ...
}

Sharing the Logic #

In order to create reusable build logic and avoid boilerplate configuration, we can create a new module, based on the principle of Gradle’s Composite Builds.

New Module: build-logic #

Let’s add a module called build-logic at the root of the project. Inside this module, we first create the settings.gradle.kts file with the following content:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }

    // Needed to access version catalog in our plugins
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"

Then, we need a build.gradle.kts:

plugins {
    `kotlin-dsl`
}

group = "org.cosmosapps.example.build.logic"

dependencies {
    compileOnly(libs.android.build.gradle)
    compileOnly(libs.kotlin.gradle)
}

Lastly, we need to include this module to our build using includeBuild in the project root’s settings.gradle.kts:

// ...

includeBuild("build-logic")
include(":app")
include(":core:common")
include(":core:bluetooth")
include(":core:database")

Convention Plugin #

As you may already know, Gradle lets you write your own plugins. Creating a plugin that encapsulates build logic common to several modules is called a convention plugin.

Once we have configured our build logic module, we can create our own convention plugin. The plugin will be located in build-logic/src/main/kotlin and will be used to configure a Library module.

class LibraryPlugin : Plugin<Project> {

    override fun apply(target: Project) {
        target.run {
            pluginManager.run {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }

            extensions.configure<LibraryExtension> {
                configureAndroid(this)
            }
        }
    }
}

This plugin will apply the Android Library plugin and Kotlin Android plugin to our module.

It will also configure the android block; we use the configureAndroid extension function for that (that is defined in a separate ProjectExt.kt file).

internal fun Project.configureAndroid(
    commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
    commonExtension.apply {
        compileSdk = 34

        defaultConfig {
            minSdk = 26

            testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        }

        buildTypes {
            getByName("release") {
                isMinifyEnabled = false
                proguardFiles(
                    getDefaultProguardFile("proguard-android-optimize.txt"),
                    "proguard-rules.pro"
                )
            }
        }

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_11
            targetCompatibility = JavaVersion.VERSION_11
        }

        configure<KotlinAndroidProjectExtension> {
            compilerOptions {
                jvmTarget = JvmTarget.JVM_11
                freeCompilerArgs = listOf("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
            }
        }
    }
}

Registering the Plugin #

To use our plugin, we need to register it and add it to our version catalog.

We have a dedicated block in our build-logic module’s build.gradle.kts:

// ...

gradlePlugin {
    plugins {
        register("libraryPlugin") {
            id = "example.library"
            implementationClass = "LibraryPlugin"
        }
    }
}

We also have to declare it in the plugins section of our catalog:

[plugins]
# ...

ex-library = { id = "example.library" }

Applying the Plugin #

In each of our Library modules, we can now remove the boilerplate code and apply our plugin instead:

plugins {
    alias(libs.plugins.ex.library)
    // ...
}

android {
    namespace = "org.cosmosapps.example.core.bluetooth"
}

dependencies {
    // ...
}

Better, right?

What About buildSrc? #

buildSrc is often mentioned when talking about reusable build logic. It is a Gradle-recognized and protected directory that can be used for custom tasks, plugins, and shared code.

However, buildSrc comes with a few drawbacks. The most significant one is that any change to it will invalidate cache and cause the whole project to become out-of-date.

Using Composite Builds and convention plugins will help mitigate those issues.

Wrapping Up #

This article is just a starting point to convention plugins; of course the logic and needs will differ from project to project.

A good demonstration of convention plugins in action is the Now in Android App; you can check the code on GitHub.

Thank you for reading!

CosmosDev
Author
CosmosDev
Android Developer | Focused on Quality, Privacy, and Innovation