Создание плагинов и переиспользуемых частей в .gradle.kts-файлах и Kotlin extension-функциях

от автора

Всем привет! На связи Дима Котиков, и мы продолжаем разговор о том, как облегчить себе жизнь и уменьшить Boilerplate в gradle-файлах. В первой части поговорили о том, как подготовиться к созданию модулей для Gradle Convention Plugin. Двигаемся дальше!

Создание базовых Convention Plugins и extension-функций

Начнем с создания базовой конфигурации для android-таргета, но перед этим добавим minSdk, targetSdk и compileSdk в `libs.versions.toml` для того, чтобы была возможность изменять эти значения в одном месте сразу для всех модулей.

Добавление minSdk, targetSdk и compileSdk в `libs.versions.toml`

Добавление minSdk, targetSdk и compileSdk в `libs.versions.toml`

Сравним конфигурации для `composeApp` и `shared-uikit` модулей:

Сравнение android-конфигураций для app- и library-модулей

Сравнение android-конфигураций для app- и library-модулей

Какие общие части можно выделить:

Общие части android-конфигураций

Общие части android-конфигураций

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

Разные типы Actions в Extension Android для app- и library-модулей

Разные типы Actions в Extension Android для app- и library-модулей

Что же теперь делать

Нужно копнуть глубже и найти, что BaseAppModuleExtension и LibraryExtension наследуются от одного интерфейса CommonExtension<BuildFeaturesT : BuildFeatures, BuildTypeT : BuildType, DefaultConfigT : DefaultConfig, ProductFlavorT : ProductFlavor, AndroidResourcesT : AndroidResources>. Его и будем использовать для обобщения android-конфигурации. 

Но перед написанием Convention Plugin сделаем пару удобных Extensions. Создаем файл BaseExtensions.kt и добавляем следующее:

package io.github.dmitriy1892.conventionplugins.base.extensions   import com.android.build.api.dsl.AndroidResources import com.android.build.api.dsl.BuildFeatures import com.android.build.api.dsl.BuildType import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.DefaultConfig import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.ProductFlavor import com.android.build.gradle.internal.dsl.BaseAppModuleExtension import org.gradle.api.Project import org.gradle.kotlin.dsl.findByType import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile   private typealias AndroidExtensions = CommonExtension<         out BuildFeatures,         out BuildType,         out DefaultConfig,         out ProductFlavor,         out AndroidResources>   private val Project.androidExtension: AndroidExtensions     get() = extensions.findByType(BaseAppModuleExtension::class)         ?: extensions.findByType(LibraryExtension::class)         ?: error(             "\"Project.androidExtension\" value may be called only from android application" +                     " or android library gradle script"         )   fun Project.androidConfig(block: AndroidExtensions.() -> Unit): Unit = block(androidExtension)   fun Project.kotlinJvmCompilerOptions(block: KotlinJvmCompilerOptions.() -> Unit) {     tasks.withType<KotlinJvmCompile>().configureEach {     compilerOptions(block)     } }

Мы объявили typealias `AndroidExtensions` для интерфейса CommonExtension, чтобы не писать все Generic из раза в раз.

В extension-поле `Project.androidExtension` обращаемся к `extensions` нашего gradle-проекта и пытаемся найти `BaseAppModuleExtension` или `LibraryExtension`, которые являются наследниками интерфейса `CommonExtension`.

В функции `Project.androidConfig` предоставляем лямбду `block` с контекстом на `AndroidExtensions`. Теперь при использовании этой функции мы сможем задавать android-specific-конфигурации.

В функции `Project.kotlinJvmCompilerOptions` мы ищем таску `KotlinJvmCompile` для того, чтобы предоставить возможность сконфигурировать в лямбде `block` параметры kotlin-компилятора под JVM-таргет.

Далее создаем файл `android.base.config.gradle.kts`, пытаемся сконфигурировать и натыкаемся на то, что version catalog недоступен в нашем Convention Plugin.

Version Catalog недоступен

Version Catalog недоступен

В предыдущем разделе в файле `build.gradle.kts` мы указывали Workaround для того, чтобы работали Version Catalogs —  но этого нам недостаточно. Чтобы Version Catalogs у нас заработали, напишем еще один Extension. Идем в файл BaseExtensions.kt и добавляем такой код:

package io.github.dmitriy1892.conventionplugins.base.extensions    import org.gradle.accessors.dm.LibrariesForLibs  import org.gradle.api.Project  import org.gradle.kotlin.dsl.the   ...   val Project.libs: LibrariesForLibs      get() = the<LibrariesForLibs>()

