
Привет! Это Саша Таболин — старший android-разработчик в red_mad_robot. Мы создали открытую библиотеку Konfeature для оптимизации работы с Feature Flags и хотим поделиться нашей разработкой.
Feature Flags в android-разработке
«Фича Флаги» — переключатели в коде, которые запускают и останавливают работу его компонентов. Они помогают выстраивать непрерывный CI/CD-процесс и гибко внедрять новые функции в процесс разработки.
Основные плюсы использования Feature Flags:
Постепенный rollout — вместо мгновенного выкатывания фичи для всех пользователей можно включать её поэтапно, минимизируя риски;
A/B-тестирование — чтобы принимать решения на основе данных сравнения ключевых метрик разных версий;
Быстрый откат — если после релиза обнаружилась критическая ошибка, проблемную фичу можно отключить без выпуска Hotfix;
Условный доступ — открывает доступ только определённым группам пользователей, например, по геолокации или подписке.
Пять требований к удобной системе Feature Flags
1. Чёткое разделение на remote и local-only флаги
Remote флаги — управляются сервером — для динамического включения или выключения фич;
Local-only флаги — хардкод или debug-конфиги — для внутреннего тестирования и разработки.
2. Поддержка нескольких remote-источников
В реальных проектах конфигурация может приходить из разных сервисов:
Firebase Remote Config — для экосистемы Google;
HMS Remote Configuration — для устройств Huawei;
Собственный Backend — например, Feature Flags могут быть частью микросервисов.
3. Детальное логирование для отладки
При работе с Feature Fags важно понимать:
Какое значение было применено;
Откуда оно получено: локальный конфиг, Firebase, кастомный API;
Были ли ошибки, например, сервер вернул строку вместо ожидаемого Boolean.
4. Гибкое переопределение значений при разработке
Необходимые инструменты разработчиков и QA, чтобы тестировать разные сценарии:
Принудительного включения/выключения флагов в runtime;
Эмуляции разных конфигураций, например, проверку поведения для разных стран.
5. Поддержка многомодульности
В современных android-приложениях код делится на модули, поэтому Feature Flags должны поддерживать:
Изолированные конфиги для каждого модуля;
Независимое управление модулей своими флагами.
Как Konfeature упрощает работу с Feature Flags
В противовес пяти требованиям к системе Feature Flags, мы выделили пять компонентов нашей библиотеки в рамках открытого API.
FeatureConfig
— содержит информацию о группе Feature Flags;FeatureSource
— источник получения значений Feature Flags;Interceptor
— перехватчик значений Feature Flags, может посмотреть и изменить значения;Spec
— содержит информацию обо всех зарегистрированных FeatureConfig;Logger
— логирует события внутри библиотеки.
1. FeatureConfig
Как создать новую группу Feature Flags с помощью FeatureConfig
Создаём новый класс, который наследует FeatureConfig
, указав уникальное name
и description
конфигурации.
class FeatureAConfig: FeatureConfig(
name = "Feature A",
description = "Description of Feature A"
)
Объявляем Feature Flag, используя делегат by toggle
, в котором нужно указать:
key
— ключ для получения значений вFeatureSource
;description
— описание Feature Flag для документирования;defaultValue
— значение по умолчанию;sourceSelectionStrategy
— стратегия выбораFeatureSource
, по умолчанию стоитSourceSelectionStrategy.None
— это local-only флаг.
class FeatureAConfig: FeatureConfig(
name = "Feature A",
description = "Description of Feature A"
) {
val isDetailedFeatureDescriptionEnabled by toggle(
key = "detailed_feature_a_description",
description = "show detailed description of feature A",
defaultValue = true,
sourceSelectionStrategy = SourceSelectionStrategy.None
)
}
Последнее действие — регистрируем FeatureConfig
в Konfeature.
val featureAConfig = FeatureAConfig()
konfeature {
register(featureAConfig)
}
И теперь можно прокинуть FeatureAConfig
во ViewModel
.
class SomeViewModel(featureAConfig: FeatureAConfig): ViewModel() {
init {
if (featureAConfig.isDetailedFeatureDescriptionEnabled) {
showDetailedFeatureDescription()
}
}
private fun showDetailedFeatureDescription() { ... }
}
Для многомодульного проекта — на примере DI с Hilt.
Допустим у нас есть модуль app
и два фича-модуля: featureA
и featureB
.
В модуле featureA
объявим Singleton конфигурацию.
@Singleton
class FeatureAConfig @Inject constructor(): FeatureConfig(
name = "Feature A",
description = "Description of Feature A"
) {
val isDetailedFeatureDescriptionEnabled by toggle(
key = "detailed_feature_a_description",
description = "show detailed description of feature A",
defaultValue = true,
sourceSelectionStrategy = SourceSelectionStrategy.None
)
}
Воспользуемся возможностью Dagger DI
и добавим FeatureAConfig
в общий Set<FeatureConfig>
.
@InstallIn(SingletonComponent::class)
@Module
class FeatureAModule {
@Provides @IntoSet
fun provideFeatureConfig(config: FeatureAConfig): FeatureConfig = config
}
Аналогичным образом создадим конфигурацию в модуле featureB
. В app
модуле зарегистрируем все конфигурации из Set<FeatureConfig>
.
@InstallIn(SingletonComponent::class)
@Module
class AppModule {
@Singleton
@JvmSuppressWildcards
@Provides
fun provideKonfeature(configs: Set<FeatureConfig>): Konfeature {
return konfeature {
configs.forEach(::register)
}
}
}
В результате мы избежим изменений кода вне модуля при добавление новых конфигураций.
2. FeatureSource
FeatureSource
— это абстракция источника данных для Feature Flags, она имеет уникальное name, которое используется в SourceSelectionStrategy
и отдаёт значение по key
.
public interface FeatureSource {
public val name: String
public fun get(key: String): Any?
}
Посмотрим на примере реализации для FirebaseRemoteConfig
.
class FirebaseFeatureSource(
private val remoteConfig: FirebaseRemoteConfig
) : FeatureSource {
override val name: String = "FirebaseRemoteConfig"
override fun get(key: String): Any? {
return remoteConfig
.getValue(key)
.takeIf { source == FirebaseRemoteConfig.VALUE_SOURCE_REMOTE }
?.let { value: FirebaseRemoteConfigValue ->
value.getOrNull { asBoolean() }
?: value.getOrNull { asString() }
?: value.getOrNull { asLong() }
?: value.getOrNull { asDouble() }
}
}
private fun FirebaseRemoteConfigValue.getOrNull(
getter: FirebaseRemoteConfigValue.() -> Any?
): Any? {
return try {
getter()
} catch (error: IllegalArgumentException) {
null
}
}
}
Чтобы Feature Flags начали получать свои значения из FirebaseFeatureSource
, нужно добавить его при создании Konfeature.
val featureAConfig = FeatureAConfig()
val source: FeatureSource = FirebaseFeatureSource(remoteConfig)
val konfeatureInstance = konfeature {
addSource(source)
register(featureAConfig)
}
Теперь в FeatureAConfig
при изменении значения поля sourceSelectionStrategy
c SourceSelectionStrategy.None
на SourceSelectionStrategy.Any
или SourceSelectionStrategy.anyOf("FirebaseRemoteConfig")
— Feature Flag получит значение из FirebaseRemoteConfig
.
Важно отметить, что если добавить несколько FeatureSource
, библиотека будет опрашивать их в порядке добавления — до первого FeatureSource
, который содержит указанное для Feature Flag значение key
.
3. Interceptor
Intereceptor
— позволяет посмотреть текущее значение Feature Flag, его источник, а также изменить значение при необходимости. Как и FeatureSource
, имеет уникальное name
.
public interface Interceptor {
public val name: String
public fun intercept(
valueSource: FeatureValueSource,
key: String,
value: Any
): Any?
}
FeatureValueSource
— это источник значения для Feature Flag — FeatureSource
, Interceptor
или значение по умолчанию.
public sealed class FeatureValueSource {
public class Source(public val name: String) : FeatureValueSource()
public class Interceptor(public val name: String) : FeatureValueSource()
public object Default : FeatureValueSource()
}
Если метод intercept
возвращает null
, то значение Feature Flag не поменяется.
Рассмотрим реализацию Interceptor
на основе DebugPanelInterceptor
. Её можно использовать для работы с Debug Panel, например, для включения Feature Flags в Debug сборкаx.
class DebugPanelInterceptor : Interceptor {
private val values = mutableMapOf<String, Any>()
override val name: String = "DebugPanelInterceptor"
override fun intercept(
valueSource: FeatureValueSource,
key: String,
value: Any
): Any? {
return values[key]
}
fun setFeatureValue(key: String, value: Any) {
values[key] = value
}
fun removeFeatureValue(key: String) {
values.remove(key)
}
}
Эту реализацию тоже нужно добавить при создании Konfeature.
val featureAConfig = FeatureAConfig()
val source: FeatureSource = FirebaseFeatureSource(remoteConfig)
val debugPanelInterceptor: Interceptor = DebugPanelInterceptor()
val konfeatureInstance = konfeature {
addSource(source)
register(featureAConfig)
addInterceptor(debugPanelInterceptor)
}
Важно отметить, что можно добавить несколько Interceptor
, но в отличие от FeatureSource
, библиотека опросит все Interceptor
в порядке добавления.
4. Spec
Konfeature предоставляет доступ к информации обо всех зарегистрированных конфигурациях, что позволяет получить текущее значение и его источник для любого Feature Flag.
public interface Konfeature {
public val spec: List<FeatureConfigSpec>
public fun <T : Any> getValue(spec: FeatureValueSpec<T>): FeatureValue<T>
}
FeatureConfigSpec
здесь — это информация о FeatureConfig
.
public interface FeatureConfigSpec {
public val name: String
public val description: String
public val values: List<FeatureValueSpec<out Any>>
}
А FeatureValueSpec
— информация о Feature Flag.
public class FeatureValueSpec<T : Any>(
public val key: String,
public val description: String,
public val defaultValue: T,
public val sourceSelectionStrategy: SourceSelectionStrategy
)
Передав FeatureValueSpec
в метод getValue
можно получить актуальное значение FeatureValue
.
public class FeatureValue<T>(
public val source: FeatureValueSource,
public val value: T,
)
Этот механизм позволяет увидеть все Feature Flags и их значения, разбитыми по FeatureConfig
в рамках приложения. Так удобно выводить Feature Flags в Debug Panel — с отображением ключа, описания, текущего значения и источника этого значения.
5. Logger
Используется для записи и сохранения событий внутри библиотеки.
public interface Logger {
public fun log(severity: Severity, message: String)
public enum class Severity {
WARNING, INFO
}
}
На данный момент логируются два события:
1. информация о Feature Flag в момент запроса его значения;
Get value 'true' by key 'profile_feature' from 'Source(name=FirebaseRemoteConfig)'
2. ошибка, если FeatureSource
вернул по ключу неожиданный тип.
Unexpected value type for 'profile_button_appear_duration': expected type is 'kotlin.Long', but value from 'Source(name=FirebaseRemoteConfig)' is 'true' with type 'kotlin.Boolean'
Рассмотрим реализацию Logger
на основе библиотеки Timber.
class TimberLogger: Logger {
override fun log(severity: Severity, message: String) {
if (severity == INFO) {
Timber.tag(TAG).i(message)
} else if (severity == WARNING) {
Timber.tag(TAG).w(message)
}
}
companion object {
private const val TAG = "FeatureFlags"
}
}
Как и другие компоненты, TimberLogger
нужно добавить при создании Konfeature.
val featureAConfig = FeatureAConfig()
val source: FeatureSource = FirebaseFeatureSource(remoteConfig)
val debugPanelInterceptor: Interceptor = DebugPanelInterceptor()
val logger: Logger = TimberLogger()
val konfeatureInstance = konfeature {
addSource(source)
register(featureAConfig)
addInterceptor(debugPanelInterceptor)
setLogger(logger)
}
Стоит отдельно отметить
библиотека написана на Kotlin Multiplatform, но на данный момент поддерживается только JVM Target;
библиотека позволяет использовать делегат
by value
для получения значений любого типа, отличного от Boolean.
Что дальше?
Загляните в документацию Konfeature;
Протестируйте библиотеку на своём проекте — она легко интегрируется с DI-фреймворками вроде Hilt;
Делитесь обратной связью — ваши кейсы помогут улучшить библиотеку.
Над материалом работали
текст — Саша Таболин;
редактура — Игорь Решетников;
иллюстрации — Юля Ефимова.