settings.gradle.kts объявляет, какие модули есть в проекте и откуда скачиваются плагины и библиотеки, build.gradle.kts каждого модуля настраивает этот модуль, а Gradle wrapper закрепляет одну версию Gradle для всех машин.
**settings.gradle.kts**: include(":app", ":core:network") и репозитории в pluginManagement и dependencyResolutionManagement. Gradle выполняет его первым.
**Корневой build.gradle.kts**: обычно только перечисляет плагины с apply false, чтобы все модули использовали одни и те же версии AGP и Kotlin.
**build.gradle.kts модуля**: применяет плагины, содержит блок android { } (namespace, compileSdk, minSdk, build types) и dependencies { }.
**gradle/libs.versions.toml: version catalog: версии всех библиотек и плагинов в одном файле.
gradle.properties**: память демона (org.gradle.jvmargs) и флаги сборки вроде org.gradle.caching=true.
**gradlew и local.properties**: gradlew и gradle/wrapper/gradle-wrapper.properties скачивают и запускают закреплённую версию Gradle, поэтому собирайте через ./gradlew и коммитьте их; local.properties хранит путь к SDK на этой машине и не коммитится.
Суффикс .kts означает Kotlin DSL, стандарт для новых проектов: скрипты проверяются компилятором и поддерживают автодополнение, в отличие от старых Groovy-файлов build.gradle.
AndroidSenior
Gradle и модуляризация
Gradle KTS, каталоги версий, convention-плагины, KSP, варианты сборки, configuration cache, модули
Закрыто вопросов
0 / 20
- 0Повторить
- 2Часто спрашивают
Вопросы
Android Lint представляет собой статический анализатор, встроенный в AGP: он проверяет код, ресурсы и манифест на реальные баги (вызов API выше minSdk, отсутствующее разрешение, неиспользуемый ресурс). Проверкой качества он становится, когда CI запускает его и ошибки роняют сборку.
Запуск: ./gradlew lintRelease пишет HTML- и XML-отчёты в build/reports. lintVitalRelease запускается автоматически при release-сборке и проверяет только фатальные проблемы.
Legacy-код: baseline = file("lint-baseline.xml") фиксирует сегодняшние проблемы и показывает только новые, так что проверку можно включить, не исправляя сначала 800 старых предупреждений.
Строгость: warningsAsErrors = true делает проверку жёсткой; disable += "..." или файл lint.xml отключают проверки, которые для проекта оказываются шумом.
Свои правила: собственные проверки живут в модуле, подключённом через lintChecks(project(":lint-rules")); библиотеки поставляют свои внутри AAR через lintPublish.
Это не проверка стиля: форматированием и code smells в Kotlin занимаются ktlint и detekt; обычно в CI запускают все три.
Один файл classes.dex может ссылаться максимум на 65 536 методов, поэтому большие приложения делятся на classes2.dex, classes3.dex и так далее. При minSdk 21 и выше ART загружает их все сам, так что multidex работает без какой-либо настройки.
Лимит: ссылки на методы в dex-файле используют 16-битный индекс, и считается каждая библиотека. Приложение с Firebase, Compose и парой SDK легко переходит 64K.
Почему это было больно: до Android 5.0 Dalvik загружал только основной dex. Нужны были multiDexEnabled = true, библиотека androidx.multidex и MultiDexApplication, а классы, нужные при старте, должны были оставаться в основном dex, иначе старые телефоны падали с ClassNotFoundException.
Сейчас: D8 и R8 создают столько dex-файлов, сколько нужно, а большинство актуальных библиотек AndroidX и так требуют minSdk 21 или выше. Библиотека multidex осталась в истории.
Что по-прежнему важно: шринкинг в R8 удаляет неиспользуемый код, а это уменьшает число методов, размер APK и загрузку классов при старте.
./gradlew hello выведет 1, 5, 2, 3, а ./gradlew help выведет только 1 и 5. Тело скрипта выполняется в фазе конфигурации при каждой сборке, блок register выполняется только тогда, когда задача нужна, а doLast срабатывает только тогда, когда задача выполняется.
У Gradle три фазы: инициализация (settings.gradle.kts решает, какие проекты существуют), конфигурация (выполняются все build-скрипты и строится граф задач) и выполнение (только запрошенные задачи выполняют свои действия).
1 и 5: код верхнего уровня. Каждый разработчик платит за него при каждом запуске, какую бы задачу он ни запросил.
2: tasks.register ленивый: его блок выполняется, когда hello попадает в граф задач. С tasks.create и 2, и 4 печатались бы при каждой сборке.
4 не печатается никогда: cleanup никому не нужна. ./gradlew tasks её бы напечатал: чтобы вывести список задач, их все нужно создать.
3: doLast относится к фазе выполнения. Настоящая работа должна быть здесь или в @TaskAction.
С включённым configuration cache: повторный ./gradlew hello выведет только 3: граф задач берётся из кеша, а скрипт вообще не выполняется.
git rev-parse в начале build-скрипта становится классической причиной медленной сборки: он выполняется при каждой сборке, даже на help. Его нужно перенести в providers.exec, как в примере.
Они определяют, в какие classpath попадает зависимость. implementation видна только коду этого модуля, api видна ещё и на compile classpath каждого потребителя, compileOnly есть только во время компиляции, а runtimeOnly есть только в собранном приложении.
**implementation: вариант по умолчанию. Потребители получают библиотеку в рантайме, но не могут компилироваться против неё, поэтому изменения внутри неё никогда их не перекомпилируют.
api: нужна, только если типы библиотеки есть в ваших публичных сигнатурах: тип возврата, параметр, супертип. Цена: каждый потребитель, транзитивно, перекомпилируется при изменении ABI этой библиотеки.
compileOnly: аннотации, которые исчезают после компиляции, или API, которое предоставляет среда выполнения. В пакет не попадает.
runtimeOnly: то, на что ваш код напрямую не ссылается, например бэкенд логирования.
Ограниченные варианты**: debugImplementation(libs.leakcanary) добавляет библиотеку только в debug-сборки; testImplementation и androidTestImplementation относятся к classpath тестов.
Классическая ошибка: :app падает с «Cannot access class 'okhttp3.OkHttpClient'», потому что :core:network возвращает этот тип из публичной функции, а OkHttp подключён как implementation. Замена на api починит компиляцию, но часто лучше перестать отдавать этот тип наружу и возвращать свой.
Version catalog (gradle/libs.versions.toml) хранит общий список библиотек, плагинов и версий с типобезопасными аксессорами вроде libs.retrofit. Он только объявляет версии; BOM, подключённый через platform(...), действительно выравнивает версии в итоговом графе, включая транзитивные.
Что даёт каталог: одно место для обновления версии, никаких строк с версиями в скриптах модулей, [bundles] для групп и [plugins] для alias(libs.plugins...). Дефисы превращаются в точки: androidx-core-ktx становится libs.androidx.core.ktx.
Чего он не делает: не мешает транзитивной зависимости притянуть другую версию и ничего не навязывает. Это просто типизированный список констант.
BOM: implementation(platform(libs.compose.bom)) добавляет ограничения версий для целого семейства библиотек, так что ui, material3 и всё, что тянет их транзитивно, получают согласованные версии. Записи каталога для этих библиотек версии не содержат.
В convention-плагинах: сгенерированного аксессора libs внутри классов build-logic нет; каталог читают через VersionCatalogsExtension и findLibrary("retrofit").
Обновления: Renovate и Dependabot понимают TOML-файл, поэтому обновление версии приходит пул-реквестом в одну строку.
Build types описывают, как собирается приложение (debuggable, подпись, минификация), product flavors описывают, какое приложение собирается (free или paid, staging или prod), а build variant получается их комбинацией. Staging получает свой applicationIdSuffix, buildConfigField с URL и ресурсы в src/staging/res.
Ставится рядом с продакшеном: applicationIdSuffix = ".staging" меняет идентификатор приложения на устройстве. namespace, где живут R и BuildConfig, остаётся прежним, так что код не меняется.
Свой URL: buildConfigField("String", "API_URL", ...) для каждого build type; код читает BuildConfig.API_URL, и в нём нет ни одного if. С AGP 8 нужно явно включить buildFeatures.buildConfig = true.
Своя иконка и название: ic_launcher и app_name кладутся в src/staging/res; source set варианта перекрывает те же ресурсы из src/main. Туда же кладут отдельный google-services.json.
Библиотечные модули: у них нет build type staging, поэтому matchingFallbacks += listOf("release") говорит Gradle, какой их вариант брать.
Build type или flavor: build type достаточно, когда staging значит «release, но для QA». Измерение flavor (env с dev, staging, prod) нужно, когда staging требуется и debuggable, и минифицированным, ценой роста числа вариантов: три flavors env на два build types дают шесть.
Двенадцать: 2 tier × 2 store × 3 build types, у каждого свой набор задач. freePlayDebug использует src/freePlayDebug, src/debug, src/freePlay, src/free, src/play и src/main, от высшего приоритета к низшему.
Ресурсы и манифесты сливаются: строка в src/freePlay/res перекрывает такую же в src/free, а та, в свою очередь, перекрывает строку в src/main. Между одиночными flavors побеждает измерение, указанное в flavorDimensions первым.
Классы не перекрываются: один и тот же класс в src/main и src/free даёт ошибку duplicate class. Одна реализация Billing кладётся в src/free, другая кладётся в src/paid, а в main её нет.
У каждого варианта есть цена: время конфигурации и синхронизации IDE растёт с числом вариантов, а CI-джоба, собирающая все, умножает своё время.
Уберите то, что не выпускается: androidComponents.beforeVariants с enable = false убирает комбинацию до того, как созданы её задачи.
Выбор варианта: Android Studio собирает один вариант за раз (панель Build Variants); в командной строке это делает ./gradlew assembleFreePlayDebug.
Исходники компилируются в .class, D8 (или R8 при isMinifyEnabled = true) делает из них classes.dex, AAPT2 компилирует ресурсы и объединённый манифест, а результат пакуется в zip, выравнивается и подписывается.
Компиляция: сначала работают annotation processors (KSP, kapt), затем kotlinc и javac выдают байткод. Библиотеки приходят как .jar и .aar.
Dex: D8 превращает байткод в dex и делает desugaring новых возможностей Java; при isMinifyEnabled = true R8 за один проход шринкает, обфусцирует, оптимизирует и делает dex.
Ресурсы: манифесты всех модулей и библиотек объединяются; AAPT2 компилирует res/ в бинарный XML и resources.arsc (таблицу ID ресурса → значение) и генерирует R.
Упаковка и подпись: APK представляет собой zip, выровненный zipalign (.so без сжатия, по границам 16 КБ), который затем подписывает apksigner: v1 (JAR-файлы в META-INF), v2 (весь файл, Android 7+), v3 (ротация ключа, Android 9+), v4 (инкрементальная установка).
Внутри APK: classes*.dex, resources.arsc, res/, бинарный AndroidManifest.xml, lib/<abi>/, assets/, META-INF/; подпись v2+ лежит в signing block.
AAB: bundleRelease останавливается до финального APK: сохраняет скомпилированные части и таблицы ресурсов в формате для публикации, а Play нарезает APK под устройства.
kapt запускает Java annotation processors, сначала генерируя Java-заглушки (stubs) для каждого Kotlin-файла и компилируя их javac; KSP работает как Kotlin-API, которое читает символы компилятора напрямую. Без заглушек обработка аннотаций выходит примерно до двух раз быстрее.
Почему kapt медленный: kaptGenerateStubs перезапускается почти при любом изменении Kotlin-кода в модуле, а заглушки теряют информацию, которая есть только в Kotlin: функции-расширения, typealias, видимость internal, аргументы по умолчанию.
KSP: процессоры видят настоящие Kotlin-типы и могут работать инкрементально. Room, Hilt и Dagger, Moshi и Glide поставляют KSP-процессоры, а Room умеет генерировать Kotlin вместо Java.
Миграция: подключить плагин com.google.devtools.ksp, заменить kapt(...) на ksp(...), перенести kapt { arguments { } } в ksp { arg(...) }. Версия KSP должна быть совместима с версией Kotlin.
Всё или ничего в пределах модуля: один оставшийся kapt-процессор (data binding всё ещё требует kapt) возвращает шаг генерации заглушек для всего модуля, и большая часть выигрыша теряется.
Статус: kapt в режиме поддержки. annotationProcessor остаётся обычной Java-конфигурацией для модулей без Kotlin.
Когда два пути в графе запрашивают разные версии одного модуля, Gradle выбирает самую высокую, в отличие от правила nearest-wins в Maven. «Duplicate class» означает другую проблему: один и тот же класс есть в двух разных модулях, поэтому конфликта версий, который Gradle мог бы разрешить, нет.
Сначала диагностика: ./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency okhttp показывает, кто какую версию запросил и почему победила одна; dependencies печатает всё дерево, build scan показывает то же в браузере.
Классический дубликат: kotlin-stdlib и старый kotlin-stdlib-jdk8. С Kotlin 1.8 эти классы живут в kotlin-stdlib, поэтому старый транзитивный -jdk8 их дублирует. Помогает выравнивание всего на одну версию Kotlin, например через kotlin-bom.
Выравнивайте семейства: platform(libs.okhttp.bom) держит okhttp, logging-interceptor и mockwebserver на одной версии.
Понижайте осознанно: strictly("3.9.1") с because(...) в блоке constraints. force тоже работает, но скрывает, кто что хотел.
**exclude: для стороннего SDK, который тянет свою копию; крайняя мера, потому что совместимость теперь на вас.
failOnVersionConflict()**: превращает каждое тихое повышение версии в ошибку сборки, для команд, которым нужно явное решение по каждому конфликту.
Toolchain закрепляет JDK, которым компилируется код, независимо от того, какой JDK запускает Gradle. Ошибка значит, что Java (compileOptions) и Kotlin (jvmTarget) в одном модуле нацелены на разные версии байткода; нужно задать им одно значение, лучше один раз в convention-плагине.
Toolchain: kotlin { jvmToolchain(17) } компилирует JDK 17, даже если JAVA_HOME указывает на 21; плагин foojay resolver в settings.gradle.kts скачивает недостающий JDK. JDK, на котором работает сам Gradle, настраивается отдельно (Gradle 9 нужен 17 или новее).
Ошибка: Kotlin по умолчанию берёт jvmTarget из версии toolchain, а в compileOptions может остаться 11. Kotlin отказывается их смешивать, потому что в модуле оказались бы классы для двух разных уровней JVM.
Что значит target в Android: уровень байткода, который принимает D8, а не уровень API устройства (за него отвечает minSdk). D8 делает desugaring языковых возможностей Java для старых устройств; библиотечным API Java вроде java.time на старых устройствах нужен ещё isCoreLibraryDesugaringEnabled = true.
Одно место: задавайте его в convention-плагине, чтобы сорок модулей не разъехались.
buildSrc лежит на classpath каждого build-скрипта, поэтому любое изменение в нём делает устаревшими все скомпилированные скрипты и вызывает полную переконфигурацию. Included build вроде build-logic публикует плагины по id, и затронуты только модули, применяющие изменённый плагин.
buildSrc
Gradle собирает buildSrc раньше всего остального и кладёт результат на classpath каждого build.gradle.kts. Строка includeBuild не нужна, и именно поэтому каждое изменение ощущается везде.
Современный Gradle обращается с ним почти как с included build (он участвует в build cache и configuration cache), но связь через classpath не изменилась.
Это один проект с одним classpath: Android convention-плагин и мелкий хелпер для CI делят зависимости и делят инвалидацию.
Included build
pluginManagement { includeBuild("build-logic") } делает build-logic отдельной сборкой со своими settings, зависимостями и тестами. Её можно разбить на несколько проектов.
Convention-плагин оформляют как класс, реализующий Plugin<Project>, или precompiled script вроде myapp.android.library.gradle.kts, где имя файла становится id плагина.
AGP и Kotlin-плагин там объявляются как compileOnly: build-logic компилируется против них, а работает та копия, которую резолвит основная сборка. С implementation на одном classpath могут оказаться две версии AGP.
Плагин, применённый в каждом модуле, при изменении всё равно переконфигурирует все модули; выигрыш дают разбиение build-logic на несколько проектов и то, что несвязанные хелперы больше не инвалидируют всё подряд.
Цель
Build-файл feature-модуля сводится к plugins { id("myapp.android.feature") } и блоку dependencies, а уровни SDK, настройка Compose и подключение тестов описаны один раз. Переходить на build-logic стоит, как только логика сборки выросла больше пары файлов.
Задача не объявляет ни входов, ни выходов, поэтому Gradle никогда не может счесть её актуальной (up to date), а её действие обращается к project во время выполнения, что configuration cache запрещает. Типизированный класс задачи с объявленными входами и выходами решает обе проблемы.
Почему она всегда перезапускается
Проверка up-to-date сравнивает объявленные входы и выходы с предыдущим запуском. Задача без объявленных выходов никогда не бывает актуальной, и build cache тоже не может её сохранить.
API_URL читается внутри действия, поэтому даже с объявленными выходами Gradle не знал бы, что результат от него зависит, и новое значение было бы проигнорировано.
Почему её отвергает configuration cache
Кеш сохраняет граф задач в конце конфигурации, и действия выполняются уже на этом снимке. Во время выполнения Project нет, поэтому project.layout внутри doLast фиксируется как проблема.
Чтение переменной окружения через providers.environmentVariable к тому же регистрирует её как вход конфигурации, так что новое значение инвалидирует запись в кеше, а не используется молча старое.
Исправление
abstract-класс задачи с @get:Input для значений, @get:InputFile или @get:InputFiles для файлов и @get:OutputDirectory для результатов (на типах Property, RegularFileProperty и DirectoryProperty).
@CacheableTask позволяет переиспользовать результат другим чекаутам и CI; @PathSensitive(PathSensitivity.RELATIVE) добавьте, когда у задачи появятся файловые входы.
Выход подключается к варианту через addGeneratedSourceDirectory. Зависимость между задачами приходит вместе с ним, dependsOn не нужен.
Configuration cache хранит готовый граф задач и пропускает всю фазу конфигурации; build cache хранит выходы задач по ключу из их входов и пропускает сами задачи. Если сборка без изменений всё равно идёт 40 секунд, это время конфигурации, и убирает его только configuration cache.
Что хранит каждый
Configuration cache (org.gradle.configuration-cache=true) хранит граф задач со всеми полями каждой задачи, по ключу из build-скриптов, запрошенных задач и всего, что прочитала конфигурация: переменных окружения, системных свойств, файлов. При попадании Gradle сразу переходит к выполнению. В Gradle 9 это рекомендуемый режим.
Build cache (org.gradle.caching=true) хранит выходы задач по хешу их входов и реализации. При попадании выходы распаковываются вместо запуска задачи; с удалённым узлом ваш ноутбук переиспользует то, что скомпилировал CI.
Они независимы: build cache окупается на свежем чекауте или при смене ветки, а configuration cache окупается на двадцатом запуске одной и той же команды.
Что ломает configuration cache
Действие задачи, использующее Task.project или другие живые объекты сборки; значения нужно захватывать в поля Property во время конфигурации.
Запуск процесса во время конфигурации (ProcessBuilder, Runtime.exec); вместо него используйте providers.exec. Файлы, прочитанные при конфигурации, считаются входами, и их изменение инвалидирует запись.
Плагин, который ещё не совместим. HTML-отчёт в build/reports/configuration-cache называет плагин и строку; org.gradle.configuration-cache.problems=warn позволяет мигрировать постепенно.
Почему промахивается удалённый build cache
Абсолютные пути во входах: файловые входы без @PathSensitive(PathSensitivity.RELATIVE) хешируются по-разному в каждом чекауте.
Разный JDK на CI и на ноутбуках; toolchain считается входом каждой задачи компиляции.
Недетерминированные выходы, например timestamp в сгенерированном файле, который меняет ключи всех задач ниже по графу.
Задачи, которые вообще не кешируются. Сравните две сборки в build scan или запустите с -Dorg.gradle.caching.debug=true, чтобы увидеть хеш каждого входа.
Обычная схема: CI пишет в удалённый кеш, разработчики только читают.
Сначала измерить, потом менять: build scan или Build Analyzer в Android Studio на реалистичной инкрементальной сборке покажут, куда уходит время (на конфигурацию, на несколько медленных задач или на длинную цепочку модулей, ждущих друг друга). Для каждого случая своё решение.
Измерение
Замеряйте ту сборку, которую реально запускают разработчики: поменяйте одну строку в core-модуле и запустите :app:assembleDebug. Чистая сборка прячет большинство проблем.
--scan показывает время конфигурации по плагинам, таймлайн задач, попадания и промахи кеша; Gradle Profiler повторяет сценарий, чтобы надёжно сравнивать изменения.
Дешёвые улучшения
gradle.properties: org.gradle.caching, org.gradle.configuration-cache, org.gradle.parallel и достаточно памяти демону (org.gradle.jvmargs), чтобы сборка не тонула в сборке мусора.
Переход с kapt на KSP; актуальные версии Gradle, AGP и Kotlin, в которых обычно есть исправления скорости сборки.
Никакого R8 в debug-сборках и только те buildFeatures, которые модуль использует.
Никаких динамических версий: 1.+ заставляет ходить в сеть и меняет ключи кеша.
Ленивые API задач (register, named) и никакой работы на верхнем уровне build-скриптов.
Граф модулей
Параллельное выполнение помогает только там, где модули не зависят друг от друга. Глубокая цепочка (:feature → :domain → :data → :core) образует критический путь, который ничем не распараллелить.
api там, где хватило бы implementation, и один большой :core, который используют все, приводят к тому, что изменение одной строки перекомпилирует большинство модулей.
Маленькие стабильные API-модули между фичами делают граф широким и неглубоким: больше модулей компилируется одновременно и меньше перекомпилируется.
Оно окупается, когда сервис используют многие модули, а его реализация часто меняется: потребители компилируются против маленького стабильного модуля :api, и правка реализации перекомпилирует только :impl и :app. Для маленьких или редко меняющихся модулей это лишь удваивает число модулей.
Что даёт разделение
Меньше перекомпиляций: фичи зависят от :core:cart:api (интерфейсы и модели). Изменение внутри :core:cart:impl не меняет ABI API-модуля, поэтому ни одна фича не перекомпилируется.
Параллельность: фичи больше не ждут компиляции тяжёлой реализации (Retrofit, Room, кодогенерация); им нужен только маленький API-модуль.
Границы, которые проверяет компилятор: фичи не могут дотянуться до DAO Room или DTO, потому что их нет на classpath. Компилятор следит за тем, за чем раньше следило код-ревью.
Тесты и замена: фичи тестируются против фейков интерфейса, а замена реализации сводится к изменению одной строки в :app.
Когда это лишний бойлерплейт
Интерфейс меняется так же часто, как реализация, и потребители всё равно перекомпилируются.
Сервис использует только один модуль.
Модуль маленький и стабильный; лишний модуль добавляет время конфигурации, синхронизации IDE и шаблонный код (интерфейс, DI-биндинг) без измеримого выигрыша.
Кто-то добавляет :impl в зависимости фичи «ради одного класса», и выигрыш пропадает. Это ловит проверка графа модулей в CI.
Как это сочетается с DI
От всех :impl зависит только :app; Hilt-модули с @Binds живут внутри :impl и собираются при компиляции :app. Замерьте build scan до и после: в результате на типичном изменении должно перекомпилироваться меньше модулей.
Запретить плавающие версии, зафиксировать итоговый граф и проверять контрольные суммы артефактов. Тогда изменение зависимости может прийти только через проверенный пул-реквест с диффом lock-файла, а не как побочный эффект сборки в другой день.
Уберите плавающие версии
Динамические версии (2.+, latest.release) и -SNAPSHOT со временем резолвятся в разные артефакты. Диапазон, записанный в libs.versions.toml, ничуть не безопаснее.
resolutionStrategy.failOnNonReproducibleResolution() роняет сборку на любом из них.
Фиксированных версий мало: POM стороннего пакета может объявлять диапазон или + для своей зависимости, а переопубликованный артефакт сохраняет версию. Итоговый граф тогда меняется от дня ко дню, и поймать это может только lock-файл.
Зафиксируйте и проверяйте
Dependency locking: dependencyLocking { lockAllConfigurations() } и ./gradlew dependencies --write-locks пишут gradle.lockfile с точным итоговым графом, включая транзитивные зависимости. Другой результат роняет сборку, пока lock-файл не перегенерируют в пул-реквесте.
Verification metadata: ./gradlew --write-verification-metadata sha256 help создаёт gradle/verification-metadata.xml; после этого Gradle отвергает любой артефакт с изменившейся контрольной суммой. Это часть проблемы, связанная с supply chain.
Фильтрация репозиториев: exclusiveContent или content { includeGroup(...) } заставляют внутренние группы резолвиться только из внутреннего репозитория, чтобы публичный пакет с тем же именем не мог их подменить.
Сделайте изменения проверяемыми
Повышения версий открывают Renovate или Dependabot; ревьюеры смотрят на дифф lock-файла и контрольных сумм.
Закрепите и инструменты: Gradle wrapper с distributionSha256Sum, JDK через toolchain, версии AGP и Kotlin в каталоге.
Кроме подключения maven-publish: опубликовать явный вариант, чтобы потребители получили правильный артефакт и метаданные, положить в AAR правила R8 для потребителей и защитить публичный API, чтобы узнать о ломающем изменении раньше потребителей.
Публикация варианта
AGP не создаёт публикуемый компонент, пока его не попросить: android { publishing { singleVariant("release") { withSourcesJar() } } }, затем MavenPublication с from(components["release"]).
Gradle Module Metadata (файл .module рядом с POM) переносит атрибуты варианта, такие как build type и flavors, которые POM выразить не может.
Версионируйте по semantic versioning и никогда не публикуйте ту же версию повторно: потребители и кеши считают версию неизменяемой.
Что едет внутри AAR
consumerProguardFiles("consumer-rules.pro") упаковывает ваши keep-правила, и R8 потребителя применяет их автоматически. Без этого потребители узнают о вашей рефлексии только в своих release-сборках.
Кладите только правила, нужные вашему коду. -dontobfuscate или широкий -keep class ** в библиотеке портят размер каждого приложения, которое её использует.
Ваш манифест сливается с их манифестом: разрешения, записи <provider> и <queries> становятся частью вашего публичного API.
Не ломайте потребителей
Binary compatibility validator коммитит файл .api; apiCheck роняет пул-реквест, который меняет публичную сигнатуру. В Kotlin 2.2 в Kotlin Gradle plugin появился похожий экспериментальный блок abiValidation.
Помечайте internal всё, что вы не собирались открывать. Добавление параметра со значением по умолчанию совместимо по исходникам, но не бинарно: уже скомпилированные потребители вызывают метод, которого больше нет.
Держите транзитивные зависимости на implementation, если их типов нет в вашем API, чтобы потребители не получали версии ваших библиотек на свой compile classpath.
Стрелка разворачивается: dynamic feature зависит от :app, а не наоборот, поэтому база не может импортировать ни один класс фичи. Она устанавливает модуль через SplitInstallManager, а затем открывает экран по имени класса или через интент.
В сборке
:app перечисляет dynamicFeatures += setOf(":feature:scan"); фича применяет com.android.dynamic-feature и зависит от project(":app").
У dynamic feature нет своих build types и flavors; она наследует варианты, подпись и версионирование базы. R8 запускается один раз на базу вместе с фичами.
Способ доставки задаётся в манифесте фичи: <dist:on-demand/> или install-time с условиями вроде минимального SDK или страны.
Релизная доставка идёт через app bundle (bundleRelease); при разработке Android Studio сама ставит split-модули, а release-бандл локально ставится через bundletool.
В рантайме
SplitInstallManager из com.google.android.play:feature-delivery запрашивает модуль. Это UI-флоу, а не вызов функции: нужно обработать загрузку, REQUIRES_USER_CONFIRMATION для больших модулей, INSTALLED и ошибки.
В активностях фичи вызывайте SplitCompat.installActivity (или используйте SplitCompatApplication), иначе ресурсы нового модуля не видны до перезапуска приложения.
Общий код между базой и фичей идёт через интерфейсы, объявленные в базе, реализованные в фиче и найденные через рефлексию или ServiceLoader.
Ловушка при тестировании
Android Studio при разработке ставит все split-модули сразу, поэтому on-demand-запрос мгновенно «успешен», и вы никогда не видите загрузку, подтверждение или ошибку. Проверяйте их через FakeSplitInstallManager и через internal test track в Play.