Build / Gradle

Gradle: Version Catalogs

In April 2021 Gradle V7.0 was released. It introduces a new experimental feature called version catalogs. These can make life easier when we need to maintain consistent dependency versions in multi-module projects. In this post, we’ll take a look at this new feature, and how it can make life easier.

Regular readers of Styling Android may be aware that I like to organise my dependency versioning. Most recently, I have been using a Dependencies.kt Kotlin file in the buildSrc directory. This gives a single source of truth for all dependency versions. It can make it much easier to update a dependency to a new version in a multi-module project. It can also simplify things when multiple related dependencies share a common version. For example, two distinct libraries share the same lifecycle version in the linked example earlier – e.g. the navigation-fragment and navigation-ui libraries have a common version. Having a single source of truth makes it much easier to maintain your app dependencies.

While this works quite nicely, it can slow the build down. Changes to buildSrc require re-compilation and this is quite slow. The new Gradle feature overcomes this and also offers some other improvements.

I should mention that the following examples all use Kotlin DSL rather than Groovy DSL for the Gradle config files. Version Catalogs are supported for both. However, I opted to go with the Kotlin DSL for two main reasons:

  1. It’s Kotlin 🙂
  2. There is one small extra step required to get Version Catalogs to work with the Kotlin DSL, so by going this route, we can cover that when we come to it.

Version Catalogs

Version Catalogs are a new incubating feature in Gradle. While they may not be in their final form, they seem to work quite nicely in my testing. However, we need to enable this feature in order to use it:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}
rootProject.name = "VersionCatalogs"
include(":app")
enableFeaturePreview("VERSION_CATALOGS")

Next we create a file in the gradle directory of our project:

[versions]
kotlin = "1.4.32"
androidBuildTools = "7.1.0-alpha01"
versions = "0.38.0"
detekt = "1.17.0"
ktlint = "10.0.0"
canidropjetifier = "0.5"

androidx-core = "1.6.0-beta01"
androidx-appcompat = "1.4.0-alpha01"
material = "1.4.0-beta01"
androidx-lifecycle-runtime = "2.4.0-alpha01"
androidx-activity = "1.3.0-alpha08"
compose = "1.0.0-beta07"

testing-junit = "4.13.2"
testing-androidx-junit = "1.1.3-beta01"
testing-espresso-core = "3.4.0-beta01"

[libraries]
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidBuildTools"}
versionsPlugin = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versions" }
detektPlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
ktlintPlugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" }
canidropjetifierPlugin = { module = "com.github.plnice:canidropjetifier", version.ref = "canidropjetifier" }

androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core"}
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat"}
material = { module = "com.google.android.material:material", version.ref = "material"}
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime"}
androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity"}

androidx-composeUi = { module = "androidx.compose.ui:ui", version.ref = "compose"}
androidx-composeMaterial = { module = "androidx.compose.material:material", version.ref = "compose" }
androidx-composeUiTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }

testing-junit = { module = "junit:junit", version.ref = "testing-junit"}
testing-androidx-junit = { module = "androidx.test.ext:junit", version.ref = "testing-androidx-junit"}
testing-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "testing-espresso-core"}
testing-compose-ui = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose"}
testing-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose"}

[bundles]
androidx-compose = [ "androidx-composeUi", "androidx-composeMaterial", "androidx-composeUiTooling" ]

This is a Tom’s Obvious Minimal Language file. The format aims to be easy to understand and remember. It has support for various primitive types along with some simple collections.

When you create a toml file, Android Studio may prompt you to install a plugin. It’s well worth doing this because you’ll get errors when using bundles, otherwise.

Versions

As its name suggests, the versions section at the top contains the actual library versions. I mentioned earlier that sometimes related libraries might share a common version. It can sometimes break things if you only update one of them. So defining the version here gives a single instance that needs updating. In this example, the compose value is used by a number of separate libraries which we’ll see in a moment.

As well as libraries, I have also elected to put the Kotlin and Android Build Tools versions in here along with a number of build tools that I use. This makes this versions section the single source of truth for all dependency and build tool versions for the entire project.

Libraries

The libraries section declares the actual libraries themselves. Once again this is a simple map, although the definitions for each library are a map. The module component is the standard group and name, and the version.ref looks up a value from the versions section. Here we can see the different Compose libraries all have the same version.ref so we’ll get consistent versioning if we update the version value.

Another thing worthy of note is the library naming convention. Here I have used hyphens as separators (e.g. androidx-core-ktx and androidx-appcompat). This enables us to group similar dependencies into logical packages. Essentially, these hyphens will get translated to period characters (“.”) giving a similar feel to Java / Kotlin packages. We’ll see how this works we look at using these declarations in our build.gradle.kts.

Bundles

Bundles are a really useful feature. They allow us to group multiple dependencies in to a single declaration. In this example, the androidx-compose bundle consists of the androidx-composeUi, androidx-composeMaterial, and androidx-composeUiTooling modules. If we use this bundle as a dependency it will include all of the grouped dependencies.

It’s worth mentioning that it is possible to get naming clashes if you’re not aware of some rules. Initially I used the convention androidx-compose-ui instead of androidx-composeUi. When I did this I got errors when declaring a bundle named androidx-compose. That’s because that package name androidx.compose was already defined because of the androidx.compose-ui naming. We’ll explore the reason for this once we look at the usage.

Usage

In the build.gradle in the project root we can use the defined dependencies to specify various build plugins:

buildscript {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
    dependencies {
        val libs = project.extensions.getByType().named("libs") as org.gradle.accessors.dm.LibrariesForLibs
        classpath(libs.android.gradlePlugin)
        classpath(libs.kotlin.gradlePlugin)

        classpath(libs.versionsPlugin)
        classpath(libs.detektPlugin)
        classpath(libs.ktlintPlugin)
        classpath(libs.canidropjetifierPlugin)
    }
}

subprojects {
    apply(plugin ="com.github.ben-manes.versions")
    apply(plugin ="io.gitlab.arturbosch.detekt")
    apply(plugin ="org.jlleitschuh.gradle.ktlint")
    apply(plugin ="com.github.plnice.canidropjetifier")
}

tasks.register("clean", Delete::class) {
    delete(rootProject.buildDir)
}

I mentioned earlier that there is an extra step required when using the Kotlin DSL, and this is where we encounter it. Currently the Kotlin DSL does not declare the libs object in the buildscript scope. There is an open bug for this. I have tested this with Gradle 7.0.0 to 7.0.2 and is not working in any of them. So we have to do it manually (line 8). This line is not required if we are using the Groovy DSL.

It’s worth noting the way we include the Android Gradle Plugin and the Kotlin Gradle Plugin. These were declared as android-gradlePlugin and kotlin-gradlePlugin in the libraries section we looked at earlier. But when we add them as dependencies, we use dot notation instead of the hyphen. This is the logical grouping that we discussed earlier in action. This becomes more apparent in the build.gradle.kts for the app module:

import org.jlleitschuh.gradle.ktlint.reporter.ReporterType

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

android {
    compileSdk = 30

    defaultConfig {
        applicationId = "com.stylingandroid.versioncatalogs"
        minSdk = 21
        targetSdk = 30
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    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"
        useIR = true
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = libs.versions.compose.get()
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.lifecycle.runtime)
    implementation(libs.androidx.activity)
    implementation(libs.bundles.androidx.compose)

    testImplementation(libs.testing.junit)

    debugImplementation(libs.testing.compose.manifest)

    androidTestImplementation(libs.testing.androidx.junit)
    androidTestImplementation(libs.testing.espresso.core)
    androidTestImplementation(libs.testing.compose.ui)
}

detekt {
    version = "1.0.0"
    reports {
        xml {
            destination = file("$project.buildDir/reports/detekt/detekt.xml")
        }
    }
}

ktlint {
    android.set(true)
    version.set("0.41.0")
    ignoreFailures.set(false)
    reporters {
        reporter(ReporterType.PLAIN)
        reporter(ReporterType.CHECKSTYLE)
    }
    outputToConsole.set(true)
}

Once again we can see these logical groupings in place. Essentially we replace the hyphens with dots, but we’ll get a degree of auto-completion when reusing packages.

We don’t need to declare the libs object here as we did for the buildscript secion of the top level build.gradle.kts as it is correctly declared here by Gradle.

The libs.bundles.androidx.compose dependency is the bundle that we saw earlier. A single declaration here includes all of the dependencies that we added to the androidx.compose bundle earlier. Even though this is qualified as a bundle in the name, the logical group is androidx.compose. This is the reason that we get a clash if we used the name androidx.compose.ui because that group will already exist.

Another thing worth of note is that we can look up versions when needed. The kotlinCompilerExtensionVersion does precisely this. It gets the compose version value that was defined in the versions section.

What’s missing

There are some things that are not possible. For example, we might want to add all of the Hilt dependencies into a bundle. This will work for the dependencies themselves, but we can’t add any any kapt plugins to the bundle. The Version Catalog has no knowledge of the configuration in which a dependency will be used. It is only in the app build.gradle.kts that we specify implementation, kapt, api, etc. configurations. The bundle itself just defines a collection of dependencies and not the scope to which they are applied. However, we could create two bundles – one for the library dependencies for Hilt, and another for the kapt dependencies. Then we could apply these:

implementation libsbundles.hilt.libraries
kapt libs.bundles.hilt.kapt

Another thing that is missing is support in IDEs. Although there is a TOML plugin for IDEA / Android Studio, the IDE doesn’t fully support things like auto-completion very well yet. I believe that it is coming to IDEA later this year. This means it will come to Android Studio once that forks the branch of IDEA which contains it.

Conclusion

Version Catalogs don’t offer massive improvements over my older mechanism of a Dependencies file. They seem like an evolution rather than a revolution. However they are much faster to build, and bundles certainly provide some nice functionality. Overall I see them as an improvement, so I’ll be using them henceforth.

The source code for a very simple project using Version Catalogs is available here.

© 2021, Mark Allison. All rights reserved.

Copyright © 2021 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

2 Comments

  1. Hello, I would like to ask, is there a way to know if there are new versions of a library using toml? I mean the IDE suggests to update to newer versions.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.