Композитная сборка как альтернатива buildSrc в Gradle
В Gradle — системе автоматической сборки — подход с buildSrc уже успел стать стандартом для реализации собственных плагинов и задач, а также создания общих конфигураций, например списков зависимостей и версий. Но у него есть существенный недостаток: при изменении buildSrc
кеш сборки становится недействительным.
При этом Gradle предоставляет альтернативный подход — композитные сборки, лишённые этого недостатка. В этой статье я расскажу, как использовать композитную сборку вместо buildSrc
и с какими сложностями можно столкнуться при миграции.
Мой опыт конфигурирования Gradle
Система сборки Gradle для сборки Android приложений была представлена вместе с Android Studio. Последняя существовала не всегда: если вы разрабатываете Android-приложения более шести лет, то, вероятно, помните Eclipse с Android-плагином. Сегодня эта комбинация считается устаревшей и не поддерживается.
В то время Gradle и Groovy казались магией (и до сих пор кажутся) и я копировал всё подряд со Stack Overflow. Поскольку у приложений редко было больше одного модуля, считалось совершенно нормальным хранить всё в одном файле build.gradle
.
Я начал использовать модули для внешних библиотек каждый раз, когда мне хотелось что-то в них исправить. Я просто копировал всю магию из файла приложения build.gradle
в библиотечный файл. Метод отлично работал, если не считать того, что всё приходилось дублировать, например обновлять версии зависимостей во всех модулях. Позднее я обнаружил, что можно использовать корневой файл build.gradle
для создания общей конфигурации модулей.
// projectRoot/build.gradle
public void configureAndroid(Project project) {
project.android {
compileSdkVersion 9
}
}
// projectRoot/app/build.gradle
configureAndroid(this)
android {
// Module specific configuration
}
Стало намного лучше, но не идеально. Корневой файл build.gradle
оказался слишком большим и сложным в поддержке. Когда в моду вошла модульность и мы начали разделять свои приложения на модули data
, core
, domain
и presentation
, я открыл другой подход: извлечь эти функции в отдельные скрипты Gradle и применять их.
// projectRoot/android.gradle
project.android {
compileSdkVersion 9
}
// projectRoot/app/build.gradle
apply from: '../android.gradle'
Такому решению не хватает автодополнения в IDE, которое даже не получится добавить. В build.gradle
эту проблему можно решить с помощью блока plugins { }
, но для всех остальных файлов скриптов он недоступен.
Сейчас, годы спустя, многие разработчики, в том числе и я, используют buildSrc для управления общими конфигурациями. После многих лет страданий применение buildSrc
в проектах стало настоящим счастьем. Вы можете использовать любой JVM-язык, иметь полноценное автодополнение и поддержку IDE. Вы можете даже писать тесты: модульные (с помощью JUnit или любого другого фреймворка) или интеграционные тесты, которые запускают отдельный экземпляр Gradle в тестовом окружении. Неужто мы, наконец, нашли святой Грааль конфигурирования Gradle?!
Недостаток buildSrc
К сожалению, вместе с обилием всех этих классных функций мы получаем и один большой недостаток. Любые изменения внутри buildSrc
делают кеш сборки полностью недействительным. Также недействительным становится remote кеш, если вы его используете. В маленьких проектах это не является проблемой, однако в больших проектах с сотнями модулей всё гораздо сложнее. С другой стороны, изменения в файлах скриптов Gradle делают недействительным не весь кеш, а только некоторые задачи.
Представьте такую цепочку кешируемых задач: compile
(Java-плагин) → report
(наша собственная задача). compile
имеет тип JavaCompile
, который мы получили от встроенного Java-плагина. report
— наша собственная задача, которую можно создать двумя способами: внутри buildSrc
или build.gradle
.
Теперь внесём в класс задачи report
изменение, влияющее на байт-код. В случае с buildSrc
-подходом задачи compile
и report
будут выполнены снова, даже если не менялись класс compile
и входные и выходные данные. А при подходе с применением build.gradle
снова будет выполнена лишь задача report
. Входные и выходные данные, а также байт-код для задачи compile
не менялись, поэтому результаты будут взяты из кеша. Gradle не может проверить, сгенерирует ли задача report
такой же результат, поэтому запускает её снова, игнорируя кеш сборки.
Как нам сделать так, задача compile
не выполнялась повторно? Ведь мы точно не хотим возвращаться назад и терять все эти классные функции buildSrc
ради сохранения скорости сборки.
Композитные сборки
Говоря простым языком, композитная сборка содержит разные корневые проекты. Если у вас просто многомодульный проект, тогда все модули используют общую конфигурацию, которая задаётся корневым build.gradle
. Однако есть способ добавить проект без влияния на него общей конфигурации. Это полезно для сборки внешних библиотек и частей вашего проекта, которые совершенно независимы друг от друга, а также для Gradle-плагинов. Вы сможете ссылаться на плагины по идентификатору из включённой (included) в основной проект сборки.
Если извлечь из папки buildSrc
логику конфигурации, то классы из включённой сборки не будут расцениваться как часть buildSrc
и Gradle не отметит кеш сборки как полностью недействительный. Логика конфигурации будет предоставляться основному проекту как внешняя зависимость (как и другие плагины, например Android Gradle plugin). В этом случае Gradle может корректно проверить входные и выходные данные задачи и использовать кеш сборки.
Важно отметить, что последующие изменения влияют только на кеш сборки. Он содержит сериализованные результаты выполнения задач с ключами, которые определяются входными данными и classpath. Когда результат выполнения задачи берётся из кеша, вы видите у задачи статус FROM-CACHE
. Эти изменения не влияют на инкрементальные сборки, и задачи в любом случае будут становиться недействительными.
Перед запуском задачи Gradle может проверить, менялись ли входные и выходные данные после предыдущего запуска. Если не менялись, вы увидите статус UP-TO-DATE
.
Миграция с buildSrc на композитную сборку
Теперь я расскажу о миграции нашей библиотеки Reaktive на композитную сборку. Это прекрасный пример по ряду причин:
- у неё есть buildSrc, в котором находятся собственные плагины;
- у неё есть внешний конфигурационный файл binary-compatibility.gradle;
- у неё общая логика находится внутри корневого файла build.gradle.
То есть применены все три подхода, описанные в первой части статьи. Я покажу, как работать с каждым из них и как преобразовать их в отдельные плагины.
Копирование
Первый шаг прост. Скопируем папку buildSrc
в папку buildSrc2
. Если вы ещё не используете плагины в папке buildSrc
, то самое время начать. Без плагинов классы из нового модуля не будут загружены в classpath скрипта. Пока не удаляйте исходную папку buildSrc
, чтобы была возможность синхронизировать проект. Чтобы сообщить Gradle о появлении нового модуля, добавим в settings.gradle
следующий код:
// projectRoot/settings.gradle.kts
pluginManagement {
repositories {
google()
jcenter()
gradlePluginPortal()
}
}
includeBuild("buildSrc2")
// include(":module")
Первая строка добавлена для нашего удобства и для удаления блока buildscript { repositories { } }
. С помощью функции includeBuild
мы заставляем Gradle обращаться с проектом в папке buildSrc2
как с включённой сборкой.
Миграция плагинов
Как начать использовать плагины и классы из buildSrc2
? Сначала объявим плагины.
// projectRoot/buildSrc2/build.gradle.kts
plugins {
`kotlin-dsl`
`java-gradle-plugin`
}
gradlePlugin {
// Объявим пустой плагин, чтобы с помощью него можно было загрузить классы
plugins.register("class-loader-plugin") {
id = "class-loader-plugin"
implementationClass = "com.example.ClassLoaderPlugin"
}
// Или, если у вас уже есть плагины, их можно зарегистрировать здесь таким же образом
}
java-gradle-plugin
создаст для плагинов соответствующие файлы properties
, так что теперь эти файлы можно удалить. Почитать о java-gradle-plugin
можно здесь.
Если у вас пока нет плагинов для управления зависимостями и вы используете только buildSrc
, нужно создать пустой плагин и применить его к проекту, чтобы классы стали доступны в Gradle-скрипте.
// ClassLoaderPlugin.kt
class ClassLoaderPlugin: Plugin {
override fun apply(target: Project) {
// no-op
}
}
// Deps.kt
object Deps {
const val kotlinStdLib = "..."
}
После применения class-loader-plugin
к проекту становится доступен класс Deps
. И автодополнение будет работать как прежде.
// projectRoot/app/build.gradle
plugins {
id 'class-loader-plugin'
}
dependencies {
implementation(Deps.kotlinStdLib)
}
Миграция общих функций
В build.gradle
есть функция setupMultiplatformLibrary.
// projectRoot/build.gradle
void setupMultiplatformLibrary(Project project) {
project.apply plugin: 'org.jetbrains.kotlin.multiplatform'
project.kotlin {
sourceSets {
commonMain {
dependencies {
implementation Deps.kotlin.stdlib.common
}
}
commonTest {
dependencies {
implementation Deps.kotlin.test.common
implementation Deps.kotlin.test.annotationsCommon
}
}
}
}
}
Она определяет некую общую конфигурацию для всех модулей. Мы применили плагин Kotlin Multiplatform и объявили некоторые важные зависимости.
Для преобразования этой функции в Gradle-плагин нужно добавить зависимость и объявить его:
// projectRoot/buildSrc2/build.gradle.kts
dependencies {
// Зависимость на Kotlin Gradle plugin, чтобы у нас появилась возможность использовать его классы
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72")
}
gradlePlugin {
// Зарегистрируем наш плагин
plugins.register("mpp-configuration") {
id = "mpp-configuration"
implementationClass = "com.badoo.reaktive.configuration.MppConfigurationPlugin"
}
}
// MppConfigurationPlugin.kt
class MppConfigurationPlugin : Plugin {
override fun apply(target: Project) {
// Каждый плагин может зарегистрировать расширения как "kotlin" или "android"
target.extensions.create("configuration", MppConfigurationExtension::class.java, target)
// В этой функции сделаем всё то же самое, что и в оригинальной, только с использованием Kotlin
setupMultiplatformLibrary(target)
}
private fun setupMultiplatformLibrary(target: Project) {
// project.apply plugin: 'org.jetbrains.kotlin.multiplatform'
target.apply(plugin = "org.jetbrains.kotlin.multiplatform")
// project.kotlin {
target.extensions.configure(KotlinMultiplatformExtension::class.java) {
sourceSets {
maybeCreate("commonMain").dependencies { implementation(Deps.kotlin.stdlib.common) }
maybeCreate("commonTest").dependencies {
implementation(Deps.kotlin.test.common)
implementation(Deps.kotlin.test.annotationsCommon)
}
}
}
}
}
Также у нас есть параметризованная настройка для setupAllTargetsWithDefaultSourceSets с параметром isLinuxArm32HfpEnabled
. Корутины не поддерживают linuxArm32Hfp
, а мы поддерживаем. Поэтому нужно уметь настраивать модули как с поддержкой linuxArm32Hfp
, так и без неё. Для этого можно отфильтровать project.name
, но поддерживать отдельно такой список проектов не хочется. Давайте реализуем это с помощью расширения, как это делают другие плагины.
// MppConfigurationExtension.kt
open class MppConfigurationExtension @Inject constructor(
private val project: Project
) {
var isLinuxArm32HfpEnabled: Boolean = false
private set
// Мы вызовем этот метод, если в модуле нужна поддержка ARM32
fun enableLinuxArm32Hfp() {
if (isLinuxArm32HfpEnabled) return
project.plugins.findPlugin(MppConfigurationPlugin::class.java)?.setupLinuxArm32HfpTarget(project)
isLinuxArm32HfpEnabled = true
}
}
// MppConfigurationPlugin.kt
class MppConfigurationPlugin : Plugin {
override fun apply(target: Project) {
target.extensions.create("configuration", MppConfigurationExtension::class.java, target)
...
}
fun setupLinuxArm32HfpTarget(project: Project) {
if (!Target.shouldDefineTarget(project, Target.LINUX)) return
project.kotlin {
linuxArm32Hfp()
sourceSets {
maybeCreate("linuxArm32HfpMain").dependsOn(getByName("linuxCommonMain"))
maybeCreate("linuxArm32HfpTest").dependsOn(getByName("linuxCommonTest"))
}
}
}
}
К сожалению, здесь мы не можем сделать наоборот (disableLinuxArm32Hfp()
и включать по умолчанию), потому что плагин Kotlin не обрабатывает удаление целевой платформы компиляции (только добавление). Зато мы можем применить конфигурацию с помощью плагина mpp-configuration
и его расширения configuration
.
// projectRoot/reaktive/build.gradle
plugins {
id 'mpp-configuration'
}
// Опционально
configuration {
enableLinuxArm32Hfp()
}
Автодополнение работает как надо.
Миграция внешнего файла скрипта
Мы используем Binary compatibility validator, который я описывал в другой статье. Его конфигурация определяется внутри binary-compatibility.gradle и применяется в корневом файле build.gradle
. По сути, скрипт просто применяет плагин и задаёт игнорируемые модули.
// projectRoot/binary-compatibility.gradle
if (Target.shouldDefineTarget(target, Target.ALL_LINUX_HOSTED)) {
apply plugin: kotlinx.validation.BinaryCompatibilityValidatorPlugin
apiValidation {
ignoredProjects += [
'benchmarks',
'jmh',
'sample-mpp-module',
'sample-android-app',
'sample-js-browser-app',
'sample-linuxx64-app',
'sample-ios-app',
'sample-macos-app'
]
}
}
Теперь можно преобразовать этот скрипт в плагин с помощью описанного выше подхода.
// projectRoot/buildSrc2/build.gradle.kts
dependencies {
// Добавим зависимость на Binary Compatibility Plugin
implementation("org.jetbrains.kotlinx:binary-compatibility-validator:0.2.3")
}
gradlePlugin {
// Зарегистрируем наш плагин
plugins.register("binary-compatibility-configuration") {
id = "binary-compatibility-configuration"
implementationClass = "com.badoo.reaktive.compatibility.BinaryCompatibilityConfigurationPlugin"
}
}
// BinaryCompatibilityConfigurationPlugin.kt
class BinaryCompatibilityConfigurationPlugin : Plugin {
override fun apply(target: Project) {
if (Target.shouldDefineTarget(target, Target.ALL_LINUX_HOSTED)) {
target.apply(plugin = "binary-compatibility-validator")
target.extensions.configure(ApiValidationExtension::class) {
ignoredProjects.addAll(
listOf(
"benchmarks",
"jmh",
"sample-mpp-module",
"sample-android-app",
"sample-js-browser-app",
"sample-linuxx64-app",
"sample-ios-app",
"sample-macos-app"
)
)
}
}
}
}
Затем применим наш новый плагин внутри корневого файла build.gradle
.
// projectRoot/build.gradle
plugins {
id 'binary-compatibility-configuration'
}
Зависимости
С нашим новым подходом необходимо управлять зависимостями в основном проекте и во включённых сборках. В последних мы будем использовать в качестве зависимостей разные плагины, чтобы обеспечить доступ к соответствующим классам, таким как расширения проектов, задачи и т. д. Для управления зависимостями создадим ещё одну включённую сборку.
// rootProject/dependencies/build.gradle.kts
plugins {
`kotlin-dsl`
`java-gradle-plugin`
}
// Зададим параметры публикации
group = "com.badoo.reaktive.dependencies"
version = "SNAPSHOT"
repositories {
jcenter()
}
gradlePlugin {
// Создадим пустой плагин, чтобы получить доступ к классам
plugins.register("dependencies") {
id = "dependencies"
implementationClass = "com.badoo.reaktive.dependencies.DependenciesPlugin"
}
}
Теперь мы можем создать класс Deps
для всех внешних зависимостей. Посмотреть реализацию можно здесь. Добавим в settings.gradle
новый модуль с помощью includeBuild("dependencies")
. Теперь можно в любом проекте использовать плагин dependencies
и класс Deps
.
// projectRoot/buildSrc2/build.gradle.kts
import com.badoo.reaktive.dependencies.Deps
plugins {
`kotlin-dsl`
`java-gradle-plugin`
id("dependencies")
}
dependencies {
// Теперь в другой включённой сборке можно использовать Deps
implementation(Deps.kotlin.plugin)
// implementation(implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT"))
}
// Почти то же самое, что и implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT"), но с работающим автодополнением
kotlin.sourceSets.getByName("main").kotlin.srcDir("../dependencies/src/main/kotlin")
В процессе реализации этого подхода я обнаружил, что если использовать включённую сборку в качестве зависимости внутри другой включённой сборки, то IDEA не подсвечивает её классы в редакторе (хотя проект компилируется без ошибок). Чтобы это исправить, я вручную добавил класс Deps
в компиляцию buildSrc2
, чтобы он был доступен везде, где применяются плагины из buildSrc2
. Это довольно грубый и нестабильный костыль, но я надеюсь, что это поведение исправят. После этого достаточно будет использовать обычную нотацию implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT")
.
Плагин dependencies
можно использовать в модулях основного проекта вышеописанным способом.
Недостатки
Единственной разницей между использованием композитных сборок и buildSrc
является доступность классов без соответствующих плагинов. Она проявляется, когда мы применяем блок plugins { }
вместо apply plugin: 'id'
или прямых вызовов функций. Главное достоинство использования блока plugins
в том, что в Groovy-скриптах работает автодополнение. У вас будет доступ к классам и расширениям, относящимся к конкретным плагинам. Но вы не можете быть уверены в том, что расширения полностью сконфигурированы на момент применения плагина.
Допустим, у вас такой набор плагинов:
apply plugin: 'android-library'
android {
compileSdkVersion 30
}
apply plugin: 'custom-plugin'
class CustomPlugin: Plugin {
override fun apply(target: Project) {
target.logger.warn(
target.extentions.getByType(BaseExtension::class)
.compileSdkVersion.toString()
)
}
}
Плагин при конфигурировании просто выводит в консоли текущий compileSdkVersion
. В этой реализации будет выведено значение 30
. Теперь попробуем использовать блок plugins { }
.
plugins {
id 'android-library'
id 'custom-plugin'
}
android {
compileSdkVersion 30
}
Теперь вы увидите в консоли null
, потому что custom-plugin
применился до конфигурации android
. Исправить это можно несколькими способами:
- Продолжать использовать
apply plugin: 'custom-plugin'
или статические функции. Если мигрировать трудно, то никто не запрещает делать по-старому. - Использовать внутри плагина блок
project.afterEvaluate { }
. Но будьте осторожны: если злоупотребить этим, то блокиafterEvaluate
начнут зависеть от других блоковafterEvaluate
и порядка их исполнения. - Попробовать преобразовать логику в плагине с помощью ленивого API, задач и других механизмов. Для этого нужно хорошо разбираться в Gradle API, но зато вы сможете создавать переиспользуемые и независимые плагины.
Заключение
Композитную сборку можно использовать в качестве альтернативы buildSrc
, чтобы избегать инвалидации кеша Gradle. Перейти на неё можно просто и безболезненно с помощью предложенного подхода. Чтобы оценить все возможности автодополнения в Groovy-скриптах, нужно использовать блоки plugins { }
. А если у вас нет плагинов, то просто создайте пустой плагин и примените его для загрузки классов из того же модуля.