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 хранилищ
Адаптация - нужно будет писать много оберток.
Где найти?
Более подробный туториал и подключение на странице