
Всем привет! На связи Дима Котиков, и мы продолжаем разговор о том, как облегчить себе жизнь и уменьшить Boilerplate в gradle-файлах. В первой части поговорили о том, как подготовиться к созданию модулей для Gradle Convention Plugin. Двигаемся дальше!
Создание базовых Convention Plugins и extension-функций
Начнем с создания базовой конфигурации для android-таргета, но перед этим добавим minSdk
, targetSdk
и compileSdk
в libs.versions.toml
для того, чтобы была возможность изменять эти значения в одном месте сразу для всех модулей.

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
увидим следующее:

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
.

shared-uikit/build.gradle.kts
и удаление кода настроек компилятора
shared-uikit/build.gradle.kts
. Добавили его выше в android.base.config.gradle.kts
Пытаемся синхронизироваться, и-и-и... Видим ошибку:

Ошибка появляется потому, что в плагине 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
:

Видим, что функция androidTarget
— часть интерфейса 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
В этом случае мы пишем очередные 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
-файлах:

kmp.base.config

iosRegularFramework
Что мы можем еще улучшить
Взглянем на блок с зависимостями, объявляемыми для всех таргетов:

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

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