Привет, Хабр! Я Дима Котиков, ведущий 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:
Создать gradle-модуль, не зависящий от каких-либо других в проекте, — в нем мы будем размещать наши будущие Convention Plugins.
Подключить модуль с плагинами в корневой проект так, чтобы он собирался до сборки остальных модулей проекта. Результат сборки — скомпилированные Convention Plugins, будем использовать в модулях основного проекта.
Сконфигурировать
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 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
:
Дублирую зависимости для тех, кто идет по шагам при чтении статьи:
# 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.
Предварительную настройку закончили — переходим к созданию базовых Сonvention pugins в .gradle.kts
-файлах.
О том, как создавать плагины и переиспользуемые части в .gradle.kts
-файлах и Kotlin extension-функций для упрощения написания плагинов — в следующей статье. Не переключайтесь!
Комментарии (2)
UltimateOrb
18.09.2024 18:15А почему нельзя в каждом модуле прописать apply from с описанием базовой конфигурации?
Tepex
Иду тем же путем, только у меня все эти настройки и инжекции выполняются в 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. К тому же это небезопастно давать возможность разработчику управлять конфигурацией. Мультимодульный подход в разработке влечет появление большого количества микро-проектов (фичей, артефактов) и управлять этим хозяйством, особенно зависимостями между ними, без автоматизации становится адом.