Для удобства в этом же файле добавим Extension на получение версии Java, он понадобится в нескольких местах. Итого получаем:

 package io.github.dmitriy1892.conventionplugins.base.extensions     import org.gradle.accessors.dm.LibrariesForLibs  import org.gradle.api.JavaVersion  import org.gradle.api.Project  import org.gradle.kotlin.dsl.the     val Project.libs: LibrariesForLibs      get() = the<LibrariesForLibs>()     val Project.projectJavaVersion: JavaVersion      get() = JavaVersion.toVersion(libs.versions.java.get().toInt()) 

Возвращаемся к `android.base.config.gradle.kts` и конфигурируем, не забывая про импорты наших extension-функций:

import io.github.dmitriy1892.conventionplugins.base.extensions.androidConfig import io.github.dmitriy1892.conventionplugins.base.extensions.kotlinJvmCompilerOptions import io.github.dmitriy1892.conventionplugins.base.extensions.libs import io.github.dmitriy1892.conventionplugins.base.extensions.projectJavaVersion import org.jetbrains.kotlin.gradle.dsl.JvmTarget   androidConfig {     compileSdk = libs.versions.compileSdk.get().toInt()       defaultConfig {         minSdk = libs.versions.minSdk.get().toInt()     }       sourceSets["main"].apply {         manifest.srcFile("src/androidMain/AndroidManifest.xml")         res.srcDirs("src/androidMain/res")     }       compileOptions {         sourceCompatibility = JavaVersion.VERSION_1_8         targetCompatibility = JavaVersion.VERSION_1_8     } }   kotlinJvmCompilerOptions {     jvmTarget.set(JvmTarget.fromTarget(projectJavaVersion.toString()))     freeCompilerArgs.add("-Xjdk-release=${projectJavaVersion}") } 

Откуда взялся блок kotlinJvmCompilerOptions и зачем он нам? Если мы посмотрим еще раз на файлы build.gradle.kts  в модулях `composeApp` и `shared-uikit`, в блоке `kotlin` увидим следующее:

Общие части в `build.gradle.kts`-файлах модулей `composeApp` и `shared-uikit`

Общие части в `build.gradle.kts`-файлах модулей `composeApp` и `shared-uikit`

Как видим на картинке, в выделенных красным блоках конфигурируются настройки компилятора для android-таргета. По этой причине мы и вынесли их в файл  `android.base.config.gradle.kts`, предварительно настроив extension-функцию в BaseExtensions.kt .

Применяем в `build.gradle.kts`-файлах модулей наш Convention Plugin и удаляем блоки кода, которые уже есть в `android.base.config.gradle.kts` Скриншоты приложил только для модуля `shared-uikit`, но такие же правки проведены и в `composeApp`.

Добавление Convention Plugin в `shared-uikit/build.gradle.kts` и удаление кода настроек компилятора

Добавление Convention Plugin в `shared-uikit/build.gradle.kts` и удаление кода настроек компилятора
Удаление кода из `shared-uikit/build.gradle.kts`. Добавили его выше в `android.base.config.gradle.kts`

Удаление кода из `shared-uikit/build.gradle.kts`. Добавили его выше в `android.base.config.gradle.kts`

Пытаемся синхронизироваться, и-и-и… Видим ошибку:

Ошибка синхронизации проекта после добавления Convention Plugin

Ошибка синхронизации проекта после добавления Convention Plugin

Ошибка появляется потому, что в плагине `android.base.config.gradle.kts` мы добавили блок конфигурации базового android-проекта, но не добавляли плагин `com.android.application` или `com.android.library`. Gradle применяет наши плагины поочередно сверху вниз? и так как до Convention Plugin никакие другие плагины не применены, появилась ошибка.

Достаточно указать Convention Plugin ниже android-плагина, чтобы исправить этот позорный недуг.

Исправление ошибки сборки

Исправление ошибки сборки

Синхронизируемся, собираем проект — все заработало!

Успешная сборка

Успешная сборка

Дальше — больше, продолжаем выносить общую логику. Сконфигурируем тесты для android-таргета, в папке с плагинами создаем файл `android.base.test.config.gradle.kts`, но перед его наполнением добавим еще Extensions для удобства.

Для создания extension-функции для блока androidTarget нам нужно посмотреть, как до нее можно добраться.

Блок androidTarget внутри блока kotlin

Блок androidTarget внутри блока kotlin

Проваливаемся в функцию androidTarget:

Функция androidTarget

Функция androidTarget

Видим, что функция androidTarget — часть интерфейса `KotlinTargetContainerWithPresetFunctions` и что интерфейс реализуется классом `KotlinMultiplatformExtension`.

`KotlinTargetContainerWithPresetFunctions` реализуется классом `KotlinMultiplatformExtension`

`KotlinTargetContainerWithPresetFunctions` реализуется классом `KotlinMultiplatformExtension`

KotlinMultiplatformExtension мы можем добыть уже знакомым нам способом через поиск в `Project.extensions`. Возвращаемся в файл BaseExtensions.kt и пишем:

