Привет, Хабр! Я Дима Котиков, ведущий android-разработчик в Т-Банке. Работаю в команде приложения Долями. Разработкой под Android начал увлекаться в 2020 году, а потом хобби переросло в работу. Люблю разбираться в технологиях, разрабатывать под Android и KMP и латте на фундучном молоке :)

Я расскажу о том, как облегчить работу с Gradle с использованием Gradle Convention Plugins. Всю информацию я разбил на серию статей для удобства. Они будут полезны всем, кто пользуется Gradle в качестве сборщика проектов. В первой части поговорим о проблеме с build.gradle-файлами и сделаем начальную настройку для написания Gradle Convention Plugins.

build.gradle.kts — монстр

Каждый android-разработчик так или иначе сталкивается со сборщиком проектов Gradle и видит в своих модулях файлы build.gradle или build.gradle.kts. Дальше примеры будут на базе build.gradle.kts-файлов для kotlin-multiplatform-проекта, но в целом информация применима и к build.gradle.

Скрытый текст
import org.jetbrains.compose.ExperimentalComposeLibrary
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import com.android.build.api.dsl.ManagedVirtualDevice
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
  
plugins {
    alias(libs.plugins.multiplatform)
    alias(libs.plugins.compose.compiler)
    alias(libs.plugins.compose)
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlinx.serialization)
}
kotlin {
    androidTarget {
        compilations.all {
            compileTaskProvider {
                compilerOptions {
                    jvmTarget.set(JvmTarget.JVM_1_8)
                    freeCompilerArgs.add("-Xjdk-release=${JavaVersion.VERSION_1_8}")
                }
            }
        }
        //https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
        @OptIn(ExperimentalKotlinGradlePluginApi::class)
        instrumentedTestVariant {
            sourceSetTree.set(KotlinSourceSetTree.test)
            dependencies {
                debugImplementation(libs.androidx.testManifest)
                implementation(libs.androidx.junit4)
            }
        }
    }
    jvm()
  
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "ComposeApp"
            isStatic = true
        }
    }
    sourceSets {
        commonMain.dependencies {
            implementation(project(":shared-uikit"))
  
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
            implementation(libs.coil)
            implementation(libs.coil.network.ktor)
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.ktor.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.datetime)
        }
        commonTest.dependencies {
            implementation(kotlin("test"))
            @OptIn(ExperimentalComposeLibrary::class)
            implementation(compose.uiTest)
            implementation(libs.kotlinx.coroutines.test)
        }
        androidMain.dependencies {
            implementation(compose.uiTooling)
            implementation(libs.androidx.activityCompose)
            implementation(libs.kotlinx.coroutines.android)
            implementation(libs.ktor.client.okhttp)
        }
        jvmMain.dependencies {
            implementation(compose.desktop.currentOs)
            implementation(libs.kotlinx.coroutines.swing)
           implementation(libs.ktor.client.okhttp)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}
 
android {
    namespace = "io.github.dmitriy1892.gradleconventionpuginssample"
    compileSdk = 34
    defaultConfig {
        minSdk = 24
        targetSdk = 34
        applicationId = "io.github.dmitriy1892.gradleconventionpuginssample.androidApp"
        versionCode = 1
        versionName = "1.0.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    sourceSets["main"].apply {
        manifest.srcFile("src/androidMain/AndroidManifest.xml")
        res.srcDirs("src/androidMain/res")
    }
    //https://developer.android.com/studio/test/gradle-managed-devices
    @Suppress("UnstableApiUsage")
    testOptions {
        managedDevices.devices {
            maybeCreate<ManagedVirtualDevice>("pixel5").apply {
                device = "Pixel 5"
                apiLevel = 34
                systemImageSource = "aosp"
            }
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    buildFeatures {
        //enables a Compose tooling support in the AndroidStudio
        compose = true
    }
}
 
compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "io.github.dmitriy1892.gradleconventionpuginssample.desktopApp"
            packageVersion = "1.0.0"
        }
    }
}

Код выглядит монструозно. Если проект многомодульный, в каждом модуле содержится build.gradle.kts-файл примерно с таким же наполнением.

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

1. Поднять версии библиотек, minSdk/targetSdk/compileSdk или Java;

