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 }
)

Зачем это нужно?

  1. Унификация доступа. Независимо от платформы или библиотеки для Key-Value хранилища, вы всегда будете работать с единым API.

  2. Тестируемость. Не нужно тянуть реальные зависимости в юнит-тесты. Все это можно замокать.

  3. Расширяемость. Сама библиотека построена на экстеншон-функциях, что обеспечивает высокую гибкость. Оборачивайте в "Коробки" что угодно — от Map<T,V> до SQL запросов!

  4. Типобезопасность. API обеспечивает проверку типов на этапе компиляции, снижая вероятность ошибок.

  5. Легковесность. Библиотека крайне легковесная. Она содержит только сам язык и 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 хранилищ

  • Адаптация - нужно будет писать много оберток.

Где найти?

Более подробный туториал и подключение на странице