Управляем зависимостями: возможности каталога версий и convention-плагина

Введение

Представьте, что вы руководите сложным проектом на базе ОС Android с десятками зависимостей, каждая из которых имеет свою версию. Управление этими зависимостями вручную может стать настоящим кошмаром, приводящим к проблемам совместимости, ошибкам и напрасной трате времени. Однако в мире систем сборки Gradle есть свои герои  —  каталог версий Gradle и convention-плагин. Я расскажу об этом мощном дуэте и о том, как он может революционизировать управление зависимостями и оптимизировать процесс разработки.

Проблемы с зависимостями

Несоответствие версий

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

Пример. Представьте себе ситуацию: в проекте Android используются две сторонние библиотеки  —  LibraryA и LibraryB. LibraryA полагается на Retrofit версии 2.5.0, а LibraryB требует Retrofit версии 2.7.1. Без надежной системы управления зависимостями пришлось бы вручную устранять это несоответствие версий, что могло бы привести к проблемам во время выполнения.

Проблемы с совместимостью

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

Пример. Допустим, вы разрабатываете приложение под Android, предназначенное как для смартфонов, так и для планшетов. Вы используете библиотеку DeviceUtils, которая предоставляет функциональность, специфичную для аппаратного обеспечения. Без надлежащей системы управления вы можете случайно включить в смартфоны функции, свойственные планшетам, или наоборот, что приведет к ухудшению пользовательского опыта и проблемам совместимости.

Циклическая зависимость

Циклические зависимости возникают, когда два или более компонентов зависят друг от друга, образуя петлю. Это может приводить к ошибкам компиляции, затрудняя сборку и сопровождение Android-проекта.

Пример. Предположим, что в проекте Android есть два модуля  —  ModuleA и ModuleB. ModuleА зависит от классов из ModuleВ, а ModuleВ  —  от классов из ModuleА. Такая циклическая зависимость создает ситуацию, когда безошибочная компиляция одного из модулей невозможна. Размыкание такой зависимости вручную может оказаться утомительным и чреватым ошибками процессом.

Как каталог версий Gradle и convention-плагин решают эти проблемы?

Каталог версий Gradle

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

Пример. В файле сборки проекта Gradle можно определить каталог версий следующим образом:

dependencies {
versionCatalogs {
myVersionCatalog {
commonLibVersion = "1.0.0"
retrofitVersion = "2.7.1"
// Добавьте сюда другие версии зависимостей
}
}
}

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

Convention-плагин

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

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

Используем каталог версий Gradle

Обычный build.gradle.kts выглядит примерно так:

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}

android {
namespace = "garousi.dev.conventionpluginsample"
compileSdk = 34

defaultConfig {
applicationId = "garousi.dev.conventionpluginsample"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}

dependencies {

implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.7.2")
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

С помощью convention-плагинов мы можем разбить этот файл на определенные convention-плагины, которые далее применим к каждому модулю, просто используя их с пользовательским ID:

id("taravaz.android.library)

Но как совершается эта магия?

Разделим весь процесс на несколько шагов.

На первом этапе необходимо создать файл с именем libs.versions.toml.

[versions]

[libraries]

[plugins]

[bundles]

Этот файл отвечает за хранение всех зависимостей, плагинов и номеров их версий в надлежащем виде. Перенесем зависимости и их версии в этот файл:

[versions]
activity-compose = "1.7.2"
androidx-junit = "1.1.5"
core-ktx = "1.12.0"
espresso-core = "3.5.1"
junit = "4.13.2"
lifecycle-runtime-ktx = "2.6.2"
composeBOM = "2023.08.00"

[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBOM" }
compose-ui-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-ui-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-material3-material3 = { group = "androidx.compose.material3", name = "material3" }

[plugins]

[bundles]

Зависимости выглядят следующим образом:

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui.ui)
implementation(libs.compose.ui.ui.graphics)
implementation(libs.compose.ui.ui.tooling.preview)
implementation(libs.compose.material3.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.ui.test.junit4)
debugImplementation(libs.compose.ui.ui.tooling)
debugImplementation(libs.compose.ui.ui.test.manifest)
}

Мы переносим зависимости в центральную локацию, чтобы их можно было использовать во всех модулях. Далее дело за convention-плагинами. Я буду называть их build-logic (“логикой сборки”). Этот каталог включает три новых важных файла и следующие изменения в settings.gradle.kts корневого проекта.

build.gradle.kts отвечает за регистрацию пользовательских плагинов:

plugins {
`kotlin-dsl`
}

group = "garousi.dev.conventionpluginsample.buildlogic"

gradle.properties отвечает за настройку конкретного рабочего процесса логики сборки:

# Свойства Gradle не передаются во включенные сборки https://github.com/gradle/gradle/issues/2534
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true

settings.gradle.kts отвечает за управление автоматизацией сборки build-logic и настройку каталога версий:

dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

rootProject.name = "build-logic"
include(":convention")

Корень settings.gradle.kts:

До:

pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

rootProject.name = "ConventionPluginSample"
include(":app")

После:

pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

rootProject.name = "ConventionPluginSample"
include(":app")

Следующий шаг: откроем файл build.gradle модуля приложения и разделим его содержимое в этой папке.

Теперь создадим первый плагин с именем AndroidApplicationConventionPlugin.

class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
with(project) {
pluginManager.apply {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
}
}
}

Работа еще не завершена, поэтому мы перенесли сюда плагины build.gradle.kts на уровне приложения и применили их. Мы можем перенести общие блоки кода в Kotlin-файл и использовать их для всех плагинов, например для настройки kotlinOptions, buildFeatures и compileOptions, которые могли бы находиться в одном месте.

При попытке переноса targetSdk мы столкнулись с ошибкой IDE, которая требовала для этого обратиться к ApplicationExtension, однако импортировать его не удалось.

Как разрешить эту проблему? Мы должны включить Kotlin и плагины Gradle в convention-файл build.gradle.kts,

[versions]
// предыдущие версии
kotlin = "1.9.0"
androidGradlePlugin = "8.2.0-beta05"

[libraries]
// предыдущие библиотеки
# Зависимости включенной логики сборки
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }

[plugins]

[bundles]

Затем использовать их в файле convention-каталога build.gradle.kts:

plugins {
`kotlin-dsl`
}

group = "garousi.dev.conventionpluginsample.buildlogic"


dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
}

Если нажать опцию Sync Project With Gradle Files, расположенную в разделе File, то в IDE появится следующее окно:

class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
with(project) {
pluginManager.apply {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
extensions.configure<ApplicationExtension> {
defaultConfig.targetSdk = Integer.parseInt(libs.findVersion("projectTargetSdkVersion").get().toString())
}
}
}
}

Мы получаем libs и проводим соответствующую установку, но не стоит забывать, что нам нужен доступ к libs во многих местах. Лучшее, что можно сделать,  —  это создать расширение поверх Project для получения libs.

Итак, приступим:

Мы получаем targetSdk из каталога версий, но не добавляем его в файл libs.versions.toml.

[versions]
// предыдущие версии
projectApplicationId = "garousi.dev.conventionpluginsample"
projectVersionName = "1.0"
projectMinSdkVersion = "21"
projectTargetSdkVersion = "34"
projectCompileSdkVersion = "34"
projectVersionCode = "1"

Создадим файл KotlinAndroid и выполним в нем некоторые действия:

import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.ExtensionAware
import org.gradle.kotlin.dsl.provideDelegate
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions

internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *>
) {
commonExtension.apply {
compileSdk = Integer.parseInt(libs.findVersion("projectCompileSdkVersion").get().toString())
defaultConfig {
minSdk = Integer.parseInt(libs.findVersion("projectMinSdkVersion").get().toString())
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
// Рассматривайте все предупреждения Kotlin как ошибки (по умолчанию отключено)
// Переопределите, установив значение warningsAsErrors=true в ~/.gradle/gradle.properties
val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean()

// Установите для JVM целевое значение 17
jvmTarget = JavaVersion.VERSION_17.toString()
}


packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
}

fun CommonExtension<*, *, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
(this as ExtensionAware).extensions.configure("kotlinOptions", block)
}

AndroidApplicationConventionPlugin выглядит следующим образом:

class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
with(project) {
pluginManager.apply {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk =
Integer.parseInt(libs.findVersion("projectTargetSdkVersion").get().toString())
}
}
}
}

Зарегистрируем пользовательский плагин для использования системой сборки Gradle:

plugins {
`kotlin-dsl`
}

group = "garousi.dev.conventionpluginsample.buildlogic"


dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
}

gradlePlugin {
plugins {
register("androidApplication") {
id = "conventionpluginsample.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
}
}

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

plugins {
id("conventionpluginsample.android.application")
}

android {
namespace = "garousi.dev.conventionpluginsample"

defaultConfig {
applicationId = "garousi.dev.conventionpluginsample"
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
}

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui.ui)
implementation(libs.compose.ui.ui.graphics)
implementation(libs.compose.ui.ui.tooling.preview)
implementation(libs.compose.material3.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.ui.test.junit4)
debugImplementation(libs.compose.ui.ui.tooling)
debugImplementation(libs.compose.ui.ui.test.manifest)
}

Используйте возможности каталога версий Gradle и convention-плагина для оптимизации управления зависимостями в Android-проекте. Начните революционизировать процесс разработки и попрощайтесь с конфликтами версий и проблемами совместимости.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Vahid Garousi: Mastering Dependency Management: Version Catalog & Convention Plugin at Scale 🚀

Предыдущая статьяСекрет производительности Kafka
Следующая статьяСложные вопросы на собеседовании для тех, кто 7 лет работал с Java. Часть 2