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