2. Поднять версию Gradle-wrapper или Android Gradle Plugin, что в случае изменения API классов или функций gradle-конфигураций может повлечь необходимость изменений в файлах build.gradle.kts каждого модуля;

3. Обработать deprecation-ы разного рода конфигураций в новых версиях Gradle Plugin, AGP, KMP и тому подобное:

4. Копипастить все из build.gradle.kts при необходимости создать модуль вручную и нудно настраивать под конкретный модуль.

Хочется избавиться от этого списка проблем, повторяющихся частей, соблюсти DRY. На помощь может прийти Gradle Convention Plugins.

Gradle Convention Plugins

Gradle Convention Plugins — это инструмент, позволяющий переиспользовать конфигурации сборки, уменьшить Boilerplate в build.gradle.kts-файлах и проще управлять изменениями при смене версий библиотек, плагинов и тому подобного. А еще он упрощает поддержку конфигураций сборки.

Для использования механизма нужно создать модуль для Convention Plugins, выделить базовые конфигурации, применить их при написании Convention Plugins, зарегистрировать плагины в build.gradle.kts нашего модуля с плагинами и использовать в проекте. 

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

Пример того, как можно писать Convention Plugins, есть в моем проекте на GitHub, начальный код в ветке initial.

Нам нужно подготовиться, чтобы написать Convention Plugins:

  1. Создать gradle-модуль, не зависящий от каких-либо других в проекте, — в нем мы будем размещать наши будущие Convention Plugins.

  1. Подключить модуль с плагинами в корневой проект так, чтобы он собирался до сборки остальных модулей проекта. Результат сборки — скомпилированные Convention Plugins, будем использовать в модулях основного проекта.

  2. Сконфигурировать build.gradle.kts и settings.gradle.kts этого модуля правильным образом, чтобы Gradle понимал, что это модуль с плагинами.

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

Начинаем настройку для написания Gradle Convention Plugins:

1. Создадим папку convention-plugins в проекте, в ней создадим папку base, в которой будут содержаться базовые конфигурации и extension-функции для наших плагинов. Добавим пустые gradle-файлы base-модуля.

Заготовка базового модуля под плагины
Заготовка базового модуля под плагины

2. Зайдем в settings.gradle.kts и пропишем наш модуль как includeBuild:

Подключение модуля convention plugins в settings.gradle.kts проекта
Подключение модуля convention plugins в settings.gradle.kts проекта

Подключение модуля Convention Plugin отличается от подключения обычного модуля функцией includeBuild, а путь прописывается не через двоеточия, а через slash (`/`).
Вот тут как раз и будет работать механизм композитной сборки: сначала соберется модуль или проект, указанный в includeBuild, а потом — основной проект.

3. Конфигурируем build.gradle.kts и settings.gradle.kts для нашего модуля с плагинами.

Конфигурируем settings.gradle.kts — пропишем необходимые репозитории с плагинами и зависимостями, из которых будем запрашивать нужные нам зависимости для использования в наших будущих Convention Plugin-ах:

 import java.net.URI
  
 pluginManagement {
     repositories {
         google()
         gradlePluginPortal()
         mavenCentral()
         maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
     }
 }
 
 dependencyResolutionManagement {
     repositories {
         google()
         mavenCentral()
         maven {
             url = URI("https://androidx.dev/storage/compose-compiler/repository/")
         }
     }
     versionCatalogs {
         create("libs") {
             from(files("../../gradle/libs.versions.toml"))
         }
     }
 }
 rootProject.name = "base"

Мы указали репозитории для плагинов и зависимостей, прописали создание Version Catalog и указали путь до libs.versions.toml-файла.

Добавляем в libs.versions.toml-файл переменную с версией Java и зависимости нужных нам плагинов и конфигурируем build.gradle.kts модуля convention-plugins/base:

Добавление зависимостей плагинов и версии Java в libs.versions.toml
Добавление зависимостей плагинов и версии Java в libs.versions.toml

Дублирую зависимости для тех, кто идет по шагам при чтении статьи:

# Plugins for composite build
 gradleplugin-android = { module = "com.android.tools.build:gradle", version.ref = "agp" }
 gradleplugin-compose = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose" }
 gradleplugin-composeCompiler = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "compose" }
 gradleplugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }

Конфигурируем build.gradle.kts модуля convention-plugins/base:

 import org.jetbrains.kotlin.gradle.dsl.JvmTarget
 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
  
 plugins {
     `kotlin-dsl`
 }
 
 
 group = "io.github.dmitriy1892.conventionplugins.base"
 
 
 dependencies {
     implementation(libs.gradleplugin.android)
     implementation(libs.gradleplugin.compose)
     implementation(libs.gradleplugin.composeCompiler)
     implementation(libs.gradleplugin.kotlin)
     // Workaround for version catalog working inside precompiled scripts
     // Issue - https://github.com/gradle/gradle/issues/15383
     implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
 }
 
 
 private val projectJavaVersion: JavaVersion = JavaVersion.toVersion(libs.versions.java.get())
  
 java {
     sourceCompatibility = projectJavaVersion
     targetCompatibility = projectJavaVersion
 }
 tasks.withType<KotlinCompile>().configureEach {
     compilerOptions.jvmTarget.set(JvmTarget.fromTarget(projectJavaVersion.toString()))
 }

В коде мы сделали следующее:

  • В блоке plugins подключили плагин kotlin-dsl, чтобы использовать Gradle Kotlin Dsl для написания наших Convention Plugins;

  • В блоке dependencies добавили плагины, которые будем использовать в наших будущих Convention Plugins. Сразу отмечу, что добавлять плагины через блок plugins не получится: такова специфика модуля с Convention Plugin;

  • В блоке dependencies добавили Workaround, чтобы в наших плагинах были доступны Version Catalogs. Пока нет возможности по-другому использовать Version Catalogs, есть issue.

  • Задали версию Java и kotlin-компилятора.

Создаем первый пустой Convention Plugin и подключаем его к корневому build.gradle.kts нашего проекта. Это нужно для того, чтобы работали extension-функции в build.gradle.kts-файлах модулей корневого проекта, в который мы подключили наш модуль с Convention Plugins.

Создание пустого base.plugin Convention Plugins
Создание пустого base.plugin Convention Plugins
Подключение плагина к проекту
Подключение плагина к проекту

Предварительную настройку закончили — переходим к созданию базовых Сonvention pugins в .gradle.kts-файлах.

О том, как создавать плагины и переиспользуемые части в .gradle.kts-файлах и Kotlin extension-функций для упрощения написания плагинов — в следующей статье. Не переключайтесь! 

Комментарии (2)


  1. Tepex
    18.09.2024 18:15

    Иду тем же путем, только у меня все эти настройки и инжекции выполняются в Gradle-init плагине, который применяется для проектов в рамках некоторой производственной (корпоративной) среды. Этот плагин является артефактом и забирается из Maven-репозитория добавлением init-скрипта в gradle/wrapper или на локальной машине в ~/.gradle/init.d. Сам плагин — универсальный, в нем нет хардкодинга корпоративных конфигураций. Производственная конфигурация скачивается (кешируются локально) из некоторого места внутри производственной среды (LDAP, Git-репозиторий, HTTP), которое является SSoT. Адрес и способ получения прописываются в Gradle init-скрипте. Плюс плагин занимается необходимой кодогенерацией, т.к. проекты имеют единую структуру по чистой архитектуре. И большая часть — это библиотеки, фичи, с разделением на артефакты api/impl и соответствующим подключением зависимостей compileOnly/runtimeOnly.

    Производственная конфигурация в простом варианте — json-файл (или любой другой формат), где описывается производственное окружение: адреса репозиториев, мета-информация, список проектов, version catalog, общая конфигурация Android, Kotlin и т.п. вещи, общие для проектов внутри среды.

    Цель — разделить кодовую базу и конфигурации. Вынести конфигурации под отдельное управление специальной роли (тим-лид, devOps, инженер по сборке). Избавить линейного разработчика от необходимости самому все это настраивать из проекта в проект, занимаясь copy-paste. К тому же это небезопастно давать возможность разработчику управлять конфигурацией. Мультимодульный подход в разработке влечет появление большого количества микро-проектов (фичей, артефактов) и управлять этим хозяйством, особенно зависимостями между ними, без автоматизации становится адом.


  1. UltimateOrb
    18.09.2024 18:15

    А почему нельзя в каждом модуле прописать apply from с описанием базовой конфигурации?