
Если вы хоть раз работали с крупными KMP проектами, то наверняка хотели обратиться к старым добрым гредл флейворам, которые всем так хорошо знакомы по андроид разработке.
Но вот так незадача: спустя 5 минут поисков до вас доходит осознание:Gradle Flavor на KMP нет и не планируется, а вместо них нам достается expect/actual, разбивающий код по платформенным сорц сетам.
Вот так мы и остались без столь удобного механизма, позволяющего комбинировать кучу разных функциональностей в проекте, не смешивая при этом все в одной кодовой базе.
Ликбез для тех, кто не знаком c инструментом:
Gradle flavors (Build variants) - механизм сборщика проектов gradle, позволяющий разбивать кодовую базу на отдельные части по каким-либо параметрам, заданным при конфигурации проекта.
Разделить можно по полной/триал версии, региону, месту поставки сборки или магазину приложений (отдельная сборка для Huawei) и многое-многое другое.
Неиспользуемый код исключается из сборки и лежит в неподключенной папке.
Содержание
Постановка проблемы
На самом деле, с первого взгляда это небольшая проблема - нельзя сделать вариацию сборки и добавить в нее специфичный код или ресурсы.
«Хорошо, начнем все писать в одной кодовой базе, но разделять по файлам», - подумает большинство разработчиков.
И они будут правы, но к такому решению есть ряд вопросов:
Как отличить сборки?
Как делить ресурсы?
Как конфигурировать сборку? (appId, нейминг и т.п.)
Как быть со сложным функционалом?
Если первую часть вопросов можно как-то решить, а со второй просто смириться, то со временем из-за этого собирается целый ком проблем, который катится дальше с вашим проектом.
Варианты решений
На просторах гугла/ии-чатов можно достаточно быстро найти несколько вариантов, каждый из которых в той или иной мере решают поставленную проблему:
Native + BuildKonfig
Пользовательские плагины
Кратко разберу каждый из этих вариантов и выделю их преимущества и недостатки, чтобы можно было сделать правильный и осознанный выбор.
Native
Самый простой вариант - всегда на поверхности.
Можно выделить флейворы на уровне андроид модулей и сконфигурировать в андроид коде что возможно: разделить окружения, задать константы или манифесты.
Плюсы | Минусы |
Есть весь функционал из коробки | Нужна экспертиза в нативном решении |
Много документации по нативной реализации | Не работает с KMP |
В каких-то случаях это подойдет, но если мы имеем повсеместную работу в коде с конфигурацией варианта, то весь KMP код останется без механизмов работы с ней.
iOS же становится отдельной историей.
BuildKonfig
Один из самых простых и «правильных» вариантов, который часто используется в связке с нативной реализацией.
В отдельности же от флейворов плагин несет в себе достаточно ограниченный функционал, который подойдет только в случае простого приложения или совсем небольших требований к инструменту.
Плюсы | Минусы |
Прост в реализации | Не позволяет писать логику |
Легкая настройка окружения через конфиг | Не работает с зависимостями |
Работает с KMP |
Возможно, один из самых оптимальных вариантов, но без дополнительной надстройки не решает большую часть поставленных задач.
Native + BuildKonfig
Хороший «комбинированный» вариант, который можно сделать совместно с BuildKonfig и нативными флейворами (или руками через multi app или инициализацию Application).
Они отлично дополняют друг друга и могут закрыть большую часть потребностей в моменте.
При инициализации нативного приложения X такое решение создает соответствующую конфигурацию и передает ее в KMP код, где дальше разработчики с помощью каких-либо флагов разделяют логику работы приложения.
Это позволяет разделить часть зависимостей, логики и при этом распространить конфигурацию на KMP модули без танцев с бубном.
Плюсы | Минусы |
Есть весь функционал из коробки | Нужна экспертиза в нативном решении |
Работает с KMP | Связь с KMP косвенная |
Наверное, самый «велосипедный» вариант: содержит много подводных камней из-за нативных склеек, но имеет место быть, поскольку дает большую часть функционала.
Плагины
При большом желании в открытом доступе можно найти плагины, которые всё сделают за вас, однако на момент написания статьи пока нет ни одного стабильного и одобренного сообществом решения, поэтому любой выбор будет полностью на вашей совести (и совести автора плагина).
Плюсы | Минусы |
Есть весь функционал из коробки* | Большой риск использования непроверенных инструментов |
Быстрое внедрение | Возможные баги и отсутствие поддержки в будущем |
Работает с KMP |
*А может и не весь. Зависит от автора
Этот вариант я отсек сразу - большие риски для технической команды и бизнеса в целом.
Держим кулачки за какой-то крутой плагин в будущем, который избавит нас от такой, казалось бы, уже решенной проблемы.
SourceSet
Последний вариант, который удалось откопать в сети - создание собственного аналога flavors на основе базовой работы с convention plugin’ами, properties и немалым терпением.
Тот самый вариант, который всегда находился на поверхности, но до него никогда не доходили руки: боялся что займет колоссальное количество времени на реализацию и в итоге не получится.
Однако в (относительно) свежей статье был вскользь разобран этот вариант и выглядел крайне легко и логично.
Плюсы | Минусы |
Можно реализовать весь необходимый функционал | Необходимо реализовывать с нуля |
Есть возможность менять функционал под особенности проекта | Много подводных камней. |
Работает с KMP |
Именно на этом варианте я решил остановиться - несмотря на все его недостатки, он является самым гибким и функциональным решением проблемы.
Какой вариант выбрали мы?
Как и написано выше, после некоторых раздумий для моего проекта был выбран самый сложный, но крайне гибкий вариант с кастомным source sets.
Давайте разбираться, почему именно он.
Сначала выделим задачи, которые стояли перед командой:
Иметь возможность разделять ресурсы в сборках (исключать их из итогового архива).
Иметь разные манифесты для приложений.
Разделять логику (UI и остальное) и выключать ее из сборок.
Иметь разную конфигурацию (цвета, иконки, хост, язык).
Разделять зависимости по сборкам (аналогично флейворам).
Отмечу, что реализация писалась не с абсолютного нуля, а с промежуточного «временного» варианта BuildKonfig + Native вместе с ручной конфигурацией через инициализация Application.
Поэтому некоторые дальнейшие решения принимались с оглядкой на текущую реализацию и минимизацией изменений/рисков.
В итоге сравнения получилась таблица с ключевыми (для меня!) параметрами:
Native | BuildKonfig | Native + BuildKonfig | Plugin | SourceSet | |
KMP | Нет | Да | Да | Да | Да |
Сложность | Средняя | Легкая | Сложная | Легкая | Сложная |
Нужные функции | Нет | Нет | Да | Да | Да |
Риск | Нет | Нет | 50/50 | Да | 50/50 |
Реализация
Для закрытия основных потребностей, которые я перечислил выше, нужно, на удивление, не так много кода.
Многое упирается в уже существующую организацию ваших модулей и наличие утилит, упрощающих многомодульность (convention plugins / buildSrc).
В моем случае в проекте уже существовал buildSrc с некоторой логикой по уменьшению копипаста во всех модулях (Android и KMP), поэтому примем эту условность и будем отталкиваться от нее.
Если в вашем многомодульном проекте до сих пор нет таких инструментов, то настоятельно рекомендую сперва реализовать их, а только потом использовать код из этой статьи.
Но в любом случае код будет работать даже для монолита.
Итак, давайте двигаться по шагам.
Откуда мы получаем переменные сборки
Сначала нужно определиться что же для нас будет тем самым сигналом о том, что это сборка X, а не Y - это может быть что угодно, даже какая-то вычисляемая переменная.
Но, так как нам нужно легко и понятно переключаться между вариантами сборки, я остановился на gradle properties, а точнее local.properties моего проекта, который каждый может индивидуально настроить под свои нужды в процессе работы.
Для примера, разделим сборки по принципу страны, в которую мы хотим их поставлять. Допустим, что на рынок US мы хотим видеть кнопку X, а на EU кнопку Y.
Введем в наши local.properties переменную REGION.
sdk.dir=... REGION=us # other vars
И найдем утилиту по получению этих пропертей в самом коде gradle (или buildSrc) через собственную или библиотечную (jetbrains) реализацию:
val region = project.gelLocalPropery("REGION")
При желании также можно заменить проперти на System.env для работы с CI.
val region = project.gelLocalPropery("REGION") ?: System.getenv("REGION")
Поздравляю, мы сделали первый шаг! Теперь у нас есть доступ в коде к параметрам нашего варианта.
Объявление вариантов
Объявим все возможные варианты регионов.
enum class ApplicationRegion( val region: String ) { UNKNOWN("?"), US("us"), EU("eu"), RU("ru"); companion object { operator fun get(region: String) = entries.find { it.region == region } ?: UNKNOWN } }
Теперь создадим App variant, который будем совмещать в себе все возможные переменные сборки (в нашем случае только region):
enum class ApplicationVariant( val region: ApplicationRegion, val applicationId: String, ) { US(ApplicationRegion.US, "us.myproj"), EU(ApplicationRegion.EU, "eu.myproj"), RU(ApplicationRegion.RU, "ru.myproj") }
И совместим все это с кодом, который мы написали в предыдущем шаге, чтобы из local.properties получить наш полноценный вариант:
internal fun Project.getAppVariant(): ApplicationVariant { val region = ApplicationRegion[getLocalProperty("REGION")] return when (region) { ApplicationRegion.US -> ApplicationVariant.US ApplicationRegion.EU -> ApplicationVariant.EU ApplicationRegion.RU -> ApplicationVariant.RU ApplicationRegion.UNKNOWN -> error("Unknown variant config") } }
При желании условие можно расширять сколько угодно во все стороны.
А для внутренних обращений в плагинах gradle создадим еще небольшую утилиту:
fun Project.getProjectBuildVariant(): ProjectBuildVariant = ProjectBuildVariant( mapOf( "REGION" to getLocalProperty("REGION") ) ) class ProjectBuildVariant( private val params: Map<String, String> ) { val types: List<String> = params.values.filter(String::isNotBlank).toList() val fullKmpName = types.joinToString(separator = "", transform = String::uppercaseFirstChar) val fullAndroidName = types.mapIndexed { i, type -> if (i == 0) type else type.uppercaseFirstChar() }.joinToString(separator = "") operator fun get(key: String) = key.takeIf(String::isNotBlank)?.let(params::get)?.lowercase() ?: error("unknown variant key") }
typesлучше сделать комбинацией всех вариантов, но для примера достаточно
Теперь в нашем коде мы можем отовсюду обращаться к нашему билд варианту и пользоваться его переменными. В данный момент нас интересует только applicationId и просто само наличие этого варианта, которое даст преимущество дальше.
SourceSets
Вот мы и подошли к самой важной и интересной части - создание тех самых сорц сетов для наших вариантов.
Из кода выше понятно, что мы хотим прибавить к нашим текущим сорц сетам (commonMain, androidMain, iosMain и нативным main) дополнительно us, eu и ru.
Т.е. получится следующий набор
commonMain:commonMainUs/commonMainEu/commonMainRu.androidMain:androidMainUs/androidMainEu/androidMainRu.iosMain:iosMainUs/iosMainEu/iosMainRu.А для нативных просто добавим
us,eu,ruбез префиксов (для удобства).
Так как наш текущий проект содержит 2 части кода (нативная и KMP), то и настраивать их придется по отдельности.
Создадим в buildSrc утилиту для сокращения копипаста в самих модулях:
internal fun KotlinMultiplatformExtension.configureVariants() { val variant = project.getProjectBuildVariant() sourceSets { commonMain { configureKmpSourceSet(variant.types, variant.fullKmpName) } androidMain { configureKmpSourceSet(variant.types, variant.fullKmpName) } iosMain { configureKmpSourceSet(variant.types, variant.fullKmpName) } } } private fun KotlinSourceSet.configureKmpSourceSet( types: List<String>, variant: String ) { val path = project.projectDir.path types.forEach { type -> val dir = "$path/src/$name${type.uppercaseFirstChar()}" kotlin.srcDirs("$dir/kotlin") resources.srcDirs("$dir/resources") } val dir = "$path/src/$name$variant" kotlin.srcDirs("$dir/kotlin") resources.srcDirs("$dir/resources") }
Здесь мы для каждого KMP таргета добавим сорц сеты наших активных «флейворов», т.е. в нашем случае только регион.
Создастся src и директория ресурсов, а так же итоговая директория комбинации флейворов. Например, если у нас есть region (eu) и env (prod), то создастся euProd или же, например, usStage в зависимости от текущего local.properties.
Аналогично поступим и для нативных модулей:
internal fun CommonExtension.configureVariants(project: Project) { val variant = project.getProjectBuildVariant() val sourceSet = sourceSets.getByName("main") sourceSet.configureVariants(variant.types, variant.fullAndroidName) } private fun AndroidSourceSet.configureVariants( types: List<BuildVariantType>, variant: BuildVariantType ) { types.forEach { type -> val dir = "src/$type" java.directories.add("$dir/kotlin") kotlin.directories.add("$dir/kotlin") res.directories.add("$dir/res") assets.directories.add("$dir/assets") } val dir = "src/$variant" java.directories.add("$dir/kotlin") kotlin.directories.add("$dir/kotlin") res.directories.add("$dir/res") assets.directories.add("$dir/assets") }
При необходимости настроим и манифест (актуально для app):
sourceSet.manifest.srcFile("src/${variant.fullAndroidName}/AndroidManifest.xml")
А теперь применим этот метод в конкретном модуле (или плагине):
plugins { /** some logic */ } android { compileSdk = libs.versions.compileSdk.get().toInt() /* some logic */ configureVariants(project) } kotlin { /* some logic */ }
Это была самая важная и сложная часть - добавление самого сорц сета к нашей текущей кодовой базе.
Еще могут возникнуть дополнительные трудности, если у вас везде требуется манифест для модулей или какое-либо нестандартное содержание
FireBasegoogle-services.json(как у нас), однако это уже отдельный вопрос и рассматривать мы его не будем.
App plugin
В плагине для app модуля будет похожий код, но с доп. настройкой интересующих нас параметров:
APKnameAABnameapplicationIdи другие по необходимости
Здесь мы обратимся к ранее упомянутому getAppVariant и заберем все актуальные по сборке данные.
android { compileSdk = libs.versions.compileSdk.get().toInt() val applicationVariant = getAppVariant() val applicationName = getApplicationName(applicationVariant) defaultConfig { /** some logic */ applicationId = applicationVariant.applicationId versionName = applicationName } base { archivesName.set(applicationName) } androidComponents { onVariants(selector().all()) { variant -> variant.outputs.forEach { output -> (output as VariantOutputImpl).outputFileName = "$applicationName.apk" } } } configureAppVariants(project) }
На текущем этапе мы уже имеем функционирующий инструмент, который может закрыть большинство базовых потребностей средних проектов. Однако останавливаться мы не будем - хочется максимально приблизиться к возможностями стандартных флейворов.
Ресурсы
Теперь, как и полагается, мы хотим исключить из сборки (и APK) ненужные ресурсы, которые не используются в текущей конфигурации проекта.
Реализация такой фичи будет зависеть от вашего UI. В моем случае это нативный Android вперемешку с KMP, поэтому работаю с кроссплатформенными ресурсами.
Я сделал 2 версии под самые популярные решения.
Если у вас только натив, то вполне можно сделать это с помощью нативных флейворов. Либо, для консистентности сделать решение аналогичное нашему.
Moko resources
Первая и (спойлер!) более правильная версия получилась с Moko-resources.
Сделаем для ресурсов отдельный модуль и настроим плагин ресурсов на работу с нашими «вариантами», как это было с сорц сетами:
multiplatformResources { resourcesPackage.set(/** some logic */) resourcesClassName.set("Res") configureVariants() } private fun MultiplatformResourcesPluginExtension.configureVariants() { val variant = project.getProjectBuildVariant() resourcesSourceSets { val sourceSet = getByName("commonMain") variant.types.forEach { type -> sourceSet.srcDirs(File(projectDir, "src/${sourceSet.name}${type.uppercaseFirstChar()}/moko-resources")) } sourceSet.srcDirs(File(projectDir, "src/${sourceSet.name}${variant.fullKmpName}/moko-resources")) } }
Теперь для каждого варианта можно создать свою директорию с ресурсами и обращаться к ним из нужного сорц сета.
Compose resources
С стандартным решением от CMP все получилось сложнее.
В библиотеке на сегодняшний день отсутствует возможность подключения больше одной рабочей директории. Можно только поменять 1 действующую с помощью следующего вызова:
customDirectory("commonMain", project.mergedResourcesDir)
Для реализации аналогичной функции потребуется в процессе сборки или синка проекта открывать нужные директории и собирать их содержимое в конечный набор ресурсов.
Получившееся решение прикладывать не будем, так как оно не отличается лаконичностью и «правильностью» и можно получить похожее через LLM.
Зависимости
Финальный штрих - реализация динамических зависимостей по варианту как в флейворах. Подсмотрим немного в стандартную реализацию gradle зависимостей и обратимся к их коду.
Под капотом привычных нам вызовов нет ничего сверхъестественного (не считая того, как появляются эти самые методы).
internal fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? = add("implementation", dependencyNotation)
Так как при создании таких зависимостей нам надо иметь постоянный доступ к local.properties, то легко обратиться к ним из блока dependencies без копипаста не получится. Иначе придется каждый раз читать пропертисы и передавать их в нужный метод. Поэтому добавим вот такой метод (аналогичный dependencies) в проект, но уже с нашим «прокаченным» скоупом.
fun Project.variantDependencies(configuration: VariantDependencyHandlerScope.() -> Unit) { val variant = getProjectBuildVariant() val scope = VariantDependencyHandlerScope(dependencies, variant) scope.configuration() }
А вот и сама реализация скоупа:
class VariantDependencyHandlerScope( private val dependencies: DependencyHandler, variant: ProjectBuildVariant ) { private val region = ApplicationRegion[variant["REGION"]] //region Android fun euImplementation(dependencyNotation: Any): Dependency? = add( region = ApplicationRegion.EU, dependencyNotation = dependencyNotation, target = Target.NATIVE, type = Type.IMPLEMENTATION ) fun euApi(dependencyNotation: Any): Dependency? = add( region = ApplicationRegion.EU, dependencyNotation = dependencyNotation, target = Target.NATIVE, type = Type.API ) /** some logic */ //endregion Android //region KMP //region KMP Common fun commonMainUsImplementation(dependencyNotation: Any): Dependency? = add( region = ApplicationRegion.US, dependencyNotation = dependencyNotation, target = Target.COMMON, type = Type.IMPLEMENTATION ) /** some logic */ //endregion KMP Common //region KMP Android fun androidMainUsImplementation(dependencyNotation: Any): Dependency? = add( region = ApplicationRegion.US, dependencyNotation = dependencyNotation, target = Target.ANDROID, type = Type.IMPLEMENTATION ) /** some logic */ //endregion KMP Android //region KMP iOS //endregion KMP iOS /** some logic */ //endregion KMP private fun add( dependencyNotation: Any, region: ApplicationRegion = this.region, target: Target, type: Type ): Dependency? { if (region != this.region) return null val type = if (target == Target.NATIVE) type.type else type.type.uppercaseFirstChar() val dependency = "${target.target}$type" return dependencies.add(dependency, dependencyNotation) } private enum class Target(val target: String) { NATIVE(""), COMMON("commonMain"), ANDROID("androidMain"), IOS("iosMain") } private enum class Type(val type: String) { IMPLEMENTATION("implementation"), API("api") } }
Ключевой метод тут - add. Мы можем создать удобные API для взаимодействия со скоупом сами, либо же напрямую вызывать его в коде - будет гибко, но слегка неудобно.
Итоговый клиентский код выглядит так:
dependencies { /** some logic */ } variantDependencies { commonMainEuImplementation(libs.some.dependency1) commonMainUsImplementation(libs.some.dependency2) androidMainEuImplementation(projects.module1) androidMainUsImplementation(projects.module2) }
Так, все неактивные варианты будут просто игнорироваться - будет вызываться только добавление действующих зависимостей.
При очень большом желании, можно сделать более гибкое решение через кодогенерацию или копнуть еще глубже, но на текущий момент такая реализация оптимальна с точки зрения потраченных сил и полученного результата.
Примеры
Теперь с полной уверенностью мы можем пойти в наш код и разобрать накопившиеся if-else и when условия по обработке текущего варианта сборки.
Код com/myproj/feature/main до:
@Composable internal fun FeatureContent( modifier: Modifier = Modifier ) { when (BuildConfig.REGION) { Region.EU -> EuFreatureContent(modifier = modifier) Region.US -> UsFeatureContent(modifier = modifier) } }
Так как разделение логики происходит во время работы приложения, то в коде всегда сохраняется некоторая недосказанность, из-за чего в процессе работы требуются лишние проверки окружения, в котором сейчас исполняется код (например, проверка региона).
Наш инструмент позволяет избавиться от таких проблем и явно определить вариант во время gradle синка и подсветить проблемы при начале сборки.
Код com/myproj/feature/main после:
@Composable internal fun FeatureContent( modifier: Modifier = Modifier ) { RegionFeatureContent(modifier = modifier) }
com/myproj/feature/eu :
@Composable internal fun RegionFeatureContent( modifier: Modifier = Modifier ) { Column( modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text(/** some logic */) Text(/** some logic */) Icon(/** some logic */) } }
com/myproj/feature/us :
@Composable internal fun RegionFeatureContent( modifier: Modifier = Modifier ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Image(/** some logic */) Image(/** some logic */) } Text(/** some logic */) } }
Все четко и по полочкам! Теперь ненужный код будет исключен из процесса сборки и индексации при работе. При этом, во время написания такого кода мы всегда точно знаем в каком варианте сборки находимся и можем опираться на это без лишних проверок.
Расположение файлов тоже стало более говорящим:
Было:

