Безопасное обновления состояния в ViewModel

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Это краткая заметка о подходе, который я выработал для себя, чтобы обновлять состояние экрана при использовании MVI-like паттерна в ViewModel.

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

Вводные

Предположим, у нас есть базовая ViewModel такого вида (опущу работу с эффектами и прочее, т.к. для примера это излишне):

abstract class BaseViewModel<State : Any>(initialState: State) : ViewModel() {

    private val _state = MutableStateFlow(initialState)

    protected var state: State
        get() = _state.value
        set(value) {
            _state.value = value
        }

    fun states() = _state.asStateFlow()

}

И моделька состояния экрана:

data class ProfileScreenState(
    val user: User,
    val notificationsNumber: Int
) {
    data class User(
        val name: String,
        val balance: Balance
    ) {
        data class Balance(
            val total: Double,
            val accounts: Map<Int, Double>
        )
    }
}

Для обновления состояния внутри ViewModel мы пишем что-то такое:

state = state.copy(
    user = state.user.copy(balance = getBalance()),
    notificationsNumber = fetchNotificationsNumber()
)

Проблемы

При таком подходе есть две проблемы.

  1. Если State содержит много вложенных классов, то можно быстро утонуть в конструкциях вида:

state = state.copy(
	user = state.user.copy(
		balance = state.user.balance.copy(total = 100.0)
	)
)
  1. Работа с асинхронным кодом при обновлении состояния может привести к невалидному состоянию. В момент вызова state.copy происходит захват состояния, и если дальше мы где-то вызываем корутину, то к моменту окончания её выполнения захваченное состояние уже может быть неактуальным.

suspend fun fetchUser(): User { /* ... */ }

suspend fun fetchNotificationsNumber(): Int { /* ... */ }

fun updateUser() {
    viewModelScope.launch {
        state = state.copy(
            user = fetchUser()
        )
    }
}

fun updateNotifications() {
    viewModelScope.launch {
        state = state.copy(
            notificationsNumber = fetchNotificationsNumber()
        )
    }
}

Если функции updateUser и updateNotifications были вызваны одновременно, то с большой вероятность победит какая-то одна из них. То есть мы или получим актуального юзера, или актуальное количество уведомлений, но не то и другое вместе.

Второй случай решается вызовом асинхронных операций до обновления состояния:

val notificationsNumber = fetchNotificationsNumber()
state = state.copy(notificationsNumber = notificationsNumber)

Но об этом надо либо помнить, либо озаботиться линтерами.

Простое решение

Обе проблемы решаются банально, но не без своих недостатков.

Первую можно решить избавившись от вложенных классов и сделав State максимально "плоским":

data class ProfileScreenState(
    val userName: String,
    val totalBalance: Double,
    val accountBalances: Map<Int, Double>,
    val notificationsNumber: Int
)

Минус очевиден: на сложных компонентах такое состояние может вырасти до десятков полей и ориентироваться в нём будет не так просто.

Вторая проблема тоже не rocket science, просто переносим обновление состояния в лямбду внутри которой запрещаем вызов корутин:

// BaseViewModel

fun updateState(block: (State) -> State) {
    _state.update(block)
}

// Использование

updateState { state ->
    state.copy(
        notificationsNumber = notificationsNumber
    )
}

Для меня минус в получившемся синтаксисе. Это уже не красивое присваивание в свойство класса, а вызов функции. Минус спорный, но это мои загоны :)

Моё решение

Я решил две этих проблемы двумя плагинами (да не переусложняю я!).
Во-первых Arrow, который реализует концепцию оптики из функционального программирования.
Во-вторых плагина от JetBrains, позволяющего переопределять оператор присваивания (=).

Сразу покажу, как теперь выглядит обновление состояния в моём коде:

val freshUser = fetchUser()
val freshNotificationsNumber = fetchNotificationsNumber()
state = {
    State.user.totalBalance set freshUser.totalBalance
    State.notificationsNumber set freshNotificationsNumber
}

Как видно, это решает первую проблему, потому что больше нет необходимости писать множественные вложенные copy. А также решает вторую, потому что внутри фигурных скобок не получится вызвать корутину, и мы вынуждены получить все данные перед обновлением состояния. При этом сохранился синтаксис с присваиванием значения в свойство класса.

Реализация

Нам понадобится аннотация, которую нужно навешивать на классы состояния, для переопределения операции присваивания:

@Suppress("ClassName")
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
annotation class assigned

Название аннотации можно выбрать на свой вкус.

Далее подготовим базовую ViewModel. Добавляем зависимость на Arrow Optics:

implementation("io.arrow-kt:arrow-optics:2.0.0")

И изменияем ViewModel:

abstract class BaseViewModel<State : Any>(initialState: State) : ViewModel() {

    private val _state = MutableStateFlow(initialState)

    protected val state: State
        get() = _state.value

    fun states() = _state.asStateFlow()

    protected fun State.assign(@BuilderInference block: Copy<State>.() -> Unit) {
        _state.update { it.copy(block) }
    }

}

Так как аннотации должны быть у конечного класса состояния, я использую gradle-скрипт, который подключает все необходимые зависимости в фиче-модуль:

// buildSrc/src/main/kotlin/optics-setup.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask

plugins {
    id("kotlin-multiplatform")
    id("com.android.library")
    id("com.google.devtools.ksp")
    id("org.jetbrains.kotlin.plugin.assignment")
}

kotlin {
    sourceSets {
        commonMain {
            kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
            dependencies {
                implementation("io.arrow-kt:arrow-optics:2.0.0")
            }
        }
    }
}

dependencies {
    add("kspCommonMainMetadata", "io.arrow-kt:arrow-optics-ksp-plugin:2.0.0")
}

assignment {
    annotation("org.app.example.assigned")
}

project.tasks.withType(KotlinCompilationTask::class.java).configureEach {
    if (name != "kspCommonMainKotlinMetadata") {
        dependsOn("kspCommonMainKotlinMetadata")
    }
}

Подключаем его в фиче-модулях:

plugins {
	// ...
    id("optics-setup")
}

И теперь всё что нам остается, это повесить на классы стейта нужные аннотации и написать пустые companion object:

@optics
@assigned
data class ProfileScreenState(
    val user: User,
    val notificationsNumber: Int
) {
    companion object

    @optics
    data class User(
        val name: String,
        val balance: Balance
	) {
        companion object

        @optics
        data class Balance(
            val total: Double,
            val accounts: Map<Int, Double>
		) {
            companion object
		}
	}
}

Вывод

Я ни в коем случае не навязываю использование такого подхода. Из его минусов могу выделить:

  • Зависимость от двух плагинов. Но Arrow существует и развивается столько, сколько я знаю Kotlin, а второй плагин — от JetBrains;

  • Небольшой бойлерплейт в виде двух аннотаций, которые надо не забывать объявлять для классов состояния;

  • Специфический синтаксис.

Но если вам близко моё видение, надеюсь, эта заметка будет полезной и упростит ваш опыт разработки.

Источник: https://habr.com/ru/articles/880108/


Интересные статьи

Интересные статьи

Если ваша реклама не работает, тому есть 3️ глобальных причины. Я называю это «Правило 3М».⠀Проверьте свою рекламу по этим параметрам и если, хотя бы один не работает, то вся рекламная кампания потер...
Привет, Хабр!Меня зовут Дмитрий Матлах. Я тимлид в AGIMA. Мы с коллегами обратили внимание, что в сообществе часто возникает вопрос о том, как совместить на одном проекте Bitrix-компоненты и реактивны...
Мне было необходимо делать 2 раза в сутки бэкап сайта на «1С-Битрикс: Управление сайтом» (файлов и базы mysql) и хранить историю изменений за 90 дней. Сайт расположен на VDS под уп...
Аннотированная фотография из набора данных Diversity in Faces от IBM Недавно компания IBM подверглась критике за то, что для обучения нейросетей без разрешения взяла общедоступные фотографии...