Blog Infos
Author
Published
Topics
Published
Topics

Version Catalog & Convention Plugin

 

Introduction:

You’re in charge of a complex Android project with dozens of dependencies, each with its own version. Managing these dependencies manually can be a nightmare, leading to compatibility issues, bugs, and wasted time. However, there’s a hero in the world of Gradle build systems: the Gradle Version Catalog and Convention Plugin. In this article, I will take you on a journey through this powerful duo, exploring how they can revolutionize your dependency management and streamline your development process.

The Dependency Dilemma:
Version mismatches

One of the most common challenges in Android development is dealing with version mismatches. When multiple libraries and dependencies are included in a project, they may rely on different versions of the same underlying libraries or APIs. This can result in conflicts, runtime errors, and unpredictable behavior.

Example: Imagine a scenario where you are using two third-party libraries, LibraryA and LibraryB, in your Android project. LibraryA relies on Retrofit version 2.5.0, while LibraryB requires Retrofit version 2.7.1. Without a robust dependency management system, you would have to manually resolve this version mismatch, potentially leading to runtime issues.

Compatibility issues

Android devices come in various shapes and sizes, each with its own set of hardware capabilities and Android versions. Ensuring that your dependencies are compatible with the target devices and Android versions can be challenging.

Example: Let’s say you are developing an Android app that targets both smartphones and tablets. You are using a library called DeviceUtils, which provides hardware-specific functionality. Without proper management, you might inadvertently include tablet-specific features on smartphones or vice versa, leading to poor user experience and compatibility issues.

Circular dependency

Circular dependencies occur when two or more components depend on each other, forming a loop. This can result in compilation errors, making it difficult to build and maintain your Android project.

Example: Suppose you have two modules in your Android project, ModuleA and ModuleB. ModuleA depends on classes from ModuleB, and ModuleB depends on classes from ModuleA. This circular dependency creates a situation where compiling either module without errors is impossible. Breaking this circular dependency manually can be a tedious and error-prone process.

How Gradle Version Catalog and Convention Plugin Address These Issues
Gradle Version Catalog:

Gradle Version Catalog is a powerful feature that allows you to define and centralize dependency versions in a single location, ensuring that all dependencies use consistent versions throughout your project. This solves the version mismatch problem by providing a single source of truth for dependency versions.

Example:
In your project’s Gradle build file, you can define a version catalog like this:

// This is a sample for pseudo-code to show how the Version catalog is written
dependencies {
    versionCatalogs {
        myVersionCatalog {
            commonLibVersion = "1.0.0"
            retrofitVersion = "2.7.1"
            // Add other dependency versions here
        }
    }
}

When you declare dependencies in your modules, you can reference these versions from the catalog, ensuring that all modules use the same versions.

Convention Plugin:

The Gradle Convention Plugin allows you to enforce project-specific conventions, making it easier to manage compatibility and prevent circular dependencies. You can define and enforce coding and project structure conventions across your Android project

Example:
Consider that you build a plugin based on your clean architectural layers, and in each module that has its own task, only the desired plugin is added. This will apply all related configurations in that module

Enter the Gradle Version Catalog:

A common build.gradle.kts is something like this:

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

android {
    namespace = "garousi.dev.conventionpluginsample"
    compileSdk = 34

    defaultConfig {
        applicationId = "garousi.dev.conventionpluginsample"
        minSdk = 21
        targetSdk = 34
        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"
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.1"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {

    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
    implementation("androidx.activity:activity-compose:1.7.2")
    implementation(platform("androidx.compose:compose-bom:2023.08.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

with powerful convention plugins, we can break this file into specific convention plugins which of those plugins can apply to every module by just applying them with our custom ID:

id("taravaz.android.library)

but how can we do this magic?

let’s step forward by taking some small steps:

From the first point of view, it is a big ghoul, but don’t worry I will explain it all

Programming is an acquired science, so from here on you may see some questions, please read the article completely to understand. I don’t miss anything

in the first step, you should create a file named libs.versions.toml

[versions]

[libraries]

[plugins]

[bundles]

this file is responsible for holding all dependencies, plugins, and their version numbers in a proper way:

let’s move our dependencies and their versions to this file:

[versions]
activity-compose = "1.7.2"
androidx-junit = "1.1.5"
core-ktx = "1.12.0"
espresso-core = "3.5.1"
junit = "4.13.2"
lifecycle-runtime-ktx = "2.6.2"
composeBOM = "2023.08.00"

[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBOM" }
compose-ui-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-ui-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-material3-material3 = { group = "androidx.compose.material3", name = "material3" }

[plugins]


[bundles]

and our dependencies look like this:

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.compose.bom))
    implementation(libs.compose.ui.ui)
    implementation(libs.compose.ui.ui.graphics)
    implementation(libs.compose.ui.ui.tooling.preview)
    implementation(libs.compose.material3.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.compose.bom))
    androidTestImplementation(libs.compose.ui.ui.test.junit4)
    debugImplementation(libs.compose.ui.ui.tooling)
    debugImplementation(libs.compose.ui.ui.test.manifest)
}

In this task, we move our dependencies to a central location so that they can be utilized in all modules. Next comes the convention plugins: I will call it build-logic. This directory includes three new important files and a change in the settings.gradle.kts of the root project as follows:

build.gradle.kts ==> responsible for registering our custom plugins

plugins {
    `kotlin-dsl`
}

group = "garousi.dev.conventionpluginsample.buildlogic"

gradle.properties ==> responsible for configuring this specific build-logic workflow

# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true

settings.gradle.kts ==> responsible for managing build-logic build automation and configuring our version catalog

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"
include(":convention")

root settings.gradle.kts ==>

before:

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "ConventionPluginSample"
include(":app")

after

Job Offers

Job Offers


    Senior Android Developer

    SumUp
    Berlin
    • Full Time
    apply now

    Senior Android Engineer

    Carly Solutions GmbH
    Munich
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

No results found.

Jobs

pluginManagement {
    includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "ConventionPluginSample"
include(":app")

In the following step, let’s open our app module build.gradle file and separate its contents into this folder:

Now we will make our first plugin named AndroidApplicationConventionPlugin

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        with(project) {
            pluginManager.apply {
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
            }
        }
    }
}

Our work is still incomplete, so we moved an application-level build.gradle.kts plugins to here and applied it. We can move the shared blocks of code into a Kotlin file and use it for all plugins, such as configuring kotlinOptions, buildFeatures, and compileOptions which could all go in one place.

When we tried to move the targetSdk, we encountered an IDE error that required us to access ApplicationExtension in order to do this; however, importing this was not possible.

How can we resolve this dilemma? We should incorporate Kotlin and Gradle Plugins into our convention build.gradle.kts file.

[versions]
// previus versuins
kotlin = "1.9.0"
androidGradlePlugin = "8.2.0-beta05"

[libraries]
// previus libereries
# Dependencies of the included build-logic
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }

[plugins]


[bundles]

and then use it in build.gradle.kts file of convention directory:

plugins {
    `kotlin-dsl`
}

group = "garousi.dev.conventionpluginsample.buildlogic"


dependencies {
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.kotlin.gradlePlugin)
}

If you hit the Sync Project With Gradle Files option located under File, it will cause the IDE to show this:

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        with(project) {
            pluginManager.apply {
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
            }
            val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
            extensions.configure<ApplicationExtension> {
                defaultConfig.targetSdk = Integer.parseInt(libs.findVersion("projectTargetSdkVersion").get().toString())
            }
        }
    }
}

as you see we get libs and then set it, but keep in mind we want to access libs in many places. the best work we can do is create an extension over Project for getting libs:

let’s do this:

as you see we get targetSdk from the version catalog but we don’t add it in libs.versions.toml file
[versions]
// previus versions
projectApplicationId = "garousi.dev.conventionpluginsample"
projectVersionName = "1.0"
projectMinSdkVersion = "21"
projectTargetSdkVersion = "34"
projectCompileSdkVersion = "34"
projectVersionCode = "1"

let’s create a KotlinAndroid file and do some stuff here:

import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.ExtensionAware
import org.gradle.kotlin.dsl.provideDelegate
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions

internal fun Project.configureKotlinAndroid(
    commonExtension: CommonExtension<*, *, *, *, *>
) {
    commonExtension.apply {
        compileSdk = Integer.parseInt(libs.findVersion("projectCompileSdkVersion").get().toString())
        defaultConfig {
            minSdk = Integer.parseInt(libs.findVersion("projectMinSdkVersion").get().toString())
        }

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

        kotlinOptions {
            // Treat all Kotlin warnings as errors (disabled by default)
            // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
            val warningsAsErrors: String? by project
            allWarningsAsErrors = warningsAsErrors.toBoolean()


            // Set JVM target to 17
            jvmTarget = JavaVersion.VERSION_17.toString()
        }


        packaging {
            resources {
                excludes += "/META-INF/{AL2.0,LGPL2.1}"
            }
        }
    }
}

fun CommonExtension<*, *, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
    (this as ExtensionAware).extensions.configure("kotlinOptions", block)
}

AndroidApplicationConventionPlugin looks like this

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        with(project) {
            pluginManager.apply {
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<ApplicationExtension> {
                configureKotlinAndroid(this)
                defaultConfig.targetSdk =
                    Integer.parseInt(libs.findVersion("projectTargetSdkVersion").get().toString())
            }
        }
    }
}

let’s register our custom plugin for being usable by the gradle build system:

plugins {
    `kotlin-dsl`
}

group = "garousi.dev.conventionpluginsample.buildlogic"


dependencies {
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.kotlin.gradlePlugin)
}

gradlePlugin {
    plugins {
        register("androidApplication") {
            id = "conventionpluginsample.android.application"
            implementationClass = "AndroidApplicationConventionPlugin"
        }
    }
}

Let’s go to app-level build.gradle.kts file and refactor it in order to use our new convention plugin

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

android {
    namespace = "garousi.dev.conventionpluginsample"

    defaultConfig {
        applicationId = "garousi.dev.conventionpluginsample"
        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"
            )
        }
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.1"
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.compose.bom))
    implementation(libs.compose.ui.ui)
    implementation(libs.compose.ui.ui.graphics)
    implementation(libs.compose.ui.ui.tooling.preview)
    implementation(libs.compose.material3.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.compose.bom))
    androidTestImplementation(libs.compose.ui.ui.test.junit4)
    debugImplementation(libs.compose.ui.ui.tooling)
    debugImplementation(libs.compose.ui.ui.test.manifest)
}

I will cease further rambling for the sake of brevity. However, more information can be found through researching the ComposeNews project, where I accomplished many noteworthy tasks.

Ready to Master Dependency Management?

Explore the power of Gradle Version Catalog and Convention Plugin to streamline your Android project’s dependency management. Start revolutionizing your development process and say goodbye to version conflicts and compatibility issues. If you’re hungry for more knowledge and want to see these tools in action, don’t forget to check out our project, ComposeNews, where we’ve applied these principles extensively. Get started today and experience smoother, more efficient Android development!

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
This is the second article in an article series that will discuss the dependency…
READ MORE
blog
Let’s suppose that for some reason we are interested in doing some tests with…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu