[Перевод] Key-Value Хранилище на Стероидах

645ff8cb70e7f74669b1b7b992b76657

Устали писать методы save/read/reset для каждого key-value хранилища в вашем репозитории, прям как в этом интерфейсе?

interface SplashRepository {
    fun isFirstLaunch(): Boolean
    fun setFirstLaunch(value: Boolean)
    fun resetFirstLaunch()
}

Или, возможно, вам нужно что-то подобное, но в разных репозиториях:

interface LocalUserRepository {
    fun getLocalUserInfo(): User?
}
interface UserUpdateRepository {
    fun updateUserInfo(user: User)
}

И вы не можете шарить этими интерфейсы между модулями, потому что так сказал ваш тимлид?

KStorage

Зачем плодить бойлерплейт, если можно использовать путь библиотек Kstorage и передавать Krate куда угодно, как в этом примере:

// Представим что у нас есть ViewModel, которая отвечает за редактирование пользовательской информации локально
class ViewModel(userKrateProvider: () -> MutableKrate) {
    // Создаем свой krate
    private val userKrate = userKrateProvider.invoke()

    // Грузим последнее значение
    private val _userStateFlow = MutableStateFlow(userKrate.cachedValue)
    val userStateFlow = _userStateFlow.asStateFlow()

    // Обновляем значение
    fun onUserSave(user: User) {
        userKrate.save(user)
        _userStateFlow.update { userKrate.cachedValue }
    }

    // Полностью очищаем krate
    fun onUserDeleted() {
        userKrate.reset()
        _userStateFlow.update { userKrate.cachedValue }
    }

    // Представим, что здесь еще несколько функций
    // Одна из функций меняет имя пользователя
    fun updateSomeUserProperty(name: String) {
        _userStateFlow.update { it.copy(name = name) }
    }
}

«Но в чистой архитектуре вы должны использовать use case, который использует репозиторий, который использует…» —
Во-первых, Krate, MutableKrate — это уже интерфейсы, которые можно замокать. Во-вторых, это всего лишь пример. Вы можете
использовать Krate, как захотите.

В любом случае, этот пример показывает, что иногда вам не понадобятся use cases или репозитории для Krate. Потому что
они уже отделили вашу логику работы с data-слоем. Вы не видите, как они загружаются или сохраняются. Вы получаете готовый
результат. Его даже можно замапить.

Например, Krate может смаить вашу UserModel в ServerUserModel внутри. Но на уровне представления вы будете использовать только UserModel. Вы не будете вообще иметь понятия что есть ServerUserModel.

Маппинг

Дисклеймер: этот пример притянут за уши!

Представьте, что сервер отправляет нам данные пользователя в виде строк. И нам нужно кэшировать модель на устройстве.

// Наша мапнутая модель для Presentation слоя
data class UserModel(val name: String, val age: Int, val salary: Int)
// Модель для data-слоя
class ServerUserModel(val name: String, val age: String, val salary: String)

// Создаем Krate для ServerUserModel
// Представьте, что settings здесь — это обычное хранилище ключ-значение.
// Это может быть SharedPreferences или что-то другое
internal class ServerUserModelKrate(
    settings: Settings,
    key: String,
) : MutableKrate by DefaultMutableKrate(
    factory = { null },
    loader = {
        runCatching {
            ServerUserModel(
                name = settings.requireString("${key}_NAME"),
                age = settings.requireString("${key}_AGE"),
                salary = settings.requireString("${key}_SALARY"),
            )
        }.getOrElse { ServerUserModel("", "", "") }
    },
    saver = { serverUserModel ->
        settings["${key}_NAME"] = serverUserModel.name
        settings["${key}_AGE"] = serverUserModel.age
        settings["${key}_SALARY"] = serverUserModel.salary
    }
)

// Теперь нам нужно сопоставить его с UserModel
class UserModelKrate(
    serverUserModelKrate: MutableKrate
) : MutableKrate by DefaultMutableKrate(
    factory = { null },
    loader = {
        val serverModel = serverUserModelKrate.loadAndGet()
        UserModel(
            name = serverModel.name,
            age = serverModel.age.toIntOrNull() ?: 0,
            salary = serverModel.salary.toIntOrNull().orEmpty() ?: 0
        )
    },
    saver = { userModel ->
        val serverModel = ServerUserModel(
            name = userModel.name.toString(),
            age = userModel.age.toString(),
            salary = userModel.salary.toString()
        )
        serverUserModelKrate.save(serverModel)
    }
)

Вы можете сказать, что это немного избыточно, и да, это действительно немного избыточно в одном месте, чтобы использовать это во всем проекте, вместо того чтобы избыточно использовать в куче других мест, в каждом репозитории/usecase.

На самом деле было бы удобнее использовать что-то вроде Mapper extension:

fun  Krate.map(to: (T) -> K, from: (K) -> T): Krate = TODO()

Стандартное ранилище ключ-значение

Пример выше явно притянут за уши. Вряд ли вы будете маппить вашу модель хранилища ключ-значение с серверными моделями и наоборот.

Более распространенное использование — это обычное хранилище ключ-значение.

С использованием SharedPreferences вы можете читать свойства по ключу. С kstorage на самом деле то же самое, если говорить о внутренних кишках, но, конечно, в результате вы получаете модель полноценного класса:

enum class ThemeEnum { DARK, LIGHT, }
// Тема это у нас Enum, а не просто число
// А lastLaunchTime так вообще java Instant
data class Settings(
    val theme: ThemeEnum,
    val isFirstLaunch: Boolean,
    val lastLaunchTime: Instant
)
class SettingsKrate(
    settings: Settings,
    key: String,
) : MutableKrate by DefaultMutableKrate(
    factory = { null },
    loader = {
        Settings(
            theme = let {
                val ordinal = settings.getInt("${key}_theme", 0)
                ThemeEnum.entries[ordinal]
            },
            lastLaunchTime = let {
                val epochSeconds = settings.getLong("${key}_last_launch_time", Instant.now().epochSecond)
                Instant.ofEpochSecond(epochSeconds)
            },
            isFirstLaunch = settings.getBoolean("${key}_is_first_launch", true),
        )
    },
    saver = { settingsModel: Settings ->
        settings.put("${key}_theme", settingsModel.theme.ordinal)
        settings.put("${key}_last_launch_time", settingsModel.lastLaunchTime.epochSecond)
        settings.put("${key}_is_first_launch", settingsModel.isFirstLaunch)
    }
)

Отлично! Теперь вы можете использовать вашу модель SettingsModel где угодно без бойлерплейта. Более того, ключи для этого ключ-значение стора находятся только в одном месте (не забудьте вынести их в константы).

Nullability

Nullable — это замечательная вещь, но нужно ли создавать несколько krates для nullable и null-safe моделей? Конечно нет. Есть расширение withDefault { TODO() }, которое поможет.

В примере ниже мы испольуем паттерн декортатор. У нас будет MutableKrate над другим MutableKrate. И установим 10 в качестве значения по умолчанию.

val nullableKrate: MutableKrate = TODO()
val nullSafeKrate = nullableKrate.withDefault { 10 }

Suspend krates

«Подождите, я использую androidx.DataStore. Как я могу реализовать krates, если приведенные выше примеры только для не-суспенд функций?»

Вы можете использовать DataStore с suspend krates. Есть SuspendMutableKrate, FlowKrate и другие для Flow хранилищ ключ-значение. Все они, кстати, поддерживают кэширование.

internal class DataStoreFlowMutableKrate(
    key: Preferences.Key,
    dataStore: DataStore,
    factory: ValueFactory,
) : FlowMutableKrate by DefaultFlowMutableKrate(
    factory = factory,
    loader = { dataStore.data.map { it[key] } },
    saver = { value ->
        dataStore.edit { preferences ->
            if (value == null) preferences.remove(key)
            else preferences[key] = value
        }
    }
)
// Инициализируем значение krate с дефолтным значением
val intKrate = DataStoreFlowMutableKrate(
    key = intPreferencesKey("some_int_key"),
    dataStore = dataStore,
    factory = { null }
).withDefault(12)

Заключение

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

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

Существует также похожая библиотека под названием KStore, которая может быть полезна в подобных случаях.

Ссылки

  • KStorage

  • KStore

  • Sample — Сэмпл, который использует kstorage в качестве альтернативы SQLite (для JS).

© Habrahabr.ru