Создание Convention Plugin-ов на базе Kotlin-классов

3b1ad4e1d9b98f823387f0acfdef6575.jpg

Всем привет! На связи Дима Котиков и мы продолжаем разговор о том, как облегчить себе жизнь и уменьшить bolierplate в gradle-файлах. В предыдущих статьях мы сделали отдельный модуль для написания Convention Plugins, провели необходимые настройки и написали несколько Convention Plugin-ов в »-.gradle.kts»-файлах. В этой части мы будем создавать Convention Plugin-ы на базе Kotlin-классов.

Создание Convention Plugin-ов в Kotlin-файлах и их регистрация для дальнейшего использования

Чтобы написать convention plugin-ы в Kotlin-файлах, создадим еще один модуль для плагинов и подключим в него модуль base как композитный. Слишком подробно останавливаться на конфигурации build.gradle.kts и settings.gradle.kts для этого модуля я не буду, так как она во многом такая же, как и в модуле base. Расскажу о нескольких важных моментах.

В файле settings.gradle.kts модуля project нужно добавить includeBuild — подключаем как composite build для того, чтобы модуль base собрался раньше, чем наш новый модуль, и мы имели возможность использовать ранее созданные convention plugin-ы и extension-функции:

    ...
    versionCatalogs {
	    create("libs") {
	        from(files("../../gradle/libs.versions.toml"))
	    }
    }
}
 
rootProject.name = "project"
 
includeBuild("../base")

В файле libs.versions.toml нужно добавить ссылку на наш ранее созданный base-модуль для подключения в build.gradle.kts нового модуля. Указываем его без версии:

[libraries]
 
# Plugins for composite build
gradleplugin-base = { module = "io.github.dmitriy1892.conventionplugins:base" }

В файле build.gradle.kts модуля project добавим в блоке dependencies зависимость на base-модуль для того, чтобы в новом модуле с плагинами были видны плагины и extension-функции из модуля base. Помним, что нельзя через блок plugins добавить плагин в проекте, предназначенном для конфигурации сборки и написания других плагинов:

group = "io.github.dmitriy1892.conventionplugins"
 
dependencies {
    implementation(libs.gradleplugin.android)
    implementation(libs.gradleplugin.kotlin)
    implementation(libs.gradleplugin.compose)
    implementation(libs.gradleplugin.composeCompiler)
    // Workaround for version catalog working inside precompiled scripts
    // Issue - https://github.com/gradle/gradle/issues/15383
    implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
 
    implementation(libs.gradleplugin.base)
}

Полный код файлов build.gradle.kts и settings.gradle.kts для нового модуля можно посмотреть по ссылкам. В итоге имеем примерно такую структуру модулей:

Структура модулей с добавленным модулем plugins для будущих convention plugin-ов

Структура модулей с добавленным модулем plugins для будущих convention plugin-ов

Теперь посмотрим на build.gradle.kts-файл в модуле composeApp. Видим, что у нас в android-блоке прописан defaultConfig, который в целом можно вынести в плагин. versionCode и versionName тоже можно выделить либо в version catalog, либо в отдельный файл versions.properties. Обычно с versions.properties удобнее настраивать CI/CD и автоинкремент сборки, но для этого нужно написать отдельную таску для автоинкремента версии.

build.gradle.kts-файл модуля composeApp

build.gradle.kts-файл модуля composeApp

Для простоты примера вынесем в version catalog:

Файл libs.versions.toml

Файл libs.versions.toml

Теперь вынесем конфигурацию android application в новый convention plugin, для этого создаем kotlin-файл AndroidApplicationPlugin.kt в модуле :convention-plugin:project:

Файл AndroidApplicationPlugin.kt

Файл AndroidApplicationPlugin.kt

Прописываем класс AndroidApplicationPlugin, который наследуется от интерфейса org.gradle.api.Plugin и заполняем:

package io.github.dmitriy1892.conventionplugins.project
 
import com.android.build.api.dsl.ApplicationDefaultConfig
import io.github.dmitriy1892.conventionplugins.base.extensions.androidConfig
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class AndroidApplicationPlugin : Plugin {
 
    override fun apply(target: Project) {
	    with(target) {
	        with(pluginManager) {
	            apply(libs.plugins.android.application.get().pluginId)
                apply("android.base.config")
	            apply("android.base.test.config")
	        }
 
	        androidConfig {
	            defaultConfig {
	                this as ApplicationDefaultConfig
 
	                targetSdk = libs.versions.targetSdk.get().toInt()
 
	                versionCode = libs.versions.appVersionCode.get().toInt()
	                versionName = libs.versions.appVersionName.get()
	            }
	        }
	    }
    }
 
}

Мы отнаследовались от Plugin, в generic-параметр передали Project —  это нужно для того, чтобы сказать gradle-у, что наш класс — плагин и что этот плагин предназначен для gradle-проекта и будет использоваться в build.gradle.kts-файлах.

Есть возможность написать плагин и для settings.gradle.kts, для этого в generic-параметр нужно передать Settings, но в этой статье такие плагины не рассматриваются.

Реализовали функцию apply от интерфейса Plugin, в ней сконструировали наш скрипт плагина —  в блоке with(pluginManager) { ... }. Этот блок аналогичен блоку plugins {} в build.gradle.kts, в него мы прописали плагины, которые включает наш плагин — android application gradle plugin и наши самописные плагины android.base.config и android.base.test.config из base-модуля. 

По дефолту отсюда недоступен вариант подключения плагинов из version catalog-а через функцию alias(), как мы это можем делать в обычных build.gradle.kts-файлах в блоке plugins {}, поэтому мы через .get().pluginId подключаем android application gradle plugin плагин в apply()-функции.

Далее взяли ранее написанный extension androidConfig и сконфигурировали блок defaultConfig, взяв из properties поля версий приложения. Теперь, чтобы такой плагин заработал, его нужно зарегистрировать —  идем в build.gradle.kts модуля convention-plugins/project и указываем внизу файла:

gradlePlugin {
    plugins {
	    register("android.application.plugin") {
	        id = "android.application.plugin"
	        implementationClass = "io.github.dmitriy1892.conventionplugins.project.AndroidApplicationPlugin"
	    }
    }
}

В первом параметре функции register(name: String, configurationAction: Action) задаем имя плагина — это внутреннее имя, оно может быть любым, главное —  уникальным. 

В Action-лямбде задаем id нашего плагина —  это тот идентификатор, который будем прописывать в plugins { id() } при подключении плагина. Ну и параметр implementationClass —  это название класса нашего плагина вместе с его package name.

Теперь мы можем заменить еще часть кода в composeApp/build.gradle.kts-файле на наш плагин:

Замена плагинов на android.application.plugin

Замена плагинов на android.application.plugin

Удаление включенного в плагин кода

Удаление включенного в плагин кода

Пробуем синхронизировать проект:

Ошибка синхронизации проекта с новым плагином

Ошибка синхронизации проекта с новым плагином

По информации из ошибки видим, что при применении плагина android.base.test.config не виден kotlin multiplatform plugin. Это произошло из-за того, что мы в плагин с android-тестами добавили конфигурационный блок kotlinAndroidTarget, который содержит kotlinMultiplatformConfig

В android.application.plugin мы не подключали KMP-плагин, и поэтому нам выдало ошибку при попытке наш плагин применить. Исправим эту оплошность разделив настройку тестов для android и для kmp. Добавим в convention-plugins/base новый плагин на базе gradle.kts-файла, назовем kmp.base.test.config.gradle.kts, куда и переместим конфигурацию в блоке kotlinAndroidTarget. Итоговый вид файлов будет таким:

Рефакторинг плагинов с конфигурациями тестов

Рефакторинг плагинов с конфигурациями тестов

Плагины разделили, подключим плагин kmp.base.test.config в build.gradle.kts модулей проекта, чтобы не сломать тесты.

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

Идем дальше, сделаем плагин для android library модуля, создаем файл AndroidLibraryPlugin.kt и наполняем:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class AndroidLibraryPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.plugins.android.library.get().pluginId)
                apply("android.base.config")
                apply("android.base.test.config")
            }
        }
    }
     
}

Регистрируем плагин в build.gradle.kts модуля convention-plugins/project:

gradlePlugin {
    plugins {
        ...
         
        register("android.library.plugin") {
            id = "android.library.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.AndroidLibraryPlugin"
        }
    }
}

Подключаем плагин в shared-uikit/build.gradle.kts-файле и удаляем ставшими ненужными строчки:

Подключение плагина android.library.plugin

Подключение плагина android.library.plugin

Синхронизируемся, запускаем. Видим, что все работает. Далее по такому же принципу напишем KmpComposeApplicationPlugin:

package io.github.dmitriy1892.conventionplugins.project
 
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpComposeApplicationPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("android.application.plugin")
                apply("kmp.compose.config")
                apply("kmp.base.test.config")
            }
        }
    }
 
}

И плагин для library-модуля — KmpComposeLibraryPlugin:

package io.github.dmitriy1892.conventionplugins.project
 
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpComposeLibraryPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("android.library.plugin")
                apply("kmp.compose.config")
                apply("kmp.base.test.config")
            }
        }
    }
     
}

Зарегистрируем оба плагина в build.gradle.kts модуля convention-plugins/project:

gradlePlugin {
    plugins {
        ...
 
        register("kmp.compose.application.plugin") {
            id = "kmp.compose.application.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpComposeApplicationPlugin"
        }
         
        register("kmp.compose.library.plugin") {
            id = "kmp.compose.library.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpComposeLibraryPlugin"
        }
    }
}

Применяем плагины в build.gradle.kts модулей проекта и вычищаем ненужное из plugins-блоков:

Применение kmp-плагинов в build.gradle.kts модулей проекта

Применение kmp-плагинов в build.gradle.kts модулей проекта

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

Можно  вынести подключение библиотек в отдельные плагины для компактности и удобства подключения, сделаем плагины для подключения корутин, сериализации, ktor, coil:

1. Kotlin coroutines:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.androidMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.commonTestDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.jvmMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpCoroutinesPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            commonMainDependencies {
                implementation(libs.kotlinx.coroutines.core)
            }
 
            commonTestDependencies {
                implementation(libs.kotlinx.coroutines.test)
            }
 
            androidMainDependencies {
                implementation(libs.kotlinx.coroutines.android)
            }
 
            jvmMainDependencies {
                implementation(libs.kotlinx.coroutines.swing)
            }
        }
    }
 
}

2. Kotlin serialization:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpSerializationPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.plugins.kotlinx.serialization.get().pluginId)
            }
             
            commonMainDependencies {
                implementation(libs.kotlinx.serialization.json)
            }
        }
    }
     
}

3. Coil:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpCoilPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            commonMainDependencies {
                implementation(libs.coil)
                implementation(libs.coil.network.ktor)
            }
        }
    }
     
}

4. Ktor:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.androidMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.iosMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.jvmMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project

class KmpKtorPlugin : Plugin {
 
    override fun apply(target: Project) {
        with(target) {
            commonMainDependencies {
                implementation(libs.ktor.core)
            }
 
            androidMainDependencies {
                implementation(libs.ktor.client.okhttp)
            }
 
            jvmMainDependencies {
                implementation(libs.ktor.client.okhttp)
            }
 
            iosMainDependencies {
                implementation(libs.ktor.client.darwin)
            }
        }
    }
     
}

5. Регистрируем плагины в build.gradle.kts модуля convention-plugins/project:

gradlePlugin {
    plugins {
        ...
 
        register("kmp.coroutines.plugin") {
            id = "kmp.coroutines.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpCoroutinesPlugin"
        }
 
        register("kmp.serialization.plugin") {
            id = "kmp.serialization.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpSerializationPlugin"
        }
 
        register("kmp.coil.plugin") {
            id = "kmp.coil.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpCoilPlugin"
        }
 
        register("kmp.ktor.plugin") {
            id = "kmp.ktor.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpKtorPlugin"
        }
    }
}

Применяем полученные плагины в build.gradle.kts модулей проекта и вычищаем ненужное из plugins-блоков:

Применение плагинов библиотек и удаление ненужного кода

Применение плагинов библиотек и удаление ненужного кода

Можем пойти еще дальше: объединить наши кастомные плагины в один и подключать всю пачку одной строкой. Но такой плагин, скорее всего, будет нужен только в рамках нашего конкретного проекта. Это оправдано на многомодульных проектах с одинаковой конфигурацией в модулях, но для нашего примера это скорее будет лишним.

______________________

Посмотрим промежуточный результат:

1. Модуль composeApp, файл build.gradle.kts:

2. Модуль shared-uikit, файл build.gradle.kts:

Выглядит неплохо: для app-модуля кода почти в 3,5 раза меньше, для library-модуля — в 5,5 раза меньше!

Осталась заключительная часть нашей серии, в ней поговорим о рефакторинге зависимостей в composite builds, подведем итоги и обсудим плюсы и минусы подхода.

© Habrahabr.ru