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 хранилищ

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

Где найти?

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

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


  1. Manul
    18.05.2025 21:12

    Современные библиотеки для работы с NoSQL обычно предоставляют готовые реализации Map<K, V>. Если их нет, то сделать реализацию Map с нужными методами, бросая UnsupportedOperationException из ненужных, дело пары минут. Этот контракт гораздо более применим по всей экосистеме JVM, чем новые обертки. Может у android'щиков такая нужда в них есть (зачем?), но точно не на бекенде.

    P.S. Поставил плюсик за статью по программированию на сайте для гуманитариев))


    1. makeevrserg Автор
      18.05.2025 21:12

      Не совсем понял, при чем тут NoSql. Но окей. Допустим, вы используете одну либу для NoSql. У нее одна реализация для Map. Кто-то захотел другую либу для NoSql взять. Там уже другая обёртка. А вам нужно хранить одинаковый тип данных. С Крейтами вы просто оборачивание это в крейт и у вас код, где использовались Крэйты раньше, никак не меняется. Как и писал, это нужно для увеличения уровня абстракции.

      Речь конечно не конкретно про NoSql. Тут именно подход для Key-Value хранения. В андроиднвх библиотеках, вроде как и у всех, абстракции разные. А Крэйты просто унифицируют этот доступ


      1. Manul
        18.05.2025 21:12

        Да, я понял что вы сделали абстракцию на уровне отдельных элементов. Но на мой взгляд это лишено смысла в силу существования Map<K, V> как абстракции для k-v хранилищ вообще. Поэтому привел пример что она уже и так решает поставленную проблему

        Допустим, вы используете одну либу для NoSql. У нее одна реализация для Map. Кто-то захотел другую либу для NoSql взять. Там уже другая обёртка. А вам нужно хранить одинаковый тип данных.

        И вот здесь как раз проблемы не возникнет, все методы принимают интерфейс Map<K, V> и оперируют им, ничего менять не придется. А "крейтами", представьте сколько оверхеда на отдельный элемент?

        Может быть, просто не понимаю... Смогли бы вы привести пример кода до и после, где ваши крейты делают его лучше?


        1. makeevrserg Автор
          18.05.2025 21:12

          Нет, не все принимает на вход Map. Exposed - это вообще либа для SQL. Джавовские файлы тоже не принимают на вход Map. Jetpack DataStore не принимает на вход мама, к тому же там вообще флоу.

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

          На примере рассмотрим. Допустим, раньше хранили данные пользователя в SharedPreferences в жосне. Данные стали слишком большими, префоф стало недостаточно. Как хорошо что у нас были Крэйты. Мы теперь будем хранить данные пользователя локально в файле, а верхнеуровневый при останется такой же каким был. То же самое и с датастором и SQL


        1. makeevrserg Автор
          18.05.2025 21:12

          Ну и плюс не особо понятно, зачем тут вообще Map? У мапы совершенно другое применение. Вы видимо неверное вычитали смысл этой библиотеки. Спорить с тем, что я мог плохо это объяснить в статье не буду, так скорее всего и есть


  1. kirich1409
    18.05.2025 21:12

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


    1. makeevrserg Автор
      18.05.2025 21:12

      В этом и задумка. У каждого кейсы разные.

      Кто-то в файле хочет хранить, кто-то в MultiplatformSettings, кто-то в DataStore. А написать код, который это выполняет, можно очень по-разному