Динамические product flavors в Android: когда статической конфигурации уже мало
Рано или поздно каждый 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или полностью переопределятьapplicationId - flavor поддерживает все свойства
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 и они стабильны, не ломайте то, что работает.
Если в вашем проекте есть свой сценарий, который я не упомянул, — поделитесь в комментариях. Особенно интересны случаи, где источник конфигурации нестандартный