
Рано или поздно каждый Android‑разработчик сталкивается с задачей «одно приложение — много сборок»: white‑label‑решения, региональные версии, отдельные сборки для разных магазинов приложений, демо для клиентов, внутренние окружения.
Встроенный механизм product flavors в Android Gradle Plugin отлично справляется со своей задачей — пока количество вариантов умещается в голове и в паре экранов build.gradle.kts.
Когда же flavors становится много и каждый отличается не только applicationId, но и набором фич, ключами аналитики и доступностью в конкретном магазине, поддерживать всё это руками уже невозможно.
В этой статье я разберу подход, при котором конфигурация flavors строится динамически: список вариантов и их параметры живут вне build.gradle.kts.
Скрипт лишь интерпретирует внешний источник и разворачивает нужные варианты сборки.
Короткое напоминание: product flavor и build variant
Прежде чем нырять в динамику, зафиксируем термины — дальше они будут встречаться на каждом шагу.
Build type определяет базовую конфигурацию сборки: debug, release, иногда staging. Здесь живут настройки оптимизации, ProGuard/R8, подпись.
Product flavor — это версия приложения, которая отличается от других по applicationId, ресурсам, зависимостям, API‑ключам или включённым функциям. Каждый flavor обязан принадлежать какому‑то flavor dimension; если dimension в модуле один, все flavors попадают в него автоматически.
Build variant — декартово произведение всех dimensions и build types.
Если в проекте есть flavors demo и full в dimension mode, и build types debug и release — мы получим четыре варианта: demoDebug, demoRelease, fullDebug, fullRelease.
Важные свойства flavor, которые нам понадобятся:
Каждый flavor может задавать
applicationIdSuffixили полностью переопределятьapplicationIdflavor поддерживает все свойства
defaultConfig— базовые значения задаются вdefaultConfig, а flavor только переопределяет нужное.Dimensions можно объединять. Например, dimension
vendorсодержит список заказчиков, а dimensionstore— магазины (GooglePlay, RuStore, AppGallery). Gradle комбинирует по одному flavor из каждой dimension с каждым build type.
Если vendor содержит 10 заказчиков, store — 3 магазина, а build types два (debug и release), на выходе получаем 60 build variants.
В этот момент статическое описание становится проблематичным и болезненным
Проблемы статического описания
Классический подход выглядит так:
android { flavorDimensions += listOf("vendor", "store") productFlavors { create("vendorA") { dimension = "vendor" applicationId = "com.example.vendora" buildConfigField("String", "API_BASE_URL", "\"https://api.vendora.com\"") buildConfigField("boolean", "FEATURE_ANALYTICS", "true") // ... } create("vendorB") { dimension = "vendor" applicationId = "com.example.vendorb" buildConfigField("String", "API_BASE_URL", "\"https://api.vendorb.com\"") buildConfigField("boolean", "FEATURE_ANALYTICS", "false") // ... } // и так далее на каждого заказчика } }
Болевые точки, которые я встречал в реальных проектах:
Копипаста.
Хранить секреты VCS (надеюсь вы такое не делаете, но на практике, к сожалению, встречал)
Жёсткая связанность. Любое изменение в составе сборок требует правки
build.gradle.ktsи коммита в репозиторий.Конфигурации для разных команд. Часто возникает ситуация что у QA, локального разработчика и CI необходимы свои комбинации фич.
Сложности автоматизации. CI‑пайплайны, которые должны собрать «всё что доступно для RuStore», вынуждены парсить
build.gradle.ktsрегуляркой или хардкодить список вендоров в YAML.
Идея: единый источник правды
Ключевая мысль проста: описание flavors не должно жить внутри build.gradle.kts. Сам скрипт должен быть интерпретатором внешнего списка — прочитал, сгенерировал, подставил значения.
Этот внешний источник может быть чем угодно:
JSON/YAML/properties‑файл в корне проекта;
набор файлов, где каждый описывает один flavor;
удалённый конфиг, подтягиваемый на этапе configuration
комбинация первых двух: список имён flavors в репозитории (чтобы сборка была воспроизводимой), а секреты и фиче‑тоглы — в.
properties‑файлах, закрытых gitignore и распространяемых через защищённый канал.
Последний вариант — самый практичный для большинства проектов.
Разбор ключевых техник
1. Генерация flavors из внешнего списка
[ { "name": "vendorA", "applicationId": "com.example.vendora", "propertiesFile": "flavors/vendorA.properties" }, { "name": "vendorB", "applicationId": "com.example.vendorb", "propertiesFile": "flavors/vendorB.properties" }, { "name": "vendorC", "applicationId": "com.example.vendorc", "propertiesFile": "flavors/vendorC.properties" } ]
Читаем его в build.gradle.kts и генерируем flavors:
import groovy.json.JsonSlurper import java.util.Properties data class FlavorConfig( val name: String, val applicationId: String, val propertiesFile: String ) val flavorsJson = rootProject.file("flavors.json") @Suppress("UNCHECKED_CAST") val flavorsList: List<FlavorConfig> = (JsonSlurper().parse(flavorsJson) as List<Map<String, String>>) .map { FlavorConfig( name = it["name"]!!, applicationId = it["applicationId"]!!, propertiesFile = it["propertiesFile"]!! )} // ... android { flavorDimensions += listOf("vendor", "store") productFlavors { flavorsList.forEach { config -> runCatching { create(config.name) { dimension = "vendor" applicationId = config.applicationId versionNameSuffix = "-${config.name}" } }.onFailure { error -> logger.warn("\u001B[33m⚠ Не удалось создать flavor ${config.name}: ${error.message}\u001B[0m") } } create("GooglePlay") { dimension = "store" } create("RuStore") { dimension = "store" } } }
Несколько важных моментов, которые стоят мне пары отладочных дней в прошлом:
Безопасное чтение.
runCatchingвокругcreateспасает от падения всей конфигурации, если один элемент списка битый. Некорректный flavor не должен ронять сборку для остальных — лучше вывести в лог ошибку и продолжить.Цветной вывод в консоль. ANSI‑коды очень помогают найти проблемы среди сотен строк Gradle‑лога.
Типизация.
data class FlavorConfigвместо работы напрямую сMap<String, Any>экономит часы отладки: вся дальнейшая логика обращается к типизированным полямconfig.name,config.applicationId, и IDE сразу подсвечивает опечатки, а не встречает их в рантайме Gradle. Приведение типов через asList<Map<String, String>>нужно только в одной точке — при парсинге.
2. Подгрузка параметров из.properties‑файлов
Каждому flavor сопоставим файл с параметрами. Например, flavors/vendorA.properties:
API_BASE_URL=https://api.vendora.com ANALYTICS_KEY=AIza... FEATURE_ANALYTICS=true FEATURE_PAYMENTS=false FEATURE_CHAT=true
Для чтения properties‑файла можно сделать свою хелпер‑функцию что читает его и пробрасывает значения в buildConfigField:
import java.util.Properties fun com.android.build.api.dsl.ProductFlavor.applyKeys(propertiesFile: File) { val props = Properties().apply { if (propertiesFile.exists()) { propertiesFile.inputStream().use { load(it) } } else { logger.warn("Файл ${propertiesFile.path} не найден, используются значения по умолчанию") } } // Строковые ключи listOf("API_BASE_URL", "ANALYTICS_KEY").forEach { key -> val value = props.getProperty(key, "") buildConfigField("String", key, "\"$value\"") } // Булевы фиче-тоглы listOf("FEATURE_ANALYTICS", "FEATURE_PAYMENTS", "FEATURE_CHAT").forEach { key -> val value = props.getProperty(key, "false").toBoolean() buildConfigField("boolean", key, value.toString()) } }
И подключаем его в цикле:
productFlavors { flavorsList.forEach { config -> create(config.name) { dimension = "vendor" applicationId = config.applicationId applyKeys(rootProject.file(config.propertiesFile)) } } }
Что это даёт:
Фиче‑тоглы на уровне сборки
Никакой магии. Обычные константы, которые прекрасно понимает статический анализатор.
Секреты вне VCS
Безопасные значения по умолчанию
3. Несколько измерений и их комбинирование
Следующий мой вызов был в том что необходимо было делать сборки для отдельных сторов. вроде звучит просто — это делается через несколько dimensions:
android { flavorDimensions += listOf("vendor", "store") productFlavors { flavorsList.forEach { config -> create(config.name) { dimension = "vendor" // ... } } create("GooglePlay") { dimension = "store" buildConfigField("String", "STORE", "\"google_play\"") } create("RuStore") { dimension = "store" buildConfigField("String", "STORE", "\"rustore\"") } create("AppGallery") { dimension = "store" buildConfigField("String", "STORE", "\"appgallery\"") } } }
Но реальность такова, что не каждый заказчик выкладывается во все магазины. При 10 вендорах, 3 магазинах и 2 build types Gradle сгенерирует 60 вариантов — и в папке артефактов CI будет лежать ровно столько APK.
И вот здесь рождается отдельный класс багов. Тестировщик не должен думать, нужна ли вообще версия для AppGallery, которая автоматически собралась на CI для вендоров 1, 2, 5 и 7. Менеджер не должен писать разработчику «а почему у нас в релиз‑ноутах сборка для магазина, в который мы не публикуемся». QA‑команда тратит время на прогон чек‑листа по сборкам, которые никто никогда не увидит. А худший сценарий — кто‑то по ошибке берёт такой «фантомный» APK и отправляет его заказчику или в стор.
Решение напрашивается: невозможные комбинации должны отсутствовать в списке вариантов физически, а не лежать рядом с настоящими и ждать, пока кто‑нибудь разберётся. И это подводит нас к следующей технике.
4. Условное отключение вариантов через androidComponents
Современный API androidComponents.beforeVariants позволяет влиять на variant до его создания. Это гораздо эффективнее, чем variantFilter в устаревшем API: отключённый вариант не попадает в граф задач, не занимает память и не увеличивает время Gradle Sync.
Допустим, vendorA не публикуется в RuStore, а vendorB — только в RuStore. Плюс добавим флаг enabled, чтобы можно было быстро выключить весь flavor целиком — например, пока заказчик приостановил релизы или их backend недоступен:
# flavors/vendorA.properties ENABLED=true STORES=GooglePlay,AppGallery # flavors/vendorB.properties ENABLED=true STORES=RuStore # flavors/vendorC.properties ENABLED=false STORES=GooglePlay
androidComponents { beforeVariants { builder -> val vendorName = builder.productFlavors .firstOrNull { it.first == "vendor" }?.second ?: return@beforeVariants val storeName = builder.productFlavors .firstOrNull { it.first == "store" }?.second ?: return@beforeVariants val vendorConfig = flavorsList.firstOrNull { it.name == vendorName } ?: return@beforeVariants val propsFile = rootProject.file(vendorConfig.propertiesFile) val props = Properties().apply { if (propsFile.exists()) propsFile.inputStream().use { load(it) } } // Vendor временно отключён целиком if (!props.getProperty("ENABLED", "true").toBoolean()) { logger.lifecycle("⊘ Отключён vendor: $vendorName (ENABLED=false)") builder.enable = false return@beforeVariants } // Vendor не публикуется в этом магазине val allowedStores = props.getProperty("STORES", "").split(",").map { it.trim() } if (storeName !in allowedStores) { logger.lifecycle("⊘ Отключён вариант: ${vendorName}${storeName} (магазин не в списке)") builder.enable = false } } }
Ключевые выводы:
Фаза configuration. В этот момент variant ещё не собран, его отключение проходит безболезненно.
Декларативная фильтрация. Правило «этот flavor идёт только в эти магазины» записано один раз и работает для всех новых заказчиков автоматически.
Логируйте отключения. Иначе разработчик, не увидевший ожидаемый variant в списке, потратит полдня на поиски.
5. Связывание flavors с отдельными модулями ресурсов
Ещё один мощный приём — каждый flavor подтягивает свой модуль ресурсов или темы. Для этого в Android Gradle Plugin есть специальные суффиксы конфигураций:
// app/build.gradle.kts dependencies { flavorsList.forEach { config -> // Префикс "vendorA" + Implementation → зависимость только для flavor vendorA "${config.name}Implementation"(project(":res:${config.name}")) } }
Что это даёт:
Иерархия модулей:
:res:vendorA,:res:vendorB— полностью изолированные модули ресурсов.Изоляция: ресурсы одного клиента физически не попадают в APK другого.
Темы: строки, иконки, цвета, drawables — всё своё.
Масштабирование: чтобы добавить нового заказчика требуется создать модуль
:res:vendorX, одна запись вflavors.json. Вносить изменения вapp/build.gradle.ktsне нужно.
Сценарии использования
Подход работает шире, чем может показаться на первый взгляд. Живые сценарии из моей практики:
White‑label. Один код, десятки ребрендированных версий для разных заказчиков. Каждый vendor — отдельный flavor с уникальным набором фич, иконок и идентификаторов аналитики.
Региональные сборки. Одно приложение под разные страны с разными правовыми требованиями (GDPR vs LGPD vs 152-ФЗ), платёжными шлюзами, языками и даже функциональностью (в одном регионе есть криптокошелёк, в другом — запрещён).
Разные магазины приложений. У Google Play, RuStore, AppGallery свой биллинг, свои запрещённые библиотеки (GMS vs HMS), свои требования к аналитике. Второе измерение flavor решает эту задачу чисто.
Внутренние окружения.
dev/stage/prodкак flavors с разными API base URL, уровнями логирования, фиче‑тоглами. В паре с external properties dev‑ключи разработчика никогда не попадают в production‑сборку.A/B‑тесты на уровне сборки. Редкий кейс, но вполне возможен.
Корпоративные сборки. Внутренняя версия с дополнительными политиками безопасности, MDM‑интеграцией, отличающимся
applicationIdи подписью — как отдельный flavor, не мешающий публичной сборке.Модульная архитектура с опциональными фичами. Flavors определяют, какие feature‑модули подключаются. Заказчику X нужны модули «маркет» и «аналитика», заказчику Y — только «каталог».
Быстрое отключение проблемного flavor. Заказчик приостановил релизы на время, и сборка исчезает из CI. На локальной машине разработчика всё по‑прежнему собирается, если нужно.
Подводные камни
Теперь честно о том, за что заплачено временем:
Время configuration‑фазы. Каждый
readValue, чтение.properties, рефлексия в блокеproductFlavorsвыполняются при каждом Gradle Sync (ни в коем случае не делайте в ней сетевых запросов)Configuration Cache. Включённый
--configuration-cacheтребует, чтобы все зависимости (файлы, переменные окружения) были объявлены явно черезproviders.fileContents()илиproviders.environmentVariable(). Чтение через обычныйFile.readText()формально работает, но ломает инкрементальную сборку и выдаёт предупреждения.IDE hints и автодополнение. Android Studio может не всегда корректно определять динамически созданные flavors, часто
Invalidate Cachesрешает проблему (в последних версиях уже не встречаю такую проблему)Локализация ошибок. Если один
.propertiesбитый, сообщение Gradle не подскажет, какой именно, поэтому обязательно логируйте имя файла рядом с каждой операцией это спасёт часы отладки и облегчит вам жизньБезопасность.
.propertiesс ключами НЕ ДОЛЖНЫ лежать в репозиторииДисциплина именования. Имя flavor участвует в сотне мест: имя задачи (
assembleVendorADebug), имя конфигурации (vendorAImplementation), путь к модулю (:res:vendorA), имя variant в фильтрах. Малейшая разница в регистре — и всё разваливается. Нормализуйте имена в одном месте (config.name.lowercase()или валидация на этапе чтения JSON).Не переусердствуйте. Если у вас 2–3 flavors и они стабильны, статический подход по‑прежнему проще и понятнее.
Вместо вывода
Никакой магии в динамических flavors нет. Это просто отделение данных от логики — ровно то, что мы делаем в коде каждый день, когда выносим конфиг в отдельный файл вместо хардкода.
И да — если у вас три flavor и они стабильны, не ломайте то, что работает.
Если в вашем проекте есть свой сценарий, который я не упомянул, — поделитесь в комментариях. Особенно интересны случаи, где источник конфигурации нестандартный