Android Development

Making your Android project modular with convention plugins

May 22, 2024
Making your Android project modular with convention plugins

If you’re like me, Gra­dle and oth­er build tools can seem like mag­ic bun­dles of text that just hap­pen to build a func­tion­ing application. 

Over time, I’ve begun to bet­ter under­stand build tools like Gra­dle. As an Android project grows, you’ll like­ly need to sep­a­rate code into mod­ules to decou­ple depen­den­cies and enable team mem­bers to work on dif­fer­ent mod­ules with­out conflicts. 

In this blog, I’ll dis­cuss how we can make the process smoother with gra­dle con­ven­tion plu­g­ins.

Before and After #

When cre­at­ing an Android mod­ule from scratch with­in Android Stu­dio, you’ll end up with a Gra­dle build file that looks some­thing like the below (I’m using Kotlin DSL .kts files and a cen­tral 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, includ­ing many mag­ic num­bers for SDK ver­sions and Java com­pat­i­bil­i­ty. One way to remove the hard cod­ed con­stants and share them with oth­er mod­ules is to pull them out into vari­ables through buildSrc or into your libs.version.toml. But what if we took it fur­ther? We could con­dense it down to some­thing like:

plugins {
  id("android-library-convention")
}

android {
  namespace = "com.example.myexamplelibrary"
}

dependencies {
  ...any extra dependencies needed for this module
}

All of the repeat­ed con­fig­u­ra­tion we typ­i­cal­ly have to do inside of the android con­fig­u­ra­tion block is now gone. But, obvi­ous­ly it’s just moved else­where, right?

It is, but now we’re able to spec­i­fy a con­ven­tion plu­g­in at the top of our library mod­ules and inject the con­fig­u­ra­tion we know we need.

How do we define the con­ven­tion plu­g­ins? #

Below, I’ll list the steps I took to cre­ate con­ven­tion plu­g­ins for my project.

  • Cre­ate a direc­to­ry at the base of your project called build-logic.
  • Cre­ate a kotlin/​java mod­ule inside of that direc­to­ry and name it convention (you can change the name of these but to match the direc­tions below they’ll need to match). You should now have a source fold­er and a build.gradle.kts file inside of the convention direc­to­ry. We’ll edit the build file in a bit.
  • Make sure you’re using a libs.versions.toml file in your root project’s gra­dle fold­er. Set­up instruc­tions for that can be found at the link above.
  • Add a settings.gradle.kts file to the build-logic direc­to­ry with con­tents 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 mod­ule, the jvmToolchain ver­sion and depen­den­cies for this mod­ule will change based on what JVM ver­sion your project needs and what depen­den­cies you’re try­ing to reuse across the project. 

In my project I am mak­ing reusable plu­g­ins for android libraries and hilt set­up 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 spec­i­fy­ing these as libraries, not plu­g­ins. That’s because these plu­g­in libraries are depen­den­cies required by the con­ven­tion plu­g­ins, rather than being direct­ly using them as plu­g­ins with­in our Android mod­ules as we typ­i­cal­ly do.

Now go to the base settings.gradle.kts at the root of your project and add build-logic as an includ­ed build:

pluginManagement {
    includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

Time for the fun part: actu­al­ly fill­ing out the con­ven­tion mod­ule with con­ven­tion plu­g­ins. You could do this with just writ­ing the plu­g­ins as Kotlin class­es class AndroidLibraryConventionPlugin : Plugin<Project> {, but I chose to use a mix of Kotlin exten­sion syn­tax and Kotlin DSL syn­tax in gradle. 

If you want to cre­ate a con­ven­tion plu­g­in for reuse in all of your Android libraries, you can cre­ate 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 plu­g­in later). 

Here is what my Android library plu­g­in looks like: 

plugins {
  id("com.android.library")
  kotlin("android")
}

android {
  commonConfiguration(this)
}

kotlin {
  configureKotlinAndroid(this)
}

It’s real­ly sim­ple, but what are these commonConfiguration and configureKotlinAndroid func­tions doing? They are just func­tions defined in an ext direc­to­ry along­side the plu­g­in fold­ers. I have an AndroidExt.kt file with the con­tents 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 cur­rent­ly 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 repeat­ed con­fig­u­ra­tion code we nor­mal­ly write in our Gra­dle files and put them inside these reusable func­tions. The final piece to the puz­zle is just declar­ing the id("android-library-convention") plu­g­in 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 flex­i­ble and move as much as you want into these con­ven­tion plu­g­ins or just make them small­er so you can reuse con­fig­u­ra­tion set­up for things such as the compile-sdk across mod­ules to avoid man­u­al­ly cre­at­ing each indi­vid­ual module.

Here is an exam­ple of a hilt con­ven­tion 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 mod­ule that imports this plu­g­in no longer needs to man­u­al­ly spec­i­fy hilt plu­g­ins or hilt depen­den­cies. This con­ven­tion plu­g­in will do that sim­ply by import­ing it.

The final build-log­ic fold­er should look some­thing like this:

An image showing the build-logic module structure

Final thoughts #

Con­ven­tion plu­g­ins take a small effort up front to put in place. But once your project sup­ports them, they make cre­at­ing addi­tion­al mod­ules a breeze and they are eas­i­er to main­tain if you’re a fea­ture devel­op­er look­ing to add dependencies. 

Check out the Now­InAn­droid project as it includes con­ven­tion plu­g­ins and is a great ref­er­ence of mod­ern Android best prac­tices.

Josh Eldridge
Josh Eldridge
Software Developer

Looking for more like this?

Sign up for our monthly newsletter to receive helpful articles, case studies, and stories from our team.

Lessons Learned from our Associate Developer
Team

Lessons Learned from our Associate Developer

September 13, 2023

One of our Associate Software Developers, Rohit, reflects on his time at MichiganLabs working on a short-term project, what he learned about real-world development, and the software consultancy business model.

Read more
Between the brackets: MichiganLabs’ approach to software development
Development Team

Between the brackets: MichiganLabs’ approach to software development

February 12, 2024

Read more
Simplifying the 4 forces of AI
Business

Simplifying the 4 forces of AI

April 9, 2024

Artificial Intelligence (AI) is becoming more prevalent, but less understood. Through examples of organizations leading the charge in each of these areas, I hope to empower you to make better decisions for your enterprise and career.

Read more
View more articles