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

d294ab179d9d24fe32e62ca32ea1d55f.jpg

Всем привет! На связи Дима Котиков, и мы продолжаем разговор о том, как облегчить себе жизнь и уменьшить 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-модулей

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

72d8a7ab8a7f76683b75c3a940d3fbad.png

Нужно копнуть глубже и найти, что BaseAppModuleExtension и LibraryExtension наследуются от одного интерфейса CommonExtension. Его и будем использовать для обобщения 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().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()

Для удобства в этом же файле добавим 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()
  
 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("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()
	    ?.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()
	        .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 { .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-файлах и их регистрации для дальнейшего переиспользования.

© Habrahabr.ru