Вчера я мигрировал большой проект на 150 000 строк кода с AGP 8 на AGP 9. Это было мучительно. Это, скорее всего, самая большая миграция, через которую мне пришлось пройти в этом году. Поэтому, чтобы избавить вас от боли и десятков потраченных впустую часов, через которые мне пришлось пройти, я решил написать для вас полный гайд по миграции.
Будьте готовы, эта миграция займет время, так что лучше начинайте пораньше. С учетом того, что AGP 9.0 уже вышел в релиз, Google почему-то ожидает, что вы уже вчера начали его использовать. И они явно заявляют, что многие из существующих API и обходных путей, которые вы можете использовать прямо сейчас, чтобы отложить миграцию, перестанут работать летом 2026 года. Так что для больших приложений у вас не так много времени.
Перед тем как начать, помните, что несмотря на то, что AGP как-то оказался в production-релизе, многие официальные плагины, такие как Hilt plugin и KSP, не поддерживают AGP 9.0. Если вы используете Hilt или KSP в своем проекте, вы не сможете мигрировать без серьезных костылей прямо сейчас. Если вы читаете это позже января 2026 года, просто убедитесь, что Hilt и KSP уже выпустили поддержку AGP 9.0.
Если вас ничто не блокирует и вы все еще здесь, вот что вам нужно сделать, чтобы мигрировать ваш KMP проект на AGP 9.0.
Самый большой момент миграции: Убрали поддержку build types
Раньше у нас не было build types на других платформах в KMP, но Android все еще их имел. И по моему мнению, они были одной из лучших фич для безопасности и производительности, что у нас были, но теперь они не будут поддерживаться, и для них нет замены. Вам буквально придется удалить весь код, основанный на build type.
На первый взгляд это кажется небольшой проблемой, потому что команды обычно не разделяют много кода между source sets. Обычно это касается каких-то debug-проверок производительности и безопасности. Но здесь есть скрытый подвох. BuildConfig-значения перестанут работать полностью, потому что они используют build types под капотом.
У меня в кодбазе были десятки и десятки мест, где я использовал статическую переменную верхнего уровня isDebuggable, делегирующую в BuildConfig.DEBUG, которую я проверял и использовал много где, чтобы добавить дополнительный код рендеринга, отладки, логирования, и чтобы отключить многие проверки безопасности, которые были в приложении и применялись только на релизе.
Почему я использовал статическую переменную вместо чего-то вроде context.isDebuggable - потому что R8, оптимизируя релизную сборку приложения, мог бы удалить весь этот дополнительный debug-код без необходимости создавать дополнительные source sets и т.д. Это хорошо работает для KMP, где разделение release и debug долго не поддерживалось полностью в IDE.
Но теперь это полностью невозможно. Это огромный минус лично для меня, потому что мне пришлось выполнить огромную миграцию, чтобы заменить все эти использования статических глобальных переменных на DI-инжектируемый интерфейс, который был реализован с помощью все еще build configuration, но в application-модуле, например:
// in common domain KMP module
interface AppConfiguration {
val debuggable: Boolean
val backendUrl: String
val deeplinkDomain: String
val deeplinkSchema: String
val deeplinkPath: String
}
// in android app module
object AndroidAppConfiguration : AppConfiguration {
override val debuggable = BuildConfig.DEBUG
override val backendUrl = BuildConfig.BACKEND_URL
override val deeplinkDomain = BuildConfig.DEEPLINK_DOMAIN
override val deeplinkSchema = BuildConfig.DEEPLINK_SCHEMA
override val deeplinkPath = BuildConfig.DEEPLINK_PATH
}Это может привести к огромному рефакторингу, потому что я лично использовал статический флаг isDebuggable в местах, где context/DI недоступны (мой косяк, не спорю). Поэтому мне пришлось добавить ужасных хаков с глобальным DI singleton-объектом просто чтобы заставить приложение работать, а потом рефакторить код.
Когда вы закончите с этим шагом, у вас должно быть 0 использований BuildConfig, build types или manifest placeholders в библиотечных модулях. Обратите внимание, что кодогенерация для build-time констант все еще нормальна, просто не per-build-type / Android-специфичная. Вы можете создать кастомную Gradle-задачу, если хотите, которая сгенерирует Kotlin-файл для вас примерно в ~20 строк.
Я знаю, что разработчики очень любят BuildConfig.DEBUG, и я также использовал его для управления deep link доменами, подстановки backend URL для debug и release сборок, и все это пришлось рефакторить, поэтому я призываю вас прямо сейчас перестать использовать такой паттерн кода с этими статическими флагами isDebuggable.
Также избегайте использования boolean-свойства Context.isDebuggable, потому что это runtime-проверка, которая может быть переопределена злоумышленниками, так что она ненадежна. Не используйте это по соображениям безопасности. Помните - debug-код должен включаться только в debug-сборки.
Удалите весь NDK и JNI код из библиотечных модулей
Следующий шаг, который вам нужно сделать - удалить весь NDK и JNI код, который у вас есть в библиотечных KMP модулях. У меня есть пару мест, где мне нужно запустить C++-код в приложении, и раньше они находились в Android source set KMP library-модуля, где они были нужны, потому что Apple source set не нуждался в этом native-коде, а Android нуждался.
Официальное заявление от Google говорит, что выполнение NDK в библиотечных KMP модулях (включая C++-код и JNI) не будут поддерживаться с AGP 9.0. Так что теперь единственный способ сохранить этот код - переместить его в application-модуль или создать новый android-only модуль. Опять же, это огромная боль в заднице для меня, но поскольку Google не дал нам возможностей и не захотел слушать, вам придется подчиниться, если вы не хотите застрять на устаревшем AGP.
Поэтому прежде чем даже пытаться мигрировать на AGP 9.0, убедитесь, что вы создали абстракцию-интерфейс в вашем библиотечном модуле, который будет действовать как прокси для всего вашего NDK-кода. Затем реализация этого интерфейса может жить в app/android-only-модуле вместе со всем C++-кодом и инжектить реализацию в DI-граф, чтобы ваш библиотечный модуль в KMP-коде мог просто использовать этот интерфейс. По крайней мере, это то, что сделал я. Это самое простое решение проблемы, но если вы знаете лучшее, дайте знать.
Сама миграция: Удалите старый Kotlin Android plugin
Теперь мы наконец заканчиваем со всеми рефакторингами и подходим к самой миграции. Начните с удаления старого Kotlin Android plugin. У меня были настроены convention plugins, так что мне было довольно легко это сделать и мигрировать на новый плагин. Прочитайте эту страницу документации, чтобы узнать, что именно делать.
Когда вы удалите его, также добавьте новый плагин для Android Kotlin Multiplatform совместимости: com.android.kotlin.multiplatform.library. Это потому что ваша сборка перестанет работать и нам нужно мигрировать на новый DSL, который предоставляется только с этим новым плагином.
Чтобы исправить gradle sync, сделайте:
- Обновитесь с устаревшего Android DSL верхнего уровня
android { }и устаревшегоkotlin.androidLibrary {}DSL на новый единыйkotlin.android { }DSL. Вы должны иметь возможность скопировать-вставить всю вашу предыдущую конфигурацию, такую как compile SDK, minimum SDK и все остальные настройки Android, которые вы раньше имели в верхнеуровневом Android-блоке, и объединить это с кодом, который вы раньше имели в настройкеkotlin.androidLibraryKMP. Так что теперь это просто одно место. Обратите внимание, что библиотечные модули больше не поддерживают target SDK, который будет управляться только app-модулем.
id("sharedBuild")
id("detektConvention")
kotlin("multiplatform")
- id("com.android.library")
+ id("com.android.kotlin.multiplatform.library")
}
kotlin {
configureMultiplatform(this)
}
-android {
- configureAndroidLibrary(this)
-}
-Видите, как у меня была extension-функция из моего convention plugin, configureAndroidLibrary, и я удалил ее? Мы теперь можем полностью от нее избавиться. Все будет внутри блока kotlin. (configureMultiplatform в примере выше).
Дальше давайте обновим упомянутую функцию “configure multiplatform”. Это основано на этой официальной странице документации:
- if (android) androidTarget {
- publishLibraryVariants("release")
+ if (android) android {
+ namespace = [email protected]()
+ compileSdk = Config.compileSdk
+ minSdk = Config.minSdk
+ androidResources.enable = false
+ lint.warning.add("AutoboxingStateCreation")
+ packaging.resources.excludes.addAll(
+ listOf(
+ "/META-INF/{AL2.0,LGPL2.1}",
+ "DebugProbesKt.bin",
+ "META-INF/versions/9/previous-compilation-data.bin",
+ ),
+ )
+ withHostTest { isIncludeAndroidResources = true }
compilerOptions {
jvmTarget.set(Config.jvmTarget)
freeCompilerArgs.addAll(Config.jvmCompilerArgs)
}
+ optimization.consumerKeepRules.apply {
+ publish = true
+ file(Config.consumerProguardFile)
+ }
}
// ...
sourceSets {
commonTest.dependencies {
implementation(libs.requireBundle("unittest"))
}
- if (android) androidUnitTest {
- dependencies {
+ if (android) {
+ val androidHostTest = findByName("androidHostTest")
+ androidHostTest?.dependencies {
implementation(libs.requireLib("kotest-junit"))
}
}
}Итого, что здесь изменилось - у нас был блок androidTarget, который содержал небольшую часть настройки нашего библиотечного модуля. Он был заменен блоком android (не верхнего уровня, я знаю, путает). И теперь мы просто кладем все из нашего предыдущего Android-блока верхнего уровня сюда, и мы убрали конфигурацию target SDK, которая раньше была доступна здесь. Синтаксис немного изменился, но это только потому, что я использую convention plugins, так что у них нет всех тех же удобных DSL, которые были бы у вас, если бы вы просто настраивали это вручную в целевом модуле.
Как видите, я положил новые обходные пути packaging excludes, которые были у меня уже давно, в это новое место. Я переместил конфигурацию Lint warning (которая использовалась Compose). Не забудьте явно отключить Android resources в этом блоке, потому что большинство ваших KMP-модулей на самом деле не нуждаются в Android resources, так что я очень рекомендую включать их по требованию в ваших feature-модулях, где они вам действительно нужны. Это ускорит время сборки.
Вы также можете видеть, что вместо конфигурации androidUnitTest, которая у нас была, у нас просто androidHostTest, что в основном те же Android unit-тесты, к которым вы привыкли. Host означает, что они запускаются на host-машине, то есть на вашем ПК. Это просто небольшое изменение синтаксиса, бесячее, но терпимое.
Не забудьте применить consumer keep rules здесь, потому что широко используемая best practice - хранить consumer rules, которые используются конкретным библиотечным модулем, вместе в одном месте, вместо того чтобы сваливать все это в application-модуль. Я лично был недоволен переносом всех моих consumer rules в ProGuard rules файл application-модуля, так что я просто включил consumer keep rules для каждого библиотечного модуля, который у меня есть. Это особенно полезно для всяких network-модулей, database-модулей, где у меня все еще есть кастомные keep rules, и для модулей, которые должны использовать NDK и C++-код. Если вы этого не сделаете, новый плагин больше не будет распознавать и использовать ваши consumer keep rules, даже если вы их туда положите, так что это довольно важно, поскольку это всплывет только на релизной сборке, в runtime (возможно, даже в проде).
Теперь, как вы, возможно, уже догадались, верхнеуровневый блок android больше не будет для вас доступен. Не будет build variants, build flavors в этих KMP library-модулях. Поэтому, если вы следовали моим инструкциям и уже отрефакторили все эти использования, чтобы переместить их в application-модуль и инжектить необходимые флаги и переменные через DI, у вас, надеюсь, не будет много проблем с этим. Но если вы все еще используете какие-то BuildConfig-значения, теперь нет места, чтобы их объявить. То же самое можно сказать о res values, manifest placeholders и т.д. Все это теперь не поддерживается.
Примечание для тех кто использует Compose Multiplatform resources
Раньше вы видели, что мы отключили Android resources. Но если вы не включите обработку Android resources, даже для KMP-модулей с CMP resources, теперь в ваших feature-модулях и UI-модулях ваше приложение крашнется в рантайме.
kotlin {
androidLibrary {
androidResources { enable = true }
}
}Добавьте этот блок в каждый модуль, который у вас есть и который использует Compose Multiplatform resources. У меня был convention plugin для feature-модулей, что сделало это супер легким для меня. Больше деталей в тикете на YouTrack.
Замените Android Unit Test на Android Host Test
Следующий шаг - заменить объявления зависимостей Android Unit Test на объявления Android Host Test. Вы можете сделать это через поиск и замену в IDE, используя простой regex.
- androidUnitTestImplementation(libs.bundles.unittest)
+ androidHostTestImplementation(libs.bundles.unittest)Вам придется сделать это для каждого модуля, который имеет Android unit test зависимости. Я, к сожалению, не подумал о convention plugin, так что мне пришлось запустить это буквально на каждом build.gradle файле.
Мне также пришлось немного отрефакторить Gradle-файлы, потому что я использовал верхнеуровневые DSL-функции объявления зависимостей implementation и api:
dependencies {
implementation("...") // должно было быть androidImplementation()
}Это и так было неправильно, и это было непонятно, потому что эта “implementation” просто означала Android implementation, а не KMP implementation, так что это было хорошее изменение.
Я также использую FlowMVI в своем проекте, и, к сожалению, FlowMVI debugger полагается на Ktor, serialization и какие-то другие относительно тяжелые зависимости, которые раньше включались только в Android debug source set, но мне пришлось от этого отказаться и просто устанавливать FlowMVI debugger используя runtime-управляемый флаг из DI, который я упоминал выше. Это меня не радует, но в будущем я, возможно, пофикшу это, переместив установку плагина в Android app-модуль, поскольку FlowMVI делает расширение бизнес-логики супер легким.
Добавьте build script зависимость на Kotlin
Наконец, я рекомендую добавить новую build script зависимость на Kotlin, просто чтобы держать вашу build Kotlin-версию и runtime Kotlin-версии выровненными. Я хотел этого, потому что у меня есть одно определение version catalog. Вы делаете это в верхнеуровневом build.gradle.kts:
buildscript {
dependencies {
classpath(libs.kotlin.gradle) // org.jetbrains.kotlin:kotlin-gradle-plugin
}
}Небольшие быстрые опциональные победы в конце
- Избавьтесь от
android.lint.useK2Uast=true, который теперь устаревший, если он у вас был. - Опциональный шаг - использовать новые оптимизации R8, описанные в документе, на который я дал ссылку выше. У нас были ручные ProGuard rules для удаления Kotlin null checks, и теперь это поставляется с AGP, так что я просто мигрировал на новый синтаксис (
-processkotlinnullchecks remove)
Честно, эта миграция была для меня огромной болью. Я не буду утверждать, что у меня идеальный код, но я бы сказал мои конвенции и здоровье билд системы были адекватными. Если вы разработчик и все это звучит сложным и как огромные усилия - так и есть. Поскольку я уже это сделал, я могу помочь вашей команде мигрировать ваш проект на новый AGP гораздо быстрее и сэкономить вам усилия. Я недавно начал брать заказы в качестве консультанта по KMP, так что если вам понравился этот материал и вы хотите, чтобы я вам помог, буду рад, если вы дадите знать тимлиду/начальству об этой возможности, переслав им ссылку на мой сайт nek12.dev