Введение
Представьте, что вы руководите сложным проектом на базе ОС 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-проекте. Начните революционизировать процесс разработки и попрощайтесь с конфликтами версий и проблемами совместимости.
Читайте также:
- Как создать приложение Android за 7 шагов
- Как с легкостью создать установщик пакетов Android
- 10 практических примеров использования функций высшего порядка при разработке Android
Читайте нас в Telegram, VK и Дзен
Перевод статьи Vahid Garousi: Mastering Dependency Management: Version Catalog & Convention Plugin at Scale 🚀