Всем привет! На связи Дима Котиков, и мы продолжаем разговор о том, как облегчить себе жизнь и уменьшить Boilerplate в gradle-файлах. В первой части поговорили о том, как подготовиться к созданию модулей для Gradle Convention Plugin. Двигаемся дальше!
Создание базовых Convention Plugins и extension-функций
Начнем с создания базовой конфигурации для android-таргета, но перед этим добавим minSdk, targetSdk и compileSdk в `libs.versions.toml` для того, чтобы была возможность изменять эти значения в одном месте сразу для всех модулей.
Сравним конфигурации для `composeApp` и `shared-uikit` модулей:
Какие общие части можно выделить:
Видим, что выделенные стрелками и блоками части абсолютно идентичны и мы можем вынести их в общую конфигурацию. Для этого нам сначала нужно взглянуть на функцию `android` и посмотреть контекст, на котором выполняется логика. Проваливаемся в функции `android` наших модулей и видим проблемку: для app- и library-модуля функция `android` конфигурирует немного разные сущности.
Что же теперь делать
Нужно копнуть глубже и найти, что 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.
В предыдущем разделе в файле `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` увидим следующее:
Как видим на картинке, в выделенных красным блоках конфигурируются настройки компилятора для android-таргета. По этой причине мы и вынесли их в файл `android.base.config.gradle.kts`, предварительно настроив extension-функцию в BaseExtensions.kt .
Применяем в `build.gradle.kts`-файлах модулей наш Convention Plugin и удаляем блоки кода, которые уже есть в `android.base.config.gradle.kts` Скриншоты приложил только для модуля `shared-uikit`, но такие же правки проведены и в `composeApp`.
Пытаемся синхронизироваться, и-и-и... Видим ошибку:
Ошибка появляется потому, что в плагине `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:
Видим, что функция androidTarget — часть интерфейса `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.
В этом случае мы пишем очередные 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`-файлах модулей проекта и удаляем обобщенные в плагине блоки.
Синхронизируемся, проверяем, что наш мультиплатформенный тест работает с помощью команды `./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-файлах:
Что мы можем еще улучшить
Взглянем на блок с зависимостями, объявляемыми для всех таргетов:
Видим Callback Hell из функций `kotlin { sourceSets { <target>.dependencies { implementation(...) } } }` — выглядит не очень. Можем попробовать улучшить положение через объявление в блоке 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-файлах:
Видим, что зависимости 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`, что и сделаем.
Синхронизируем проект, проверяем, что все собралось.
Подведем промежуточные итоги. Исходный `build.gradle.kts`-файл в модуле composeApp занимал 143 строчки кода. Теперь же он уменьшился до 74 строк кода — практически в 2 раза. Вполне себе неплохо. Но это еще не предел. Идем к светлому будущему — следующему разделу: созданию Convention Plugins в kotlin-файлах и их регистрации для дальнейшего переиспользования.