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

Но вот так незадача: спустя 5 минут поисков до вас доходит осознание:Gradle Flavor на KMP нет и не планируется, а вместо них нам достается expect/actual, разбивающий код по платформенным сорц сетам.

Вот так мы и остались без столь удобного механизма, позволяющего комбинировать кучу разных функциональностей в проекте, не смешивая при этом все в одной кодовой базе.

Ликбез для тех, кто не знаком c инструментом:

Gradle flavors (Build variants) - механизм сборщика проектов gradle, позволяющий разбивать кодовую базу на отдельные части по каким-либо параметрам, заданным при конфигурации проекта.

Разделить можно по полной/триал версии, региону, месту поставки сборки или магазину приложений (отдельная сборка для Huawei) и многое-многое другое.

Неиспользуемый код исключается из сборки и лежит в неподключенной папке.


Содержание

  1. Постановка проблемы

  2. Варианты решений

  3. Какой вариант выбрали мы?

  4. Реализация

  5. Примеры

  6. Итоги


Постановка проблемы

На самом деле, с первого взгляда это небольшая проблема - нельзя сделать вариацию сборки и добавить в нее специфичный код или ресурсы.

«Хорошо, начнем все писать в одной кодовой базе, но разделять по файлам», - подумает большинство разработчиков.

И они будут правы, но к такому решению есть ряд вопросов:

  1. Как отличить сборки?

  2. Как делить ресурсы?

  3. Как конфигурировать сборку? (appId, нейминг и т.п.)

  4. Как быть со сложным функционалом?

Если первую часть вопросов можно как-то решить, а со второй просто смириться, то со временем из-за этого собирается целый ком проблем, который катится дальше с вашим проектом.


Варианты решений

На просторах гугла/ии-чатов можно достаточно быстро найти несколько вариантов, каждый из которых в той или иной мере решают поставленную проблему:

  1. Нативные flavors

  2. BuildKonfig

  3. Native + BuildKonfig

  4. Пользовательские плагины

  5. Кастомные sourceSets

Кратко разберу каждый из этих вариантов и выделю их преимущества и недостатки, чтобы можно было сделать правильный и осознанный выбор.

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.

Давайте разбираться, почему именно он.

Сначала выделим задачи, которые стояли перед командой:

  1. Иметь возможность разделять ресурсы в сборках (исключать их из итогового архива).

  2. Иметь разные манифесты для приложений.

  3. Разделять логику (UI и остальное) и выключать ее из сборок.

  4. Иметь разную конфигурацию (цвета, иконки, хост, язык).

  5. Разделять зависимости по сборкам (аналогично флейворам).

Отмечу, что реализация писалась не с абсолютного нуля, а с промежуточного «временного» варианта 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.

Т.е. получится следующий набор

  1. commonMain: commonMainUs / commonMainEu / commonMainRu.

  2. androidMain: androidMainUs / androidMainEu / androidMainRu.

  3. iosMain: iosMainUs / iosMainEu / iosMainRu.

  4. А для нативных просто добавим 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 */
}

Это была самая важная и сложная часть - добавление самого сорц сета к нашей текущей кодовой базе.

Еще могут возникнуть дополнительные трудности, если у вас везде требуется манифест для модулей или какое-либо нестандартное содержание FireBase google-services.json (как у нас), однако это уже отдельный вопрос и рассматривать мы его не будем.

App plugin

В плагине для app модуля будет похожий код, но с доп. настройкой интересующих нас параметров:

  • APK name

  • AAB name

  • applicationId

  • и другие по необходимости

Здесь мы обратимся к ранее упомянутому 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 из нужного места.

Итоги

Подводя итоги, теперь мы имеем следующий функционал:

  1. Бесконечная расширяемость сборок по разным параметрам.

  2. Изолированные кодовые базы (sourceSet).

  3. Изолированные темы UI.

  4. Специфичные для варианта зависимости.

  5. Полный контроль над переключением вариантов.

А можно ли как-то улучшить наше решение?

Естественно! Его еще ждет долгий путь улучшений и доработок, но ключевая концепция останется такой же до тех пор, пока Gradle сам не даст беспроигрышную реализацию аналогичную нативным flavors.

Ну и вдобавок можно подумать над решением актуальных проблем:

  1. Сложность в подмене google-services.json для разных app версий.

  2. Неудобное/долгое переключение между вариантами.

  3. Плохая интеграция с CMP Resources.

  4. Неудобно масштабируемые динамические зависимости.

Заключение

Именно так и прошло моё переизобретение Gradle flavors на практике.

Долгое время команда мирилась с недочетами старой реализации, копипастами, общим кодом и повсеместными конструкциями if-else или when, что загромождало кодовую базу и заставляло по кусочками собирать пазл всего флейвора.

Сейчас я пришел именно к этому решению и очень хочу развивать его дальше.

Я был рад поделиться нашим бесценным опытом с Вами!

Можете поделиться своим опытом по работе с флейворами на KMP? К какой реализации пришла ваша команда?