
Привет! Это Саша Таболин — старший 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;
Делитесь обратной связью — ваши кейсы помогут улучшить библиотеку.
Над материалом работали
текст — Саша Таболин;
редактура — Игорь Решетников;
иллюстрации — Юля Ефимова.
dmitrykabanov
Интересный проект