Android-разработчик RuStore Анатолий Гусев расскажет, как приготовить систему «холодных» конфигов для большого Android-приложения, построенного на многомодульной архитектуре. Под «холодными» конфигами здесь подразумеваются настройки приложения, которые нужно делать локально на девайсе без необходимости загружать их из сети.

Зачем нужна конфигурация

В первую очередь для TBD (trunk-based development — магистральная разработка), когда требуется поставлять фичу под «холодным» переключателем (toggle) не целиком, а быстро и небольшими частями.

Также пригодится для тестирования. С помощью конфигурации удобно задавать интервалы и длительность фоновых процессов. Например, сократить период синхронизации данных приложения с 24 часов до 15 минут, или задать приложению кастомный endpoint, на котором можно тестировать фичу в разработке.

Конфигурация в многомодульном проекте

Определившись, для чего нужна такая система, перейдём к тому, как она должна быть устроена и вписана в многомодульный проект. Многомодульность в нашем случае стандартная: все фичи распределены по Gradle-модулям. В то же время каждая фича разделена на модули api и implementation. В первом из них минимум интерфейсов для взаимодействия с фичей из вызывающего кода. Вся реализация фичи находится в модуле implementation, который не может зависеть от других implementation-модулей.

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

data class Configuration(
	val loggerEnabled: Boolean,
	val syncInterval: Long,
	val someFeatureEnabled: Boolean
	...
)

У этого подхода есть серьёзные минусы:

  1. Бизнес-контекст фич в виде отдельных полей конфига «протекает» в модуль с конфигурацией.

  2. Ситуация, когда все поля конфига содержатся в одной сущности, приводит к тому, что в большом проекте происходит много мелких, но неприятных конфликтов слияния (merge conflict). Это отнимает время и повышает вероятность ошибок.

  3. При удалении фичи легко забыть про удаление связанных с этой фичей полей конфига.

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

Но взамен мы получим две новые проблемы:

Проблема 1. Так как конфиг задан интерфейсом, у него должна быть реализация. Конфиги для релиза и отладки отличаются, поэтому нужны две реализации. Это увеличивает количество строк кода и усложняет работу.

Проблема 2. Возникает неприятная дилемма с отладочной реализацией. Реализация для отладки не должна попадать в релизную сборку. Чтобы этого не произошло, можно:

  • Разложить реализацию по типам сборки внутри модуля implementation, что замедлит сборку.

  • Делать дополнительный Gradle-модуль с отладочной реализацией и подключать его только в отладке. Это увеличит количество шаблонного кода (boilerplate) и приведёт к необходимости создания множества модулей с единственным файлом внутри.

Конфигурация с помощью Dynamic Proxy

Начнём с того, что немного сократим количество нужных реализаций конфига с помощью Java Dynamic Proxy. Это механизм, позволяющий «на лету» создать экземпляр переданного интерфейса (и не только интерфейса). С его помощью создадим релизные реализации конфигов.

Если мы делаем экземпляр конфига «на лету», то откуда взять значения полей конфига? Зададим значения с помощью аннотаций над полями конфига.

interface GreetingConfig : ConfigMarker {
	@get:ConfigStringValue("World")
	val target: String
}

Можно обойтись и без аннотаций, так как Kotlin позволяет задавать поля интерфейсов с дефолтными значениями. С другой стороны, аннотации гарантируют, что мы не получим релизные значения конфигов в отладочной сборке.

Принцип работы Dynamic Proxy определяет, каким будет интерфейс провайдера конфигов. Мы отдадим провайдеру тип конфига и получим экземпляр класса, его реализующего. Поскольку система предназначена только для конфигов, ограничим типы, с которыми она может работать, маркерным интерфейсом ConfigMarker.

interface Configs {
	operator fun <T : ConfigMarker> get(configType: Class<T>): T
}
class ConfigsImpl @Inject constructor(): Configs {

	override operator fun <T : ConfigMarker> get(configType: Class<T>): T {
		val types = arrayOf(configType)
		val invocationHandler = ConfigProxyInvocationHandler()
		return Proxy.newProxyInstance(javaClass.classLoader, types, invocationHandler) as T
	}
}
internal class ConfigProxyInvocationHandler: InvocationHandler{

	override fun invoke(instance: Any, method: Method, args: Array<out Any>?): Any {
		var valueFromAnnotation:Any? = null
		when {
			method.isAnnotationPresent(ConfigStringValue::class.java) ->{
				valueFromAnnotation = method.getAnnotation(ConfigStringValue::class.java)?.releaseValue
			}
		...
		}
	return valueFromAnnotation ?: error("All config properties must be annotated")
	}
}

InvocationHandler — это класс, который обрабатывает вызовы всех методов и полей созданного прокси. При обращении к геттерам конфига нужно проверить наличие аннотации с релизным значением, и, вытащив из аннотации это значение, вернуть его. Если было обращение к не аннотированному свойству, то выкидываем исключение.

Подготовка реализации

Теперь наша система работоспособна. Для релизной сборки этого достаточно. Подготовим реализацию для отладочной сборки с более сложной логикой.

Сначала определим, как будут храниться отладочные конфиги. Наиболее приемлемый компромисс: собрать отладочные реализации конфигов в отдельном Gradle-модуле, но только в одном — чтобы не плодить сущности. Это будет тот же модуль, где лежит реализация отладочной части системы конфигов.

Отладочные конфиги сделаем в виде написанного вручную data-класса.

data class GreetingDebugConfig(
	override val target: String = "Test",
) : GreetingCon

Для хранения конфигов в отладке используем память в Map. Ключом будет класс интерфейса конфига, а значением — отладочная реализация.

class ConfigInMemoryDataSource @Inject constructor() {

	private val configs: MutableMap<Class<out ConfigMarker>, ConfigMarker> = mutableMapOf()

	fun <T : ConfigMarker> get(configType: Class<T>): T =
		configs[configType] as? T ?: error("No debug config")
	...
}

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

После подготовки базовой части системы, дополним её более сложными фичами. Сделаем отладочный экран для редактирования конфигов. Для этого определим, как будет выглядеть State экрана.

data class ConfigState(
	val configValues: Map<String, ConfigProperty>,
	val expanded: Boolean = false,
)

open class ConfigProperty(
	val displayName: String,
	val fullName: String,
)

Для каждого типа, поддерживаемого системой конфигов, выберем отдельный подкласс ConfigProperty.

class ConfigBoolProperty(displayName: String, fullName: String, val value: Boolean) :ConfigProperty(displayName, fullName)

class ConfigIntProperty(displayName: String, fullName: String, val value: Int) :ConfigProperty(displayName, fullName)

class ConfigLongProperty(displayName: String, fullName: String, val value: Long) : ConfigProperty(displayName, fullName)

class ConfigStringProperty(displayName: String, fullName: String, val value: String) : ConfigProperty(displayName, fullName)

Это упростит редактирование на экране. А чтобы не вынуждать разработчика при изменениях конфигов лезть ещё и в UI отладочного экрана, сгенерируем State с помощью рефлексии на основе всех конфигов приложения.

Вот так выглядит маппинг в State.

class ConfigStateMapper @Inject constructor() {

	fun map(configType: Class<out ConfigMarker>, config: ConfigMarker): ConfigState {
		val properties = config.javaClass.kotlin.memberProperties
		val propertiesData = properties.associate { property ->
			val propertyValue = property.getter.call(config)
			val fullName = "${configType.name}.${property.name}"
			fullName to toPropertyModel(fullName, property, propertyValue)
		}
		return ConfigState(propertiesData)
}

	private fun <T, V> toPropertyModel(fullName: String, property: KProperty1<T, V>, propertyValue: Any?): ConfigProperty =
		when {
			isPropertyAssignableFrom(property, Boolean::class.java) -> ConfigBoolProperty(property.name, fullName, propertyValue as Boolean)
			isPropertyAssignableFrom(property, Int::class.java) -> ConfigIntProperty(property.name, fullName, propertyValue as Int)
			isPropertyAssignableFrom(property, Long::class.java) -> ConfigLongProperty(property.name, fullName, propertyValue as Long)
			else -> ConfigStringProperty(property.name, fullName, propertyValue.toString())
		}
	
	private fun <T, V> isPropertyAssignableFrom(property: KProperty1<T, V>, type: Class<*>, ): Boolean =
		property.javaField?.type?.isAssignableFrom(type) ?: false
}

В ходе работы с экраном пользователь модифицирует State. Чтобы отредактированный конфиг применился, нужно как-то хранить изменения. Это касается только изменённых полей конфига, которые состоят из пар «ключ-значение». Ключом считается полное имя класса конфига и имя поля, а значением — отредактированное значение.

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

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

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

private fun applyToConfig(configKey: Class<out ConfigMarker>, config: ConfigMarker, changed: Map<String, String>) {
	val properties = config.javaClass.kotlin.memberProperties
	properties.forEach { property ->
		val fullName = "${configKey.name}.${property.name}"

		if (changed.contains(fullName)) {
			property.javaField?.isAccessible = true

			when {
				isPropertiesAssignableFrom(property, Boolean::class.java) -> {
					property.javaField?.set(config, changed[fullName]?.toBoolean())
				}
			...
			}
		}
	}
}

Основные моменты реализации отладочного экрана готовы. Чтобы не задумываться о том, где можно использовать конфиги, а где нет, используем библиотеку androidx.startup и вынесем инициализацию конфигов на этап до старта приложения.

internal class ConfigInitializer : Initializer<Unit> {

	@Inject
	internal lateinit var initConfigsUseCase: InitConfigsUseCase

	override fun create(context: Context) {
		//inject dependency
		initConfigsUseCase()
	}
}

Так как в момент, когда инициализатор отработает графы зависимостей, основного приложения ещё нет, сделаем ConfigInMemoryDataSource синглтоном. Это позволит воспользоваться инициализированными конфигами уже в приложении.

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

Комментарии (0)