Конфигурация многомодульных проектов

    Предыстория


    Иногда, когда я прокрастинирую, я занимаюсь уборкой: чищу стол, раскладываю вещи, прибираюсь в комнате. По сути, привожу окружающую среду в порядок — это заряжает энергией и настраивает на рабочий лад. С программированием у меня та же ситуация, только я чищу проект: провожу рефакторинги, делаю различные инструменты и всячески стараюсь упростить жизнь себе и коллегам.

    Некоторое время назад мы в команде Android решили сделать один из наших проектов — Кошелек — многомодульным. Это привело как к ряду преимуществ, так и проблем, одна из которых — необходимость конфигурировать каждый модуль заново. Конечно, можно просто копировать конфигурацию из модуля в модуль, но если мы захотим что-то поменять, то придется перебрать все модули.

    Мне это не нравится, команде это не нравится, и вот какие шаги мы предприняли, чтобы упростить нашу жизнь и сделать конфигурации проще в сопровождении.



    Первая итерация — вынос версий библиотек


    На самом деле это уже было в проекте до меня, и вы, возможно, знаете этот подход. Я часто вижу, как разработчики пользуются им.

    Подход заключается в том, что надо выносить версии библиотек в отдельные глобальные свойства проекта, тогда они становятся доступны по всему проекту, что помогает использовать их многократно. Обычно это делается в файле build.gradle на уровне проекта, но иногда эти переменные выносят в отдельный .gradle файл и подключают в основном build.gradle.

    Скорее всего, вы уже видели такой код в проекте. В нем нет никакой магии, это просто одно из расширений Gradle под названием ExtraPropertiesExtension. Если кратко, то это просто Map<String, Object>, доступный по имени ext в объектe project, а все остальное — работа как будто с объектом, блоки конфигурации и прочее — магия Gradle. Примеры:
    .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
    


    Что мне нравится в этом подходе: он крайне простой и помогает версиям не разъезжаться. Но у него есть минусы: надо следить, чтобы разработчики использовали версии из этого набора, и это не сильно упрощает создание новых модулей, потому что всё равно приходится копировать еще много чего.

    Кстати, подобного эффекта можно добиться, используя gradle.properties вместо ExtraPropertiesExtension, только будьте осторожны: ваши версии можно будет переопределить при сборке с помощью -P флагов, а если вы обращаетесь к переменной просто по имени в groovy-cкриптах, то gradle.properties заменят и их. Пример с gradle.properties и переопределением:

    // 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
    

    Вторая итерация — project.subprojects


    Моя любознательность, помноноженная на нежелание копировать код и разбираться с настройкой каждого модуля, привела меня к следующему шагу: я вспомнил, что в корневом build.gradle есть блок, который генерируется по умолчанию — allprojects.

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

    Я сходил в документацию и нашел, что в него можно передать блок кода, который будет конфигурировать этот проект и все вложенные проекты. Но это не совсем то, что было нужно, поэтому я пролистал дальше и нашел subprojects — метод для конфигурации сразу всех вложенных проектов. Пришлось добавить немного проверок, и вот что получилось.

    Пример конфигурации модулей через 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()
                }
        }
    }
    


    Теперь для любого модуля с подключенным плагином com.android.application или com.android.library мы можем настраивать что угодно: подключаемые плагины, конфигурации плагинов, зависимости.

    Все было бы отлично, если бы не пара проблем: если мы захотим в модуле переопределить какие-то параметры, заданные в subprojects, то у нас это не получится, потому что конфигурация модуля происходит до применения subprojects (спасибо afterEvaluate). А еще если мы захотим не применять это автоматическое конфигурирование в отдельных модулях, то в блоке subprojects начнет появляться много дополнительных проверок. Поэтому я стал думать дальше.

    Третья итерация — buildSrc и plugin


    До этого момента я уже несколько раз слышал про buildSrc и видел примеры, в которых buildSrc использовали как альтернативу первому шагу из этой статьи. А еще я слышал про gradle plugin’ы, поэтому стал копать в этом направлении. Все оказалось очень просто: у Gradle есть документация по разработке кастомных плагинов, в которой все написано.

    Немного разобравшись, я сделал плагин, который может настраивать все что нужно с возможностью изменять при необходимости.

    Код плагина
    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
            }
        }
    }
    


    Теперь конфигурация нового проекта выглядит как apply plugin: ⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠‘ru.yandex.money.module’ и все. Можно вносить свои дополнения в блок android или dependencies, можно добавлять плагины или настраивать их, но главное, что новый модуль конфигурируется одной строкой, а его конфигурация всегда актуальна и продуктовому разработчику больше не надо думать про настройку.

    Из минусов я бы отметил то, что для этого решения нужны дополнительное время и изучение материала, но, с моей точки зрения, оно того стоит. Если вы захотите в будущем выносить плагин, как отдельный проект, то я бы не рекомендовал настраивать зависимости между модулями в плагине.

    Важный момент: если вы используете android gradle plugin ниже 4.0, то некоторые вещи очень сложно сделать в kotlin-скриптах — по крайней мере, блок android проще конфигурировать в groovy-скриптах. Там есть проблема с тем, что некоторые типы недоступны при компиляции, а groovy — динамически типизированный, и ему это не важно =)

    Дальше — standalone plugin или монорепо


    Конечно же, третий шаг — это еще не всё. Нет предела совершенству, поэтому есть варианты, куда двигаться дальше.

    Первый вариант — standalone plugin для gradle. После третьего шага это уже не так сложно: надо создать отдельный проект, перенести туда код и настроить публикацию.

    Плюсы: плагин можно шарить между несколькими проектами, что упростит жизнь не в одном проекте, а в экосистеме.

    Минусы: версионирование — при обновлении плагина придется обновлять и проверять его работоспособность в нескольких проектах сразу, а это может занять время. Кстати, на эту тему у моих коллег из бэкенд-разработки есть отличное решение, ключевое слово — modernizer — инструмент, который сам ходит по репозиториям и обновляет зависимости. Не буду на этом долго задерживаться, пусть лучше они сами расскажут.

    Монорепо — это звучит громко, но у меня нет опыта работы с ним, а есть только соображения, что один проект, вроде buildSrc, можно использовать сразу в нескольких других проектах, и это могло бы помочь решить вопрос с версионированием. Если вдруг у тебя есть опыт работы с монорепо, то поделись в комментариях, чтобы я и другие читатели могли что-то узнать про это.

    Итого


    В новом проекте делай сразу третий шаг — buildSrc и plugin — проще будет всем, тем более, что код я приложил. А второй шаг — project.subprojects — используй для того, чтобы подключать общие модули между собой.

    Если у тебя есть что добавить или возразить, пиши в комментарии или ищи меня в соцсетях.
    ЮMoney
    Всё о разработке сервисов онлайн-платежей

    Комментарии 1

      +1
      Важный момент: если вы используете android gradle plugin ниже 4.0, то некоторые вещи очень сложно сделать в kotlin-скриптах

      Подтверждаю. Год назад по работе писал такие плагины на котлин с AGP 3.x: без погружения в тонкости Gradle переписать на Kotlin даже простейший код из примеров — задача нетривиальная

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое