Идём к горам
Я 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), поскольку это используется в современных проектах, а не как у меня. На маленьких проектах использование плагинов заметно, но немного, снижает скорость сборки из-за общей её маленькой продолжительности, однако, полагаю, на больших проектах это снижение незначительно, а потому лучше сразу уметь их использовать.