Key-Value хранилища — это очень удобно... пока вам не захочется большего.
SharedPreferences на Android, DataStore, NSUserDefaults на iOS, Multiplatform Settings, локальные файлы или вообще SQL - под все эти варианты нужно писать специфичный код.
Каждое из этих апи нуждается в создании дополнительных оберток, репозиториев, для повышения абстракции и упрощения замены конкретных библиотек.
KStorage — ультимативная обёртка, которая решает эту проблему. Библиотека позволяет создавать обёртки для Key-Value хранилищ, таких как DataStore.
Также можно создавать Key-Value хранилище, где ключ — это название файла, а значение — это его сериализуемое значение.
Более того, вы даже можете спокойно сделать обёртку над SQL-запросом. Например, для создания пользователя, обновления или удаления.
Что под капотом
В основе лежит концепция "Коробок" - Krate. Обёртки над значением, которое можно сохранять и загружать.
Самый простой способ начать — использовать DefaultMutableKrate, указав, как хранить и читать значение:
private val intSettingsMap: MutableMap<String, Int> val mutableKrate: MutableKrate<Int> = DefaultMutableKrate( factory = { 0 }, loader = { intSettingsMap["INT_KEY"] }, saver = { value -> intSettingsMap["INT_KEY"] = value } )
Зачем это нужно?
Унификация доступа. Независимо от платформы или библиотеки для Key-Value хранилища, вы всегда будете работать с единым API.
Тестируемость. Не нужно тянуть реальные зависимости в юнит-тесты. Все это можно замокать.
Расширяемость. Сама библиотека построена на экстеншон-функциях, что обеспечивает высокую гибкость. Оборачивайте в "Коробки" что угодно — от
Map<T,V>до SQL запросов!Типобезопасность. API обеспечивает проверку типов на этапе компиляции, снижая вероятность ошибок.
Легковесность. Библиотека крайне легковесная. Она содержит только сам язык и first-party библиотеку - корутины.
Расширяемость и фичи
Но действительно ли библиотека так хорошо расширяема? Давайте взглянем на уже существующие экстеншоны.
Как видели выше, у нас есть DefaultMutableKrate<T>. Это здорово. а что, если я хочу кэшировать значение?
Все очень просто. Тут приходит на помощь всеми любимые декораторы!
val cachedMutableKrate: CachedKrate<Int> = mutableKrate.asCachedMutableKrate()
Cachedkrate<T> - Это декоратор над обычным Krate<T>.
Он содержит дополнительное поле value, которое загружается при инициализации.
Но что, если вы хотите использовать StateFlow в качестве кэшируемого значение?
Тут все аналогично. Мы создаем декоратор и делаем аналогичное
val stateFlowKrate: StateFlowMutableKrate<Int> = mutableKrate.asStateFlowMutableKrate()
Вот и все. Теперь мы можем подписываться на изменения значений
Race Condition
Нужно быть осторо��ным. Если библиотека не является реактивной, нет возможности синхронизировать между собой два крейта.
Но это, как мне кажется, уже должен решать сам разработчик, ведь KStorage предлагает именно обёртку. Однако если у вас есть предложение по решению этой проблемы — я буду рад вас выслушать.
Nullability
В целом, у нас может быть ситуация, когда где-то нам нужен крейт нуллабельный а где-то нет. Для этой проблемы есть решение
val nullableMutableKrate: MutableKrate<Int> = DefaultMutableKrate( factory = { null }, loader = { intSettingsMap["INT_KEY"] }, saver = { value -> if (value == null) intSettingsMap.remove("INT_KEY") else intSettingsMap["INT_KEY"] = value } ) val nonNullableMutableKrate = nullableMutableKrate.withDefault { 102 }
Декораторы и экстеншон функции опять нас спасли.
Теперь, если нам не удалось загрузить значение - по умолчанию будет возвращено 102
Dynamic Keys
Динамичные ключи легко поддерживаются.
fun userKrate(userId: Int): MutableKrate<User?> = DefaultMutableKrate( factory = { 0 }, loader = { runCatching { File("${userId}.json").toUser() } .getOrNull() }, saver = { user -> if (value == null) File("${userId}.json").delete() else File("${userId}.json").also(File::createNewFile).writeText(user.toJson()) } )
Здесь мы читаем информацию о пользователе из какого-то файла. По аналогии можно сделать SQL-запросы.
Suspend and Flow
Для кеширования придется передавать CoroutineContext, на котором будет выполнена загрузка самого первого значения.
По умолчанию будет взято значение из фабрики.
val nonNullableStateFlowSuspendMutableKrate = DefaultStateFlowSuspendMutableKrate( factory = { null }, loader = { intSettingsMap["INT_KEY"] }, saver = { value -> if (value == null) intSettingsMap.remove("INT_KEY") else intSettingsMap["INT_KEY"] = value }, coroutineContext = Dispatchers.IO )
Как видно, API идентичное предыдущему. За исключением того, что функции здесь — suspend
Экстеншоны, конечно же, тоже аналогичные.
Flow
Присутствует поддержка Flow. При этом, если вы используете один DataStore, то несколько Крейтов у вас будут синхронизованы между собой.
private val dataStore: DataStore<Preferences> = TODO() val flowKrate = DefaultFlowMutableKrate( factory = { 10 }, loader = { dataStore.data.map { it[key] } }, saver = { value -> dataStore.edit { preferences -> preferences[key] = value } } )
В данном случае в loader здесь передается Flow, а не просто единоразовый метод для загрузки.
State? Flow?
Но это ведь будет просто Flow. А я хочу StateFlow. Что мне делать?
Разумеется не волноваться, ведь это уже предусмотрено
val stateFlowValue = flowKrate.stateFlow(GlobalScope)
Расширяемость
Давайте проверим гипотезу о расширяемости на прочность. Допустим, мы хотим преобразовать один Крейт в другой. Для этого нам нужен какого-то рода маппинг. Давайте попробуем его создать.
fun <T, K> CachedKrate<T>.map(to: (T) -> K): CachedKrate<K> { return object : CachedKrate<K> { private var _cachedValue = to.invoke(this@map.cachedValue) override val cachedValue: K get() = _cachedValue override fun getValue(): K { return to.invoke(this@map.cachedValue) } } } class Repository(intSettingsMap: MutableMap<String, Int>) { val cachedIntKrate: CachedKrate<Int> = DefaultMutableKrate( factory = { 0 }, loader = { intSettingsMap["INT_KEY"] }, saver = { value -> intSettingsMap["INT_KEY"] = value } ).asCachedKrate() val cachedStringKrate: CachedKrate<String> = cachedIntKrate.map(to = { int -> "${int}_value" }) }
Как видим, с помощью магии анонимных классов, нам удалось создать функцию маппинга для кешированных крейтов.
Использование
StateFlowMutableKrate отлично подходит для использования в ViewModel, особенно при работе с реактивными UI, такими как Jetpack Compose:
class UserViewModel(private val userNameKrate: StateFlowMutableKrate<User>) : ViewModel() { val userStateFlow = userKrate.stateFlow fun onNameChanged(name: String) { userNameKrate.update { name } } fun onNameClear() { userNameKrate.reset() } }
Итоги
Если вы ищете простое и эффективное решение для работы с хранением данных в Kotlin, обратите внимание на KStorage.
Типобезопасный API, мультиплафторм, лёгкость в использовании. Все это делает библиотеку отличным выбором для вашего проекта.
Плюсы:
Унифицированный подход к хранению данных
Простая интеграция с любыми библиотеками
Большой набор удобных Extension-функций
Высокий уровень абстракций, позволяющий писать собственные расширения и декораторы
Отсутствие лишних зависимостей
Минусы
Race Condition - нужно быть осторожны при использовании не реактивных Key-Value хранилищ
Адаптация - нужно будет писать много оберток.
Где найти?
Более подробный туториал и подключение на странице
