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:
- It’s Kotlin 🙂
- 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.
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.
Not currently. However, there are third-party plugins that do just that. I generally use Ben Manes’ Gradle Versions Plugin to do this.