android
April 29

Динамические 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 содержит список заказчиков, а 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 и они стабильны, не ломайте то, что работает.

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