Рано или поздно каждый 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 содержит список заказчиков, а dimension store — магазины (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. Приведение типов через as List<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 и они стабильны, не ломайте то, что работает.

Если в вашем проекте есть свой сценарий, который я не упомянул, — поделитесь в комментариях. Особенно интересны случаи, где источник конфигурации нестандартный

Комментарии (0)