Идём к горам
Я Android-разработчик уже несколько лет и работая с монолитами (одномодульными проектами) никакой проблемы с дублированием конфигураций Gradle я не испытывал, ибо файл-то один, однако, когда я начал разбивать свои проекты на модули, стало очевидным копирование одних и тех же настроек и библиотек для различных типов модулей. Более того, одни и те же типы модулей имели практически идентичные зависимости и настройки (например, версию SDK, настройки компиляции и др.).
Я понял, что такое копирование выглядит глупо, и решил сначала найти способ как-то упростить добавление зависимостей. В одной из статей нашёл много способов это сделать, однако единственный приглянувшийся мне - Version Catalogs, это позволяло получать зависимости из единого центра и немного упрощало подключение связанных зависимостей. Но это не решало проблему очевидного дублирования конфигураций, а потому Version Catalogs было не достаточно. Попытки найти способ решения этой проблемы в Google не увенчались успехом. Опрошенные коллеги тоже не предложили способа удобно переиспользовать зависимости с конфигурациями, хотя идеи с конфигурацией модулей были, но мне они показались недостаточными, поэтому я решил придумать что-нибудь самостоятельно. Мне хотелось найти что-то легко реализуемое безо всяких там плагинов, buildSrc и отдельных модулей с зависимостями и настройками, хотя описанную идею можно реализовать несколькими способами, в том числе и через Convention Plugins, но здесь я опишу самую простую, на мой взгляд, реализацию.
Так появилась идея КоСоГоРа, которая была опробована на нескольких моих проектах.
Косим горы КоСоГоРом
КоСоГоР - компонентная система горизонтального расширения (да, я заменил "и" на "о" и что ты мне сделаешь? я в другом городе, чтобы звучало лучше). Суть подхода в разбиении зависимостей и конфигураций на компоненты, которые можно подключать независимо.
Компонент - это набор самодостаточных манипуляций, необходимых для конфигурации модуля. Под самодостаточностью я имею ввиду то, что каждый компонент в себе уже имеет все нужные компоненты и манипуляции и не нуждается в их применении где-либо вне этого компонента, но ему допустимо зависеть от определённой конфигурации модуля. Каждый тип компонентов хранится в отдельном файле .gradle, а сам компонент является полем ext, чтобы иметь простой доступ к этим компонентам в любом модуле, и представляет собой замык��ние (Closure) с первым аргументом типа Project, к которому и будут применяться необходимые манипуляции. Эти файлы подключаются в основной build.gradle проекта через apply from: 'fileName.gradle'.
Обобщённое объявление компонента:
ext.component_smth = { Project project -> // манипуляции } // Применение component_smth(project)
Компоненты с аргументами создаются и используются несколько сложнее, ибо нужно будет вернуть Closure<Project>, вызвав первый раз с аргументами, после чего вызвать так же как и без аргументов:
ext.component_withArg = { ArgType arg -> return { Project project -> // манипуляции } } // Применение component_withArg(argValue)(project)
Типы компонентов Android
Конфигурация модуля
Особый компонент, содержащий настройку для типов Gradle модулей: подключает нужные плагины и настраивает сам модуль. Отличается от других компонентов тем, что принимает компоненты и применяет их, а потому это единственный тип компонентов, который вызывается в .gradle-файлах модулей, не считая принимаемых им компонентов с аргументами. Также он должен быть единственным компонентом такого типа в .gradle-файле модуля.
Пример имени файла с конфигурациями: configs.gradle.
Шаблон именования для конфигураций: config_<whatIsIt> (например, config_android).
В Android типы модулей обусловлены подключаемыми плагинами, которые обычно содержат слово libarary, поэтому шаблон именования для конфигураций модулей Android: library_<whatIsIt> (например, library_android).
Поскольку это особый компонент, его объявление отличается от других типов компонентов:
ext.config_some = { Project project, Closure<Project>... components -> // настройка для этого типа for (final def component in components) { component(project) } } // или с аргументами ext.config_someWithArgs = { Project project, ArgType arg, Closure<Project>... components -> // настройка для этого типа с аргументами for (final def component in components) { component(project) } }
В месте использования удобнее отделять типы компонентов по строчкам друг от друга и сортируя их по размеру от большего к меньшему: bundle, component, buildFeature - тогда легче понять, о чём этот модуль. Обобщённое использование выглядит так:
config_some( project, component1, component2, componentOfAnotherType1, componentOfAnotherType2, ) // или с аргументами config_someWithArgs( project, argumentValue, component1, component2, componentOfAnotherType1, componentOfAnotherType2, ) // или компоненты с аргументами config_some( project, component1, component2(argumentValue), componentOfAnotherType1(argumentValue2), componentOfAnotherType2, ) // остальная конфигурация модуля
Чаще всего в Android необходимы конфигурации Java и Android библиотек. Дополнительно для Android я отдельно выделяю конфигурацию Android параметров (SDK версии, опции компилятора, тип сборки и др.), чтобы её можно было использовать и в :app и в Android-модулях, поскольку они имеют одинаковую структуру конфигурации, а в простых случаях ещё и параметры.
Пример конфигурации Kotlin(Java)-модуля
def javaVersion = JavaVersion.VERSION_17 ext.library_kotlin = { Project project, Closure<Project>... components -> project.apply plugin: 'java-library' project.apply plugin: 'org.jetbrains.kotlin.jvm' project.java { sourceCompatibility = javaVersion targetCompatibility = javaVersion } for (final def component in components) { component(project) } }
Пример конфигурации Android и Android-модуля
def javaVersion = JavaVersion.VERSION_17 ext.library_android = { Project project, Closure<Project>... components -> project.apply plugin: 'com.android.library' project.apply plugin: 'org.jetbrains.kotlin.android' config_android(project, components) project.android { defaultConfig { consumerProguardFiles 'consumer-rules.pro' } } } ext.config_android = { Project project, Closure<Project>... components -> project.android { compileSdk sdk defaultConfig { minSdk project.minSdk targetSdk project.sdk resourceConfigurations = ['en'] } buildTypes { debug { minifyEnabled false } release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility javaVersion targetCompatibility javaVersion } kotlinOptions { jvmTarget = javaVersion.toString() } lint { htmlReport false } packagingOptions { resources { excludes += ['META-INF/ASL2.0', 'META-INF/LICENSE*', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/MANIFEST.MF'] } } } for (final def component in components) { component(project) } }
Поскольку конфигурация Android для большинства Android-модулей одинаковая, то можно использовать основную (config_android), однако можно добавить необходимые параметры в объявление, если необходима более точная настройка. Например targetSdk:
ext.library_android = { Project project, int customTargetSdk, Closure<Project>... modifiers -> // ... config_android(project, customTargetSdk, modifiers) // ... } ext.config_android = { Project project, int customTargetSdk, Closure<Project>... modifiers -> project.android { compileSdk customTargetSdk defaultConfig { minSdk project.minSdk targetSdk customTargetSdk // ... } // ... } // ... }
Компонент
Элементарная единица КоСоГоРа. Содержит конфигурацию (например, включение определённых buildFeature) и связанные библиотеки для определён��ой цели. Хоть он и самодостаточный, однако допустимо использование компонентов с особыми настройками (например, buildFeature_resConfigs), но при использовании других компонентов он уже становится bundle.
Пример имени файла с компонентами: components.gradle.
Шаблон именования: component_<whatIsIt> (например, component_retrofit).
В Android их можно разделить на Android-компоненты и просто компоненты. Android-компоненты содержат настройки и библиотеки для Android-модулей и не должны применятся в Java-модулях. Для этого я выделяю их с помощью добавления _android к названию, таким образом шаблон именования: component_android_<whatIsIt> (например, component_android_room).
Пример просто компонента
ext.component_retrofit = { Project project -> project.dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.moshi:moshi-kotlin:$moshiVersion" } }
Пример Android-компонента
ext.component_android_room = { Project project -> project.apply plugin: 'com.google.devtools.ksp' project.dependencies { implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-runtime:$roomVersion" ksp "androidx.room:room-compiler:$roomVersion" } }
Bundle
Содержит минимальную конфигурацию для определённого типа модулей (например, для feature). Может использовать все другие типы компонентов кроме конфигураций модуля. Позволяют избежать повторения использования в одинаковом составе компонентов и каких-либо дополнительных манипуляций. Также в каком-то смысле являются маркерами того, о чём этот модуль, поэтому и рекомендую располагать их вверху списка компонентов.
Пример имени файла с bundle: bundles.gradle.
Шаблон именования: bundle_<whatIsIt> (например, bundle_uiScreen). Обычно я не разделяю на Android и нет, как это было с компонентами, поскольку их у меня немного, но думаю это будет единообразнее.
Пример bundle
Bundle для модулей с экранами:
ext.bundle_uiScreen = { Project project -> component_android_viewBinding(project) component_android_hilt(project) buildFeature_resValues(project) project.dependencies { implementation "androidx.core:core-ktx:$coreVersion" implementation "com.google.android.material:material:$materialVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" implementation "androidx.fragment:fragment-ktx:$fragmentVersion" } }
Свои типы компонентов
Иногда в проекте могу появиться особые типы компонентов, которые хочется выделить отдельно, поэтому их можно вынести отдельно в свой файл и именовать по своему. Для единообразия рекомендую придерживаться шаблона <componentName>_<whatIsIt>, а файл именовать <componentNames>.gradle.
Например, я отключаю buildfeatures в настройках gradle и мне нужно их включать в определённых модулях. Такие компоненты я называю buildFeature_<what> (например, buildFeature_resValues) и храню в buildFeatures.gradle.
Характеристика
Лучше всего подходит для проектов, в которых уже множество модулей и/или будет больше, а также конфигурация этих модулей довольно похожа.
Преимущества
Главное преимущество в упрощении конфигурации и удалении её повторяющихся кусков;
Можно изменить конфигурацию сразу для всех модулей одновременно;
Меньше времени уходит на настройку нового модуля;
Достаточно базовых знаний Groovy и Gradle;
Больше горизонтальная расширяемость нежели вертикальная;
Работает с Configuration Cache;
Гибкая настройка компонентов с помощью аргументов.
Недостатки
При обновлении Gradle миграция не будет применена, поэтому необходимо применять её вручную;
IntelliJ IDEA плохо поддерживает Groovy, поэтому искать исходный код компонентов приходится вручную.
По моему мнению, достаточно просто сделать конфигурацию нечитаемой и непонятной, поэтому необходима осторожность, но может мне просто так показалось. Специально для уменьшения вероятности этого я накладываю такие ограничения на использование и содержимое компонентов.
Примеры
Kotlin(Java)-модуль
До
plugins { id 'java-library' id 'org.jetbrains.kotlin.jvm' } java { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 }
После
library_kotlin(project)
Компоненты
def javaVersion = JavaVersion.VERSION_17 ext.library_kotlin = { Project project -> project.apply plugin: 'java-library' project.apply plugin: 'org.jetbrains.kotlin.jvm' project.java { sourceCompatibility = javaVersion targetCompatibility = javaVersion } }
Android-модуль экрана
До
plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' id 'com.google.dagger.hilt.android' } android { namespace 'com.dropdrage.simpleweather.settings.presentation' compileSdk sdk defaultConfig { minSdk project.minSdk targetSdk project.sdk resourceConfigurations = ['en'] consumerProguardFiles 'consumer-rules.pro' } buildTypes { debug { minifyEnabled false } release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { resValues true viewBinding true } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = '17' } lint { htmlReport false } packagingOptions { resources { excludes += ['META-INF/ASL2.0', 'META-INF/LICENSE*', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/MANIFEST.MF'] } } testOptions.unitTests.all { useJUnitPlatform() } } dependencies { implementation "androidx.core:core-ktx:$coreVersion" implementation "com.google.android.material:material:$materialVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" implementation "androidx.fragment:fragment-ktx:$fragmentVersion" implementation "com.google.dagger:hilt-android:$hiltVersion" kapt "com.google.dagger:hilt-compiler:$hiltVersion" implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:$viewBindingVersion" implementation project(':adapters') implementation project(':core:style') implementation project(':common:presentation') implementation project(':data:settings') testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5Version" testImplementation "org.junit.jupiter:junit-jupiter-params:$junit5Version" testImplementation "org.slf4j:slf4j-simple:$slf4jVersion" testImplementation "com.google.truth:truth:$truthVersion" testImplementation "io.mockk:mockk:$mockkVersion" testImplementation "io.mockk:mockk-android:$mockkVersion" testImplementation "io.mockk:mockk-agent:$mockkVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "app.cash.turbine:turbine:$turbineVersion" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5Version" testImplementation testFixtures(project(':common:test')) }
После
library_android( project, bundle_uiScreen, component_tests, ) android { namespace 'com.dropdrage.simpleweather.settings.presentation' } dependencies { implementation project(':adapters') implementation project(':core:style') implementation project(':common:presentation') implementation project(':data:settings') testImplementation "org.junit.jupiter:junit-jupiter-params:$junit5Version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "app.cash.turbine:turbine:$turbineVersion" testImplementation testFixtures(project(':common:test')) }
Компоненты
// Build Features ext.buildFeature_resValues = { Project project -> project.android { buildFeatures { resValues true } } } ext.buildFeature_viewBinding = { Project project -> project.android { buildFeatures { viewBinding true } } } // Components ext.component_tests = { Project project -> project.android { testOptions.unitTests.all { useJUnitPlatform() } } project.dependencies { testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5Version" testImplementation "org.slf4j:slf4j-simple:$slf4jVersion" testImplementation "com.google.truth:truth:$truthVersion" testImplementation "io.mockk:mockk:$mockkVersion" testImplementation "io.mockk:mockk-android:$mockkVersion" testImplementation "io.mockk:mockk-agent:$mockkVersion" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5Version" } } ext.component_android_viewBinding = { Project project -> buildFeature_viewBinding(project) project.dependencies { implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:$viewBindingVersion" } } ext.component_android_hilt = { Project project -> project.apply plugin: 'kotlin-kapt' project.apply plugin: 'com.google.dagger.hilt.android' project.dependencies { implementation "com.google.dagger:hilt-android:$hiltVersion" kapt "com.google.dagger:hilt-compiler:$hiltVersion" } } // Bundles ext.bundle_uiScreen = { Project project -> component_android_viewBinding(project) component_android_hilt(project) buildFeature_resValues(project) project.dependencies { implementation "androidx.core:core-ktx:$coreVersion" implementation "com.google.android.material:material:$materialVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" implementation "androidx.fragment:fragment-ktx:$fragmentVersion" } } // Configs def javaVersion = JavaVersion.VERSION_17 ext.library_android = { Project project, Closure<Project>... components -> project.apply plugin: 'com.android.library' project.apply plugin: 'org.jetbrains.kotlin.android' config_android(project, components) project.android { defaultConfig { consumerProguardFiles 'consumer-rules.pro' } } } ext.config_android = { Project project, Closure<Project>... components -> project.android { compileSdk sdk defaultConfig { minSdk project.minSdk targetSdk project.sdk resourceConfigurations = [‘en’] } buildTypes { debug { minifyEnabled false } release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility javaVersion targetCompatibility javaVersion } kotlinOptions { jvmTarget = javaVersion.toString() } lint { htmlReport false } packagingOptions { resources { excludes += ['META-INF/ASL2.0', 'META-INF/LICENSE*', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/MANIFEST.MF'] } } } for (final def component in components) { component(project) } }
Заключение
КоСоГоР решает проблему дублирования конфигурации и существенно сокращает сами файлы конфигурации, а также достаточно гибок, чтобы его можно было использовать в различных проектах, однако некоторую долю осторожности всё же стоит соблюдать, как и со многими упрощающими технологиями. В дополнение, он не требует умения написания плагинов и прочих навыков Groovy и Gradle, ибо по сути это тот же самый конфигурационный код, что и обычно, только немного по-другому вызывается.
Сама идея КоСоГоРа не ограничивается только реализацией через ext, правила разбиения можно применить и на остальные реализации (в частности Convention Plugins (см. "Что почитать?")). Мне показались они достаточно удобными, может и вам понравится.
Применив этот подход добавление новых модулей и некоторых библиотек упростилось значительно - больше не нужно помнить как блок называется, какие флаги нужно включить и какие библиотеки нужно подключить. В качестве примера реализации можете посмотреть мой репозиторий.
P.S. На данный момент не имею проектов, использующих Kotlin Gradle, так что не знаю, какие могут быть проблемы с реализацией на нём, однако в будущем планирую перевести один из проектов на Kotlin Gradle и дополню.
P.P.S. На основании комментариев добавил упоминание Convention Plugins. Спасибо комментаторам?
Что почитать?
Настоятельно рекомендую научиться использовать Convention Plugins (как добавить аргументы, пример Google Now In Android, пример Avito), поскольку это используется в современных проектах, а не как у меня. На маленьких проектах использование плагинов заметно, но немного, снижает скорость сборки из-за общей её маленькой продолжительности, однако, полагаю, на больших проектах это снижение незначительно, а потому лучше сразу уметь их использовать.