Стало (eu вариант):

BuildConfig
Ну и если Вам вдруг в рантайме понадобилось проверить текущий вариант сборки и по условию выполнить или исключить какую-то часть кода, то наверняка понадобится небольшая связка gradle части с рантаймом - BuildConfig.
Для этого можно воспользоваться либо ранее показанным механизмом sourceSet - создать object с нужными параметрами вашего варианта в соответствующей директории.
object CurrentApplicationConfig : ApplicationConfig { override val region: Region = Region.EU override val applicationId: String = "com.myproj.eu" }
Или по-простому через BuildKonfig, о котором мы говорили ранее, но для полноценной работы с классами и перечислениями придется маппить ручками.
plugins { alias(libs.plugins.codingfeline.buildkonfig) } buildkonfig { packageName = /** some logic */ defaultConfigs { /** some logic */ buildConfigField(STRING, "REGION", getLocalProperty("REGION"), false, true) } }
А далее уже обращаемся к сгенерированному BuildKonfig из нужного места.
Итоги
Подводя итоги, теперь мы имеем следующий функционал:
Бесконечная расширяемость сборок по разным параметрам.
Изолированные кодовые базы (sourceSet).
Изолированные темы UI.
Специфичные для варианта зависимости.
Полный контроль над переключением вариантов.
А можно ли как-то улучшить наше решение?
Естественно! Его еще ждет долгий путь улучшений и доработок, но ключевая концепция останется такой же до тех пор, пока Gradle сам не даст беспроигрышную реализацию аналогичную нативным flavors.
Ну и вдобавок можно подумать над решением актуальных проблем:
Сложность в подмене
google-services.jsonдля разных app версий.Неудобное/долгое переключение между вариантами.
Плохая интеграция с
CMP Resources.Неудобно масштабируемые динамические зависимости.
Заключение
Именно так и прошло моё переизобретение Gradle flavors на практике.
Долгое время команда мирилась с недочетами старой реализации, копипастами, общим кодом и повсеместными конструкциями if-else или when, что загромождало кодовую базу и заставляло по кусочками собирать пазл всего флейвора.
Сейчас я пришел именно к этому решению и очень хочу развивать его дальше.
Я был рад поделиться нашим бесценным опытом с Вами!
Можете поделиться своим опытом по работе с флейворами на KMP? К какой реализации пришла ваша команда?

