Идём к горам

Я Android-разработчик уже несколько лет и работая с монолитами (одномодульными проектами) никакой проблемы с дублированием конфигураций Gradle я не испытывал, ибо файл-то один, однако, когда я начал разбивать свои проекты на модули, стало очевидным копирование одних и тех же настроек и библиотек для различных типов модулей. Более того, одни и те же типы модулей имели практически идентичные зависимости и настройки (например, версию SDK, настройки компиляции и др.).

Я понял, что такое копирование выглядит глупо, и решил сначала найти способ как-то упростить добавление зависимостей. В одной из статей нашёл много способов это сделать, однако единственный приглянувшийся мне - Version Catalogs, это позволяло получать зависимости из единого центра и немного упрощало подключение связанных зависимостей. Но это не решало проблему очевидного дублирования конфигураций, а потому Version Catalogs было не достаточно. Попытки найти способ решения этой проблемы в Google не увенчались успехом. Опрошенные коллеги тоже не предложили способа удобно переиспользовать зависимости с конфигурациями, хотя идеи с конфигурацией модулей были, но мне они показались недостаточными, поэтому я решил придумать что-нибудь самостоятельно. Мне хотелось найти что-то легко реализуемое безо всяких там плагинов, buildSrc и отдельных модулей с зависимостями и настройками.

Так появилась идея КоСоГоРа, которая была опробована на нескольких моих проектах.

Косим горы КоСоГоРом

КоСоГоР - компонентная система горизонтального расширения (да, я заменил "и" на "о" и что ты мне сделаешь? я в другом городе, чтобы звучало лучше). Суть подхода в разбиении зависимостей и конфигураций на компоненты, которые можно подключать независимо.

Компонент - это набор самодостаточных манипуляций, необходимых для конфигурации модуля. Под самодостаточностью я имею ввиду то, что каждый компонент в себе уже имеет все нужные компоненты и манипуляции и не нуждается в их применении где-либо вне этого компонента, но ему допустимо зависеть от определённой конфигурации модуля. Каждый тип компонентов хранится в отдельном файле .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;

  • Больше горизонтальная расширяемость нежели вертикальная;

  • Гибкая настройка компонентов с помощью аргументов.

Недостатки

  • При обновлении 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, ибо по сути это тот же самый конфигурационный код, что и обычно, только немного по-другому вызывается.

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

P.S. На данный момент не имею проектов, использующих Kotlin Gradle, так что не знаю, какие могут быть проблемы с реализацией на нём, однако в будущем планирую перевести один из проектов на Kotlin Gradle и дополню.

Комментарии (4)


  1. Rusrst
    18.07.2023 12:28

    А чем подход Гугла из nio не подошёл? Наделать kt файлов в buil-logic модуле и подключать их как плагины. Вроде норм решение же.

    Но надо переезжать с groove, что в общем-то проблема только в первый раз.


  1. alaershov
    18.07.2023 12:28
    +1

    Мне хотелось найти что-то легко реализуемое безо всяких там плагинов, buildSrc и отдельных модулей с зависимостями и настройками.

    Вы ведь сами изобрели свои плагины, почему не использовать решение для плагинов, которое есть из коробки?

    И работает ли ваша система с Configuration Cache?


  1. Ztrel
    18.07.2023 12:28
    +1

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

    Для примера использования можно посмотреть на проект Google Now in Android или же на проект команды Avito - avito-android.

    Лучше потратить пару вечеров и разобраться, чем создавать своё решение с нуля.


    1. Rusrst
      18.07.2023 12:28

      Во-во, я о том же написал. Сам логику сборки из groove в kt plugin перенес по подобию nio, в целом удобно получилось - теперь его можно публиковать на локальный maven repo и просто подключать в любом проекте.