fun Project.kotlinAndroidTarget(block: KotlinAndroidTarget.() -> Unit) {     extensions.findByType(KotlinMultiplatformExtension::class)     ?.androidTarget(block)     ?: error("Kotlin multiplatform was not been added") }

Далее идем в файл android.base.test.config.gradle.kts и конфигурируем тесты с помощью написанного нами Extension `Project.kotlinAndroidTarget`. В процессе видим, что при настройке instrumentedTestVariant в блоке Dependencies недоступны функции implementation/debugImplementation.

Нет доступа к implementation/debugImplementation

Нет доступа к implementation/debugImplementation

В этом случае мы пишем очередные Extensions! Для этого создадим отдельный файл DependenciesExtensions.kt, т. к. он пригодится нам дальше, и пишем следующие функции:

package io.github.dmitriy1892.conventionplugins.base.extensions   import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.provider.Provider import org.gradle.kotlin.dsl.DependencyHandlerScope   fun DependencyHandlerScope.implementation( dependency: Provider ) {     add("implementation", dependency) }   fun DependencyHandlerScope.debugImplementation( dependency: Provider ) {     add("debugImplementation", dependency) }

Применяем это в файле android.base.test.config.gradle.kts, также заполняем другие данные для плагина теста. Получаем такой вид:

import com.android.build.api.dsl.ManagedVirtualDevice import io.github.dmitriy1892.conventionplugins.base.extensions.androidConfig import io.github.dmitriy1892.conventionplugins.base.extensions.debugImplementation import io.github.dmitriy1892.conventionplugins.base.extensions.implementation import io.github.dmitriy1892.conventionplugins.base.extensions.kotlinAndroidTarget import io.github.dmitriy1892.conventionplugins.base.extensions.libs import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree   kotlinAndroidTarget {     instrumentedTestVariant {     sourceSetTree.set(KotlinSourceSetTree.test)       dependencies {         debugImplementation(libs.androidx.testManifest)         implementation(libs.androidx.junit4)     }     } }   androidConfig {     defaultConfig {     testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"     }       //https://developer.android.com/studio/test/gradle-managed-devices     @Suppress("UnstableApiUsage")     testOptions {     managedDevices.devices {         maybeCreate<ManagedVirtualDevice>("pixel5").apply {             device = "Pixel 5"             apiLevel = libs.versions.targetSdk.get().toInt()             systemImageSource = "aosp"         }     }     } }

Применяем наш новосозданный плагин в `build.gradle.kts`-файлах модулей проекта и удаляем обобщенные в плагине блоки.

Добавление плагина тестов и удаление обобщенных скриптов

Добавление плагина тестов и удаление обобщенных скриптов
Удаление обобщенных тестовых скриптов из android-блока

Удаление обобщенных тестовых скриптов из android-блока

Синхронизируемся, проверяем, что наш мультиплатформенный тест работает с помощью команды `./gradlew :composeApp:connectedAndroidTest`, и видим, что все успешно. Почему не напрямую делаем Run Test из UI для android-таргета — потому что это не работает в KMP.

Вынесем в Convention Plugin логику конфигурации мультиплатформенного проекта — подключение плагина и добавление таргетов, под которые собирается проект. Создаем файл kmp.base.config.gradle.kts и наполняем:

plugins {     id("org.jetbrains.kotlin.multiplatform") }   kotlin {     androidTarget()       jvm()       iosX64()     iosArm64()     iosSimulatorArm64() }

Вынесем логику для упаковки iOS Framework в отдельный Extension. Для этого выделим получение `KotlinMultiplatformExtension` в отдельный Extension и заодно отрефакторим функцию kotlinAndroidTarget:

fun Project.kotlinMultiplatformConfig(block: KotlinMultiplatformExtension.() -> Unit) {     extensions.findByType<KotlinMultiplatformExtension>()     ?.apply(block)     ?: error("Kotlin multiplatform was not been added") }   fun Project.kotlinAndroidTarget(block: KotlinAndroidTarget.() -> Unit) {     kotlinMultiplatformConfig {     androidTarget(block)     } }

Далее создадим файл IosExtensions.kt и пропишем:

package io.github.dmitriy1892.conventionplugins.base.extensions   import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.mpp.Framework import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget   fun Project.iosRegularFramework(     block: Framework.() -> Unit ) {     kotlinMultiplatformConfig {     targets         .filterIsInstance<KotlinNativeTarget>()         .forEach { nativeTarget -> nativeTarget.binaries.framework(configure = block) }     } }

Теперь можем применить плагин и Extension в наших build.gradle.kts-файлах:

Применение плагина kmp.base.config

Применение плагина kmp.base.config
Добавление Extension iosRegularFramework

Добавление Extension iosRegularFramework

Что мы можем еще улучшить

Взглянем на блок с зависимостями, объявляемыми для всех таргетов:

Стандартное объявление зависимостей в KMP

Стандартное объявление зависимостей в KMP

Видим Callback Hell из функций `kotlin { sourceSets { <target>.dependencies { implementation(…) } } }` — выглядит не очень. Можем попробовать улучшить положение через объявление в блоке Dependencies на уровне файла.

Попытка объявления зависимостей в верхнеуровневом блоке Dependencies

Попытка объявления зависимостей в верхнеуровневом блоке Dependencies

Далеко не все таргеты доступны в этом блоке, да и каша из объявлений зависимостей между таргетами при таком подходе неизбежна на дистанции. 

Как улучшить положение? Конечно же, написать очередную пачку удобных Extensions.  Создадим новый файл KmpDependenciesExtensions.kt и пропишем:

package io.github.dmitriy1892.conventionplugins.base.extensions   import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler   fun Project.commonMainDependencies(block: KotlinDependencyHandler.() -> Unit) {     kotlinMultiplatformConfig {     sourceSets.commonMain.dependencies(block)     } }   fun Project.commonTestDependencies(block: KotlinDependencyHandler.() -> Unit) {     kotlinMultiplatformConfig {     sourceSets.commonTest.dependencies(block)     } }   fun Project.androidMainDependencies(block: KotlinDependencyHandler.() -> Unit) {     kotlinMultiplatformConfig {     sourceSets.androidMain.dependencies(block)     } }   fun Project.jvmMainDependencies(block: KotlinDependencyHandler.() -> Unit) {     kotlinMultiplatformConfig {     sourceSets.jvmMain.dependencies(block)     } }   fun Project.iosMainDependencies(block: KotlinDependencyHandler.() -> Unit) {     kotlinMultiplatformConfig {     sourceSets.iosMain.dependencies(block)     } }

Применяем Extensions в build.gradle.kts-файлах:

Применение extension-функций для объявления зависимостей на уровне файла

Применение extension-функций для объявления зависимостей на уровне файла

Видим, что зависимости compose покраснели — произошло это потому, что зависимости на compose-библиотеки лежат в недрах Compose Multiplatform Plugin, а не в Version Catalog, и при вынесении зависимостей в наши extension-функции перестал быть виден контекст org.jetbrains.compose.ComposePlugin. Но это не страшно, т. к. мы будем выносить конфигурацию compose в отдельный плагин, чем и займемся.

Сконфигурируем android-таргет. Для этого создадим файл android.compose.config.gradle.kts и наполним:

import io.github.dmitriy1892.conventionplugins.base.extensions.androidConfig   plugins {     id("android.base.config") }     androidConfig {     buildFeatures {     //enables a Compose tooling support in the AndroidStudio     compose = true     } }

Также создаем файл kmp.compose.config.gradle.kts и наполняем:

import io.github.dmitriy1892.conventionplugins.base.extensions.libs   plugins {     id("org.jetbrains.kotlin.plugin.compose")     id("org.jetbrains.compose")       id("kmp.base.config")     id("android.compose.config") }   kotlin {     sourceSets {     commonMain.dependencies {         implementation(compose.runtime)         implementation(compose.foundation)         implementation(compose.material3)         implementation(compose.components.resources)         implementation(compose.components.uiToolingPreview)     }       commonTest.dependencies {         implementation(kotlin("test"))         @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)         implementation(compose.uiTest)     }       androidMain.dependencies {         implementation(compose.uiTooling)         implementation(libs.androidx.activityCompose)     }       jvmMain.dependencies {         implementation(compose.desktop.currentOs)     }     } }

В плагине `android.compose.config.gradle.kts` мы применили `android.base.config`, а в плагине `kmp.compose.config.gradle.kts` — и `android.compose.config.gradle.kts`, и `kmp.base.config`. Соответственно, их можно убрать из `build.gradle.kts`-файлов, если подключить туда один наш плагин `kmp.compose.config.gradle.kts`, что и сделаем.

Применение `kmp.compose.config`-плагина

Применение `kmp.compose.config`-плагина
Применение `kmp.compose.config`-плагина

Применение `kmp.compose.config`-плагина

Синхронизируем проект, проверяем, что все собралось.


Подведем промежуточные итоги. Исходный `build.gradle.kts`-файл в модуле composeApp занимал 143 строчки кода. Теперь же он уменьшился до 74 строк кода —  практически в 2 раза. Вполне себе неплохо. Но это еще не предел. Идем к светлому будущему — следующему разделу: созданию Convention Plugins в kotlin-файлах и их регистрации для дальнейшего переиспользования.


ссылка на оригинал статьи https://habr.com/ru/articles/843662/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *