Это краткая заметка о подходе, который я выработал для себя, чтобы обновлять состояние экрана при использовании 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() )
Проблемы
При таком подходе есть две проблемы.
-
Если
Stateсодержит много вложенных классов, то можно быстро утонуть в конструкциях вида:
state = state.copy( user = state.user.copy( balance = state.user.balance.copy(total = 100.0) ) )
-
Работа с асинхронным кодом при обновлении состояния может привести к невалидному состоянию. В момент вызова
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/
Добавить комментарий