Как стать автором
Обновить

Убираем дублирование конфигурации Gradle и при чём здесь косы и горы

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров2.5K

Идём к горам

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

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии8

Публикации

Ближайшие события