Всем привет! На связи Дима Котиков, и мы продолжаем разговор о том, как облегчить себе жизнь и уменьшить 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-файлах и их регистрации для дальнейшего переиспользования.
ссылка на оригинал статьи https://habr.com/ru/articles/843662/
Добавить комментарий