Configuring multi-module projects

Background



Sometimes, when I procrastinate, I do the cleaning: I clean the table, put things out, tidy up the room. In fact, I put the environment in order - it energizes and sets you up for work. With programming, I have the same situation, only I clean the project: I carry out refactorings, make various tools and do my best to make life easier for myself and my colleagues.



Some time ago, we in the Android team decided to make one of our projects - Wallet - multi-modular. This led to both a number of advantages and problems, one of which is the need to configure each module from scratch. Of course, you can just copy the configuration from module to module, but if we want to change something, we'll have to iterate over all the modules.



I don't like this, the team doesn't like it, and here are the steps we've taken to simplify our lives and make configurations easier to maintain.







First iteration - pulling out library versions



In fact, this was already in the project before me, and you may know this approach. I often see developers using it.



The approach is that it is necessary to move the versions of libraries into separate global properties of the project, then they become available throughout the project, which helps to reuse them. This is usually done in the build.gradle file at the project level, but sometimes these variables are taken out into a separate .gradle file and included in the main build.gradle.



Most likely, you have already seen such code in the project. There is no magic in it, it is just one of the Gradle extensions called ExtraPropertiesExtension . In short, it's just Map <String, Object>, available by ext in the project object, and everything else - working as if with an object, configuration blocks and so on - the magic of Gradle. Examples:

.gradle .gradle.kts
// creation
ext {
  dagger = '2.25.3'
  fabric = '1.25.4'
  mindk = 17
}

// usage
println(dagger)
println(fabric)
println(mindk)


// creation
val dagger by extra { "2.25.3" }
val fabric by extra { "1.25.4" }
val minSdk by extra { 17 }

// usage
val dagger: String by extra.properties
val fabric: String by extra.properties
val minSdk: Int by extra.properties




What I like about this approach is that it is extremely simple and helps keep the versions running. But it has disadvantages: you need to make sure that developers use versions from this set, and this does not greatly simplify the creation of new modules, because you still have to copy a lot of things.



By the way, a similar effect can be achieved using gradle.properties instead of ExtraPropertiesExtension, just be careful : your versions can be overridden when building using the -P flags, and if you refer to a variable simply by name in groovy-scripts, then gradle.properties will be replaced and them. Example with gradle.properties and override:



// grdle.properties
overriden=2

// build.gradle
ext.dagger = 1
ext.overriden = 1

// module/build.gradle
println(rootProject.ext.dagger)   // 1
println(dagger)                   // 1

println(rootProject.ext.overriden)// 1
println(overriden)                // 2


Second iteration - project.subprojects



My curiosity, remembered by my unwillingness to copy the code and deal with the configuration of each module, led me to the next step: I remembered that in the root build.gradle there is a block that is generated by default - allprojects .



allprojects {
    repositories {
        google()
        jcenter()
    }
}


I went to the documentation and found that you can pass a block of code into it that will configure this project and all nested projects. But this is not quite what I needed, so I scrolled further and found subprojects - a method for configuring all nested projects at once. I had to add a few checks, and this is what happened .



Example of configuring modules via project.subprojects
subprojects { project ->
    afterEvaluate {
        final boolean isAndroidProject =
            (project.pluginManager.hasPlugin('com.android.application') ||
                project.pluginManager.hasPlugin('com.android.library'))

        if (isAndroidProject) {
            apply plugin: 'kotlin-android'
            apply plugin: 'kotlin-android-extensions'
            apply plugin: 'kotlin-kapt'
            
            android {
                compileSdkVersion rootProject.ext.compileSdkVersion
                
                defaultConfig {
                    minSdkVersion rootProject.ext.minSdkVersion
                    targetSdkVersion rootProject.ext.targetSdkVersion
                    
                    vectorDrawables.useSupportLibrary = true
                }

                compileOptions {
                    encoding 'UTF-8'
                    sourceCompatibility JavaVersion.VERSION_1_8
                    targetCompatibility JavaVersion.VERSION_1_8
                }

                androidExtensions {
                    experimental = true
                }
            }
        }

        dependencies {
            if (isAndroidProject) {
                // android dependencies here
            }
            
            // all subprojects dependencies here
        }

        project.tasks
            .withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile)
            .all {
                kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString()
            }
    }
}




Now, for any module with the com.android.application or com.android.library plugin connected, we can configure anything: plug-in plugins, plugin configurations, dependencies.



Everything would be fine if it were not for a couple of problems: if we want to override some parameters specified in subprojects in the module, then we will not be able to do this, because the module is configured before applying subprojects (thanks to afterEvaluate ). And also, if we want to not apply this automatic configuration in individual modules, then many additional checks will begin to appear in the subprojects block. So I started thinking further.



Third iteration - buildSrc and plugin



Up to this point, I had heard about buildSrc several times and saw examples in which buildSrc was used as an alternative to the first step in this article. And I also heard about gradle plugins, so I started digging in this direction. Everything turned out to be very simple: Gradle has documentation for developing custom plugins , in which everything is written.



After understanding a little, I made a plugin that can configure everything that needs to be changed with the ability to change if necessary.



Plugin code
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project

class ModulePlugin implements Plugin<Project> {
    @Override
    void apply(Project target) {
        target.pluginManager.apply("com.android.library")
        target.pluginManager.apply("kotlin-android")
        target.pluginManager.apply("kotlin-android-extensions")
        target.pluginManager.apply("kotlin-kapt")

        target.android {
            compileSdkVersion Versions.sdk.compile

            defaultConfig {
                minSdkVersion Versions.sdk.min
                targetSdkVersion Versions.sdk.target

                javaCompileOptions {
                    annotationProcessorOptions {
                        arguments << ["dagger.gradle.incremental": "true"]
                    }
                }
            }

            // resources prefix: modulename_
            resourcePrefix "${target.name.replace("-", "_")}_"

            lintOptions {
                baseline "lint-baseline.xml"
            }

            compileOptions {
                encoding 'UTF-8'
                sourceCompatibility JavaVersion.VERSION_1_8
                targetCompatibility JavaVersion.VERSION_1_8
            }

            testOptions {
                unitTests {
                    returnDefaultValues true
                    includeAndroidResources true
                }
            }
        }

        target.repositories {
            google()
            mavenCentral()
            jcenter()
            
            // add other repositories here
        }

        target.dependencies {
            implementation Dependencies.dagger.dagger
            implementation Dependencies.dagger.android
            kapt Dependencies.dagger.compiler
            kapt Dependencies.dagger.androidProcessor

            testImplementation Dependencies.test.junit
            
            // add other dependencies here
        }
    }
}




Now the configuration of the new project looks like apply plugin: ⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠'ru.yandex.money.module ' and that's it. You can make your own additions to the android or dependencies block, you can add plugins or customize them, but the main thing is that a new module is configured in one line, and its configuration is always relevant and the product developer no longer needs to think about setting it up.



Of the minuses, I would note that this solution requires additional time and study of the material, but, from my point of view, it is worth it. If you want to move the plugin as a separate project in the future, then I would not recommend setting up dependencies between modules in the plugin .



An important point: if you are using android gradle plugin below 4.0, then some things are very difficult to do in kotlin scripts - at least the android block is easier to configure in groovy scripts. There is a problem with the fact that some types are not available at compile time, and groovy is dynamically typed, and it does not matter to him =)



Next - standalone plugin or monorepo



Of course, the third step is not all. There is no limit to perfection, so there are options for where to go next.



The first option is the standalone plugin for gradle. After the third step, it is no longer so difficult: you need to create a separate project, transfer the code there and set up the publication.



Pros: the plugin can be fumbled between several projects, which will simplify life not in one project, but in the ecosystem.



Cons: versioning - when updating a plugin, you will have to update and check its functionality in several projects at once, and this can take time. By the way, my colleagues from backend development have an excellent solution on this topic, the keyword is modernizer - a tool that itself walks through repositories and updates dependencies. I will not dwell on this for a long time, it would be better if they themselves tell.



Monorepo - it sounds loud, but I have no experience with it, but there are only considerations that one project, like buildSrc, can be used in several other projects at once, and this could help solve the issue with versioning. If suddenly you have experience with monorepo, then share it in the comments so that I and other readers can learn something about it.



Total



In a new project, do the third step right away - buildSrc and plugin - it will be easier for everyone, especially since I have attached the code . And the second step - project.subprojects - is used to connect common modules to each other.



If you have something to add or object to, write in the comments or look for me on social networks.



All Articles