Search code examples
androidgradleandroid-gradle-plugingradle-kotlin-dsl

Modularization in Android


I am trying to learn how modularization works in Android. I am using Kotlin DSL.

In each build.gradle.kts file, do I have to add default config such as minSdk, target SDK etc? In other words, can I only set configuration in for example app module build.gradle.kts file and in other modules for example for features, can I only declare dependencies?

NowInAndroid app has following code for build.gradle.kts file:

plugins {
    id("nowinandroid.android.feature")
    id("nowinandroid.android.library.compose")
    id("nowinandroid.android.library.jacoco")
}

android {
    namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks"
}

dependencies {
    implementation(libs.androidx.compose.material3.windowSizeClass)
}

which only declares dependencies and plugins, but I suppose that in those plugins there are different configurations.

The other method I saw was to inherit from other build.gradle file by using

apply {
 (path to build.gradle) file
}

But with using that method I can't declare namespace, therefore I have to add this in build.gradle file, which is shared across different features and without it I can't access resources in given feature module.


Solution

  • Build logic in a multi-project build can be organized into reusable plugins (Gradle Convention Plugins).
    Convention plugin does not refer to a specific plugin (like com.android.library), but rather to a class of precompiled plugins whose role is to extract some of the shared build logic.


    First, let's manually create a new module call build-logic with the following structure:

    rootProject
    ├─build-logic
    │  │  settings.gradle.kts
    │  └─convention
    │      │  build.gradle.kts
    │      └─src
    │          └─main
    │              └─kotlin
    │                    AndroidLibraryConventionPlugin.kt
    ├─...
    

    We will include the build-logic module in the root Project with Composing builds.

    A composite build is simply a build that includes other builds. In many ways a composite build is similar to a Gradle multi-project build, except that instead of including single projects, complete builds are included.

    rootProject/settings.gradle.kts:

    pluginManagement {
        includeBuild("build-logic") // include build-logic module
        repositories {
            google()
            mavenCentral()
            gradlePluginPortal()
        }
    }
    
    dependencyResolutionManagement {
        repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
        repositories {
            google()
            mavenCentral()
        }
    }
    
    rootProject.name = "GradleConventionPluginsSample"
    include(":app")
    

    A build that is included in a composite build is referred to, naturally enough, as an "included build". Included builds do not share any configuration with the composite build, or the other included builds. Each included build is configured and executed in isolation.

    Since the included build is completely independent, we need to declare the repository source in settings.gradle.kts, and also explicitly declare the path of version catalogs file.

    rootProject/build-logic/settings.gradle.kts:

    dependencyResolutionManagement {
        repositories {
            google()
            mavenCentral()
        }
        versionCatalogs {
            create("libs") {
                // make sure the file rootProject/gradle/verison.toml exists!
                from(files("../gradle/libs.versions.toml"))
            }
        }
    }
    
    rootProject.name = "build-logic"
    include(":convention")
    

    Note that you need to ensure that the file rootProject/gradle/verison.toml exists, and add the following lines to it.

    rootProject/gradle/verison.toml:

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

    rootProject/build-logic/convention/build.gradle.kts:

    import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
    
    plugins {
        `kotlin-dsl`
    }
    
    group = "com.bqliang.gradleconventionplugins.buildlogic"
    
    // Configure the build-logic plugins to target JDK 17
    // This matches the JDK used to build the project, and is not related to what is running on device.
    java {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    tasks.withType<KotlinCompile>().configureEach {
        kotlinOptions {
            jvmTarget = JavaVersion.VERSION_17.toString()
        }
    }
    
    dependencies {
        compileOnly(libs.android.gradlePlugin)
        compileOnly(libs.kotlin.gradlePlugin)
    }
    
    gradlePlugin {
        // register the convention plugin
        plugins {
            register("androidLibrary") {
                id = "bqliang.android.library"
                implementationClass = "AndroidLibraryConventionPlugin"
            }
        }
    }
    

    In the file of build-logic/convention/build.gradle.kts, We apply kotlin-dsl plugin, add dependencies... and finally register the convention plugin.

    Note that we haven't implemented the implementationClass yet, this is our final step.


    Put shared build logic in AndroidLibraryConventionPlugin so that we can reuse it in other Android Library modules.

    rootProject/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt:

    import com.android.build.api.dsl.CommonExtension
    import com.android.build.gradle.LibraryExtension
    import org.gradle.api.JavaVersion
    import org.gradle.api.Plugin
    import org.gradle.api.Project
    import org.gradle.kotlin.dsl.configure
    import org.gradle.kotlin.dsl.withType
    import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
    
    class AndroidLibraryConventionPlugin : Plugin<Project> {
        override fun apply(project: Project) {
            with(project) {
                with(pluginManager) {
                    apply("com.android.library")
                    apply("org.jetbrains.kotlin.android")
                }
    
                extensions.configure<LibraryExtension> {
                    configureKotlinAndroid(this)
                    defaultConfig.targetSdk = 34
                }
            }
        }
    
        private fun Project.configureKotlinAndroid(
            commonExtension: CommonExtension<*, *, *, *, *>,
        ) {
            commonExtension.apply {
                compileSdk = 34
    
                defaultConfig {
                    minSdk = 26
                }
    
                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_11
                    targetCompatibility = JavaVersion.VERSION_11
                }
            }
    
            configureKotlin()
        }
    
        private fun Project.configureKotlin() {
            // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947
            tasks.withType<KotlinCompile>().configureEach {
                kotlinOptions {
                    // Set JVM target to 11
                    jvmTarget = JavaVersion.VERSION_11.toString()
                }
            }
        }
    }
    

    My Gradle version is 8.4, if you can't find some classes in the above code, you can try to upgrade your Gradle version first.
    rootProject/gradle/wrapper/gradle-wrapper.properties:
    distributionUrl=https://services.gradle.org/distributions/gradle-8.4-bin.zip


    Since we're already using the Version Catalog. Why not put the plugin id of AndroidLibraryConventionPlugin we just created in libs.versions.toml file?

    rootProject/gralde/libs.versions.toml:

    [versions]
    ...
    
    [libraries]
    ...
    
    [plugins]
    bqliang-android-library = { id = "bqliang.android.library", version = "unspecified" }
    ...
    

    Now we can use the AndroidLibraryConventionPlugin in other android library modules. Create a new Android library module called mylibrary by Android Studio. The build.gradle.kts in mylibrary module is as follows:

    @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
    plugins {
        alias(libs.plugins.com.android.library)
        alias(libs.plugins.org.jetbrains.kotlin.android)
    }
    
    android {
        namespace = "com.bqliang.mylibrary"
        compileSdk = 33
    
        defaultConfig {
            minSdk = 24
    
            testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
            consumerProguardFiles("consumer-rules.pro")
        }
    
        ...
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_1_8
            targetCompatibility = JavaVersion.VERSION_1_8
        }
        kotlinOptions {
            jvmTarget = "1.8"
        }
    }
    
    dependencies {
        ...
    }
    

    We have put the above build logic about Android Library in AndroidLibraryConventionPlugin, so we can remove them and just apply our convention plugin.

    plugins {
        // just apply out convention plugin simply here
        alias(libs.plugins.bqliang.android.library)
    }
    
    android {
        namespace = "com.bqliang.mylibrary"
    
        defaultConfig {
            testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
            consumerProguardFiles("consumer-rules.pro")
        }
    
        buildTypes {
            release {
                isMinifyEnabled = false
                proguardFiles(
                    getDefaultProguardFile("proguard-android-optimize.txt"),
                    "proguard-rules.pro"
                )
            }
        }
    }
    
    dependencies {
        ...
    }
    

    Great! We have created a reusable convention plugin that shares build logic across multiple projects. In this way, we can abstract the same build logic(like compileSdk, sourceCompatibility, targetCompatibility...) from different modules. As far as I know, Now in Android project also use this way.

    The above is just a very simple example, actually you can share more build logic in the convention plugin, like productFlavors, Jetpack Compose, Room, build type, test, etc.

    These plugins are additive and composable, and try to only accomplish a single responsibility. Modules can then pick and choose the configurations they need.


    References: