Ты уже оптимизировал билд кэши на CI, но время прогонов в МРах неуклонно переваливает за 10 минут? Не хочешь лезть в импакт анализ? Долгий синк проекта в студии?
Проблема
Android Gradle Plugin это тяжелая штука. Модуль с подключенным AGP (например с com.android.library
) конфигурируется в два раза дольше чем обычный jvm модуль java/kotlin. В момент конфигурации android-модуля потребляется в два раза больше оперативной памяти. У модуля с AGP больше тасок, больше вес output артефактов, больше триггеров для инвалидации билд-кэша.
В крупных проектах, когда у нас есть возможность использовать модули без AGP, мы должны это делать ради времени билда и конфигурации.
К сожалению в 90% случаев мы не можем оставить модуль обычным JVM модулем из-за того что он ссылается на какие-то сущности из Android SDK или библиотеки, распространяемые в виде AAR артефактов.
Решение
Решение придумано не мной. Спасибо за первоисточник Степану Гончарову и его плагинам android-jar и aar2jar.
TLDR; Решение в том чтобы разрешить JVM модулям зависеть от Android SDK и AAR, при этом не подключая AGP.
Избавляемся от AGP в модулях
Путь отказа от AGP состоит из двух шагов, которые впервые в публичном поле озвучил Степан Гончаров в своем докладе. У него даже остался исходный код на GitHub, который к сожалению уже не работает на новых версиях Gradle.
Избавляемся чуть-чуть (android-jar)
Первоисточник: https://github.com/stepango/android-jar
Первым делом мы можем избавиться от AGP в тех модулях, которые каким-либо образом зависят от классов чистого Android, от содержимого пакетов android.*
.
Например, часто встречается зависимость от класса android.content.Context
.
AGP под капотом подключает к модулю android.jar
- API от той версии Android под которую ты разрабатываешь (target sdk). Этот JAR не вшивается в приложение, он используется только на этапе сборки. Чтобы провалидировать все вызовы API Android из твоего кода, и чтобы в IDE автокомплит работал. По-умному говорят что android.jar
входит в compile classpath, но не входит в runtime classpath. Подробнее об этом можно почитать тут. Когда приложение попадает на устройство или эмулятор, в рантайме оно уже пользуется реальным android.jar.
compileClasspath можно воспринимать как аналог заголовочных (.h) файлов в Си/С++. Им не обязательно нести реализацию. Они нужны в первую очередь для того, чтобы дать нам API внешних библиотек.
Фишка в том что никто не мешает нам подключить к JVM модулю android.jar
самостоятельно, при этом избежав подключения всего AGP. В Gradle для этого есть конфигурация compileOnly
:
// build.gradle
dependencies {
compileOnly(androidJar)
}
Но откуда в контексте билдскрипта возьмется androidJar
? Мы его туда сами добавим. Причем сделаем это так чтобы работало и в скриптах Groovy, и в KTS (осуждаю).
Определяем расположение android.jar
Поиск нужного нам файла будет происходить на этапе конфигурации корневого проекта. Искать можно с помощью вот такого класса:
private class AndroidJarFinder(private val project: Project) {
fun find(compileSdk: Int): ConfigurableFileCollection =
project.files("${findSdkLocation()}/platforms/android-${compileSdk}/android.jar")
private fun findSdkLocation(): File {
val localProperties = File(project.rootDir, "local.properties").takeIf { it.exists() }
return localProperties?.let(::getSdkDirFromLocalProperties)
?: getSdkDirFromEnvVariable()
?: throw RuntimeException(
"The Android SDK location could not be found in following locations:" +
"- Absolute path `sdk.dir` in `local.properties` file\n" +
"- Relative path `android.dir` in `local.properties` file\n" +
"- `ANDROID_HOME` environment variable\n" +
"- System property `android.home`"
)
}
private fun getSdkDirFromLocalProperties(localProperties: File): File? {
val properties = Properties()
FileInputStream(localProperties).use(properties::load)
// `sdk.dir` is for absolute path, `android.dir` is for relative
return properties.getProperty("sdk.dir")?.let(::File)
?: properties.getProperty("android.dir")?.let { File(project.rootDir, it) }
}
private fun getSdkDirFromEnvVariable(): File? {
return System.getenv("ANDROID_HOME")?.let(::File)
?: System.getProperty("android.home")?.let(::File)
}
}
Ищем путь до установленного в систему android sdk в 4 местах:
Свойство
sdk.dir
вlocal.properties
.Свойство
android.dir
вlocal.properties
— обычно тут указывается путь до Android SDK установленный в директорию проекта.Переменная окружения
ANDROID_HOME
.Java System property
android.home
.
К корневому проекту должен быть подключен какой-нибудь Convention-плагин, который вызовет у себя rememberAndroidJar
:
private const val AndroidJarLocationKey = "EXAMPLE_INTERNAL__ANDROID_JAR_LOCATION"
class AndroidJarFinderConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
assert(target == target.rootProject) {
"Plugin 'jar.finder' can be applied only to root project"
}
target.rememberAndroidJar()
}
/** В `ext` корневого проекта кладем найденный `android.jar` */
internal fun Project.rememberAndroidJar() {
val targetSdk = 35 // сами придумайте откуда его брать
val androidJarFinder = AndroidJarFinder(this)
val androidJar = androidJarFinder.find(targetSdk)
extensions.extraProperties[AndroidJarLocationKey] = androidJar
}
}
Конфигурация корневого проекта всегда происходит до конфигурации дочерних проектов (сабмодулей). Даже если включена фича Configure On Demand. Поэтому на этапе конфигурации дочерних модулей мы можем пользоваться результатами вычислений в root project:
Доступ к androidJar из дочерних модулей
Gradle позволяет добавлять в контекст скриптов расширения(extensions). Расширения могут быть любым объектом, поэтому мы создадим расширение с именем androidJar
и запихаем внутрь него ConfigurableFileCollection
, который уже вычислен на этапе конфигурации корневого модуля. Тогда и на Groovy, и на KTS мы сможем указывать его в качестве зависимости.
internal fun Project.createAndroidJarAccessorExtension() {
extensions.add(
/* name = */ "androidJar",
/* extension = */ checkNotNull(rootProject.extensions.extraProperties[AndroidJarLocationKey]) {
"The property with 'android.jar' location is missing. Make sure the " +
"'jar.finder' plugin is applied in the root project"
}
)
}
В convention-плагине Kotlin JVM модуля применяем:
class KotlinJvmConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
...
target.createAndroidJarAccessorExtension()
}
}
Теперь прописывая в Kotlin JVM модулях зависимость compileOnly(androidJar)
мы сможем использовать в них Context и другие андроидовские классы.
Реализовать шаринг данных между корневым и дочерними проектами на этапе конфигурации можно разными способами. Возможно, в будущем текущий способ станет несовместимым с project isolation. Но к тому моменту у нас будет новое API для подобных целей.
Избавляемся полностью жестб (aar-to-jar)
Первоисточник: https://github.com/stepango/aar2jar
По опыту могу сказать, что для большинства модулей в проекте подключения android jar не будет достаточно. Мы часто используем внешние библиотеки, которые можно подключить только к android-модулям.
Все дело в том что библиотеки под Android распространяются в виде AAR артефактов. JVM модули не знают о таких и позволяют подключать к себе только JAR библиотеки. Если мы заглянем внутрь AAR, то увидим что это ZIP архив внутри которого содержатся всякие андроидо-специфичные штуки и... JAR файлы с кодом.
Решение на русском языке
Чтобы наши pure kotlin/java модули научились понимать AAR зависимости, нам нужно всего лишь старый советский распаковать AAR, вытащить из него JAR файлы и подключить их в качестве зависимостей к нашему модулю. Звучит не сложно да?
Реализация
В Gradle есть встроенный механизм, позволяющий без большого количества костылей описать процесс трансформации зависимостей из одного формата в другой — Artifact Transform. У нее есть непонятная дока (как и любая дока по Gradle), но в нашем случае суть довольно проста:
TransformAction
можно рассматривать как Task Action, но у которого есть ровно один input и один output. И этот Action предназначен только для конвертации одного артефакта в другой.Когда какой-либо input у таски имеет "непонятный тип", Gradle попытается привести его к "понятному типу" путем применения нужных трансформаций.
Нам нужен такой
TransformAction
, который получив на вход AAR либу, выплюнет наружу ее JAR файлы
Собсна TransformAction
может выглядеть вот так:
abstract class Aar2JarTransformAction : TransformAction<TransformParameters.None> {
@get:InputArtifact
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val inputArtifact: Provider<FileSystemLocation>
override fun transform(outputs: TransformOutputs) {
val inputFile = inputArtifact.get().asFile
val baseName = inputFile.nameWithoutExtension
ZipFile(inputFile).use { zipFile ->
zipFile
.entries()
.toList()
.filter { entry -> entry.name.endsWith(".jar") }
.forEach { entry ->
val outputName = when (entry.name) {
"classes.jar" -> "$baseName.jar"
else -> "$baseName-${entry.name}"
}
outputs.file(outputName).outputStream().use { output ->
zipFile.getInputStream(entry).use { entryStream ->
entryStream.copyTo(output)
}
}
}
}
}
}
Эта версия немного отличается от оригинальной. Она умеет работать с AAR, в которые упаковано более одного classes.jar
.
Ну и дальше в нашем Convention-плагине для Kotlin/Java JVM модулей мы должны зарегистрировать кастомный TransformAction
, вызвав configureAar2JarFeature
:
fun Project.configureAar2JarFeature() {
val artifactTypeAttribute = ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE
// Регистрируем нашу трансформилку
dependencies.registerTransform(Aar2JarTransformAction::class.java) { spec ->
spec.from.attribute(artifactTypeAttribute, "aar")
spec.to.attribute(artifactTypeAttribute, "jar")
}
}
При регистрации указываем, что наша поделка трансформирует артефакты из AAR в JAR. Подробнее про атрибуты есть непонятная дока от Gradle.
Пробуем подключить AAR либу через compileOnly
Чтобы протестировать, что наше решение НЕ РАБОТАЕТ, попробуем подключить к Kotlin модулю библиотеку Timber (у нее AAR, брат). Подключим ее через конфигурацию compileOnly
:
dependencies {
compileOnly(libs.timber)
}
Во время синка будет ошибка вот такого рода:
Я до конца так и не понял, как это обойти. Возможно в одном issue и кроется ответ: github.com/gradle/gradle/issues/8386.
Если выражаться на птичьем языке — какие-то из атрибутов конфигурации compileOnly
несовместимы с подключаемыми AAR. А обойти это можно создав отдельную конфигурацию и вкинув ее содержимое в java compile classpath ручками. Пиздеееец.
Пробуем подключить AAR либу через aarCompileOnly
Создаем конфигурацию с именем aarCompileOnly
. будем с ее помощью подключать андроидовские библиотеки к Kotlin модулям. Дополняем функцию configureAar2JarFeature
:
fun Project.configureAar2JarFeature() {
val artifactTypeAttribute = ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE
configurations.register("aarCompileOnly") { aarCompileOnly ->
with(aarCompileOnly) {
isTransitive = false // не подсасываем транзитивные зависимости
isCanBeConsumed = false
attributes.attribute(artifactTypeAttribute, "jar")
}
plugins.withId("java") {
javaExtension.sourceSets.named("main") { main ->
main.compileClasspath += aarCompileOnly
}
}
}
// Регистрируем нашу трансформилку
...
}
private val Project.javaExtension: JavaPluginExtension
get() = extensions.getByType(JavaPluginExtension::class.java)
Пробуем подключить Timber еще раз:
dependencies {
aarCompileOnly(libs.timber)
}
И омагад, оно работает. Можно импортировать Timber в нашем Kotlin модуле, а потом это еще и без ошибок скомпилируется.
Докручиваем решение
Для того чтобы можно было подключать какие-то Android-библиотеки к Unit-тестам, нужна еще одна конфигурация: aarTestImplementation
. Получается такой странный набор кастомных конфигураций, я объясню это в конце.
fun Project.configureAar2JarFeature() {
val artifactTypeAttribute = ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE
configurations.register("aarCompileOnly") { ... }
configurations.register("aarTestImplementation") { aarTestImplementation ->
with(aarTestImplementation) {
isTransitive = false
isCanBeConsumed = false
attributes.attribute(artifactTypeAttribute, "jar")
}
plugins.withId("java") {
javaExtension.sourceSets.named("test") { test ->
test.compileClasspath += aarTestImplementation
test.runtimeClasspath += aarTestImplementation
}
}
}
}
И вишенкой на торте можно добавить фикс, нужный для корректной работы GUI Android Studio и IntelliJ IDEA с новыми конфигурациями:
internal fun Project.configureAar2JarFeature() {
...
configurations.register("aarCompileOnly") { aarCompileOnly ->
...
// Register configuration in IDEA if needed
plugins.withId("idea") {
ideaExtension.module.scopes.forEach { (_, value) ->
value["plus"]?.add(aarCompileOnly)
}
}
}
configurations.register("aarTestImplementation") { aarTestImplementation ->
...
// Register configuration in IDEA if needed
plugins.withId("idea") {
ideaExtension.module.scopes["TEST"]
?.get("plus")?.add(aarTestImplementation)
}
}
}
private val Project.ideaExtension: IdeaModel
get() = extensions.getByType(IdeaModel::class.java)
На этом все, пользуйтесь на здоровье. А теперь читаем про побочные эффекты этой черной магии.
О чем стоит помнить
Может упасть в рантайме
Библиотеки, добавленные через aarCompileOnly
конфигурацию должны быть добавлены где-то еще в проекте через runtimeOnly
или implementation
. Например, в модуле :app
.
Все потому что, как я уже писал, если библиотека есть в compile classpath, но отсутствует в runtime classpath, она не поедет вместе с вашим приложением на прод. И при обращении к любому ее классу в рантайме будет NoClassDefFoundError
.
Так чисто на всякий случай пишу что
implementation(androidJar)
писать нигде не надо, потому что в рантайме он всегда есть и без вашей помощи.
Странный набор конфигураций
AAR библиотеки как правило несут в себе иного другой полезной инфы, нужной для сборки Android приложения. Например, AndroidManifest и proguard файлы. Когда мы подключаем такую библиотеку через aar-to-jar трансформацию, мы все это успешно выбрасываем.
Поэтому я считаю что подключать AAR к jvm модулям нужно только через aarCompileOnly
. Подключать только ради доступа к API. При этом в :app
модуле подключать эти библиотеки через implementation
.
Конфигурация aarTestImplementation
подключает либу и в compile, и в runtime classpath. Это нужно для того чтобы Unit-тесты можно было запустить и чтоб они не падали из-за NoClassDefFoundError
.
Погибает android lint
Мы теряем возможность использования Android Lint, отключая AGP от модуля. Хорошо что у нас есть Detekt.
Итоги
Избавление от AGP это один из самых эффективных способов снизить время конфигурации в большом проекте (до тех пор пока не появится gradle project isolation). И один из последних способов снизить билд-тайм, не внедряя impact-анализ.
Решение из этой заметки не новое, но я постарался хотя бы по верхам объяснить, за счет чего оно работает.