惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
Google Developers Blog
人人都是产品经理
人人都是产品经理
腾讯CDC
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
WordPress大学
WordPress大学
S
SegmentFault 最新的问题
小众软件
小众软件
B
Blog
博客园 - 叶小钗
Microsoft Azure Blog
Microsoft Azure Blog
Apple Machine Learning Research
Apple Machine Learning Research
A
About on SuperTechFans
J
Java Code Geeks
Blog — PlanetScale
Blog — PlanetScale
博客园 - 司徒正美
博客园 - 【当耐特】
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Recent Announcements
Recent Announcements
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
Hugging Face - Blog
Hugging Face - Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
V
V2EX

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Динамические product flavors в Android: когда статической...
PALiarMo · 2026-04-28 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение10 мин

Охват и читатели24

Туториал

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

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