Как известно, MVI строится на основе трех компонентов - модели, намерения (действия) и состояния экрана. Логика приложения диктуется пользователем, например, он хочет загрузить картинку в высоком разрешении, и различными внешними эффектами (далее - side-effects), например, внезапной потерей соединения.
Мне хотелось создать механизм, который будет способен обрабатывать намерения пользователя и их следствия (что-то внешнее, например ответ от api). Для этого я решил создать три интерфейса-маркера, которые будут отвечать за какое-либо событие, состояние экрана и не влияющие на состояние экрана уведомления.
interface Action
interface State
interface News
Подготовка
Как я уже сказал, у экрана приложения имеется свой State, способный меняться из-за действий пользователя и внешних воздействий. Было решено создать некую сущность, способную содержать пути наружу (в DI модуле конфигурируем как хотим) и обработчик side-effects, чтобы иметь инструмент для обработки нашей бизнес логики в ViewModel.
open class Store<S: State, A: Action, N: News> @Inject constructor(
val apiRepository: ApiRepository,
val preferencesRepository: PreferencesRepository,
val logger: JuleLogger,
val resources: Resources
) {
var middlewares: List<Middleware<A>> by notNull()
var reducer: Reducer<S, A, N> by notNull()
}
Тут нужно остановиться - я упоминал обработчик внешних эффектов. Где он тут находится?
Обработка событий
Как мы видим, в Store находится какое-то количество Middleware и Reducer. Мы уже знаем, что side-effects должны нести в себе какие-то данные, чтобы изменить UI, почему бы тогда каждую бизнес задачу не вынести в отдельный Middleware и держать в Store их коллекцию и обрабатывать каждый из них, принимая результат и возвращая State нашего экрана? Этим и будет заниматься Reducer.
abstract class Middleware<A: Action>(store: Store<*, *, *>) {
protected val apiRepository: ApiRepository = store.apiRepository
protected val preferencesRepository: PreferencesRepository = store.preferencesRepository
protected val logger: JuleLogger = store.logger.apply { connect(javaClass) }
protected val resources: Resources = store.resources
abstract suspend fun effect(action: A): A?
suspend operator fun invoke(action: A) = effect(action)
}
interface Reducer<S: State, A: Action, N: News> {
fun reduce(state: S, action: A): Pair<S?, N?>
operator fun invoke(state: S, action: A) = reduce(state, action)
}
Middleware содержит доступ к "ручкам", чтобы иметь возможность порождать новые Action и абстрактный метод effect(), чтобы выполнить его там.
Внимательный читатель заметил, что Middleware не просто порождает какой-то Action, но и принимает его в качестве параметра. Как я уже говорил, внешние эффекты могут быть вызваны пользователем напрямую, например явным запросом в сеть. Но ведь одни side-effects способны вызывать другие и наоборот. Мы хотим, чтобы каждый результат бизнес-задачи был "услышан" всеми остальными и, если необходимо, последовал новый side-effect. (Это вовсе не обязательно, для этого возвращаемый тип nullable)
C доменным слоем нашего приложения разобрались, теперь нужно обеспечить преобразование пришедшей извне информации на UI. Как я уже упоминал, этим будет заниматься Reducer. Что для этого нужно, помимо текущего State экрана? Очевидно, виновник торжества - Action, именно то, что будут возвращать нам Middleware'ы. Таким образом, Reducer принимает side-effect и исходя от него, меняет текущий State и вдобавок может выкинуть какое-то уведомление - News.
А что в ViewModel?
Давайте взглянем на базовую ViewModel, из важного тут - метод bind() - он будет отвечать за обработку нашего UI. В параметре приходит интерфейс MviView, на который подписан холдер данной ViewModel, в котором просто реализована отрисовка приходящих State и News.
abstract class BaseViewModel<S: State, A: Action, N: News>: ViewModel() {
private val backgroundScope = CoroutineScope(IO + SupervisorJob())
@Inject
lateinit var logger: JuleLogger
abstract val stateFlow: MutableStateFlow<S>
abstract val newsFlow: MutableSharedFlow<N>
abstract val actionFlow: MutableSharedFlow<A?>
abstract val store: Store<S, A, N>
override fun onCleared() {
super.onCleared()
backgroundScope.coroutineContext.cancelChildren()
}
fun obtainAction(action: A) {
backgroundScope.launch {
actionFlow.emit(action)
}
}
fun obtainState(state: S) {
stateFlow.value = state
}
fun bind(foregroundScope: LifecycleCoroutineScope, mviView: MviView<S, N>) {
logger.connect(javaClass)
with(foregroundScope) {
launch {
stateFlow
.onEach(mviView::renderState)
.catch { logger.logException(it) }
.collect()
}
launch {
newsFlow
.onEach(mviView::renderNews)
.catch { logger.logException(it) }
.collect()
}
}
}
}
У читателя возникает логичный вопрос - раз у нас есть механизм, который все обрабатывает, и ViewModel, посылающая эти обработанные данные на UI, то как нам обеспечить, чтобы эти данные таки доходили наших state и news flow? Все это происходит в конструкторе.
init {
backgroundScope.launch {
actionFlow
.filterNotNull()
.transform {
store.middlewares.forEach { middleware ->
val effect = middleware.effect(it)
effect?.let {
logger.log("${middleware.javaClass.simpleName} effects $it")
emit(it)
}
}
}.flowOn(Default).combine(stateFlow) { a, s ->
val (reducedState, reducedNews) = store.reducer.reduce(s, a)
// Пришедший State
reducedState?.let {
stateFlow.value = it
}
// Пришедший News
reducedNews?.let {
newsFlow.emit(it)
}
}.catch {
logger.logException(it)
}.collect()
}
}
В этом фрагменте, пожалуй, находится ядро нашего "механизма". Каждый Action в блоке transform() уведомляет все Middleware о себе, а затем, комбинируясь с нашим state flow, проходит Reducer'а и как результат stateFlow и newsFlow потенциально получают какое-то значение и отправляются на отрисовку в UI. А что самое важное, другие Middleware обрабатывают этот Action и потенциально порождают новые изменения в UI в дальнейшем.
Каково это на практике?
Для примера возьмем типичный экран "Профиль". Для простоты позволим пользователю совершать logout и загрузку содержимого профиля. Так будут выглядеть State, Action и News:
sealed class ProfileState: State {
// Состояние экрана декларируется через ProfileModel,
// по сути отражающую наличие контента и navDirections,
// если мы хотим куда-то двигаться по приложению
data class Default(
val profile: ProfileModel? = null,
val navDirections: NavDirections? = null,
): ProfileState()
}
sealed class ProfileNews: News {
// Простое уведомление
data class Message(val duration: Int, val content: String): ProfileNews()
}
sealed class ProfileAction: Action {
// Намерения пользователя
object FetchProfile: ProfileAction()
object Logout: ProfileAction()
// То, что приходит как результат работы middleware
data class FetchProfileDone(
val profile: ProfileResponse? = null,
val interpretedError: InterpretedError? = null
): ProfileAction()
object LogoutDone: ProfileAction()
}
Теперь взглянем на то, как будут "общаться" Reducer и Middleware
// Logout
class LogoutMiddleware(store: Store<*, *, *>): Middleware<ProfileAction>(store) {
override suspend fun effect(action: ProfileAction): ProfileAction? {
var effect: ProfileAction? = null
with(action) {
// Реагируем только на этот Action
if (this is ProfileAction.Logout) {
CoroutineScope(Dispatchers.IO).launch {
preferencesRepository.clearAccessToken()
preferencesRepository.clearRefreshToken()
}
effect = ProfileAction.LogoutDone
}
}
// Возвращаем side-effect
return effect
}
}
// Загрузка профиля
class GetProfileMiddleware(store: Store<*, *, *>): Middleware<ProfileAction>(store) {
override suspend fun effect(action: ProfileAction): ProfileAction? {
var effect: ProfileAction? = null
with(action) {
// Реагируем только на этот Action
if (this is ProfileAction.FetchProfile) {
// Выполняем какой-то запрос в сеть
doRequest(
responseAsync = {
apiRepository.getProfile()
},
onOk = {
effect = ProfileAction.FetchProfileDone(profile = this)
},
onApiErrorStatus = {
effect = ProfileAction.FetchProfileDone(interpretedError = this)
},
onException = {
effect = ProfileAction.FetchProfileDone(interpretedError = this)
},
)
}
}
// Возвращаем side-effect
return effect
}
}
// Обработка приходящих Action'ов
class ProfileReducer: Reducer<ProfileState, ProfileAction, ProfileNews> {
override fun reduce(state: ProfileState, action: ProfileAction): Pair<ProfileState?, ProfileNews?> {
var reducedState: ProfileState? = null
var reducedNews: ProfileNews? = null
// Меняем State в зависимости от Action
when (action) {
is ProfileAction.LogoutDone -> {
// Перемещаемся в фрагмент авторизации
reducedState = ProfileState.Default(
navDirections = ContainerFragmentDirections.containerAuth()
)
}
is ProfileAction.FetchProfileDone -> {
// Вставляем модель в State и никуда не перемещаемся
reducedState = ProfileState.Default(
profile = action.profile?.profile?.let { ProfileModel(it) },
navDirections = null
)
}
}
// Возвращаем потенциальные State и News
return reducedState to reducedNews
}
}
И для полноты картины взглянем на части кода фрагмента
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logger.connect(javaClass)
with(profileViewModel) {
bind(viewLifecycleOwner.lifecycleScope, this@ProfileFragment)
// Сразу выполняем загрузку профиля
lifecycleScope.launch {
obtainAction(ProfileAction.FetchProfile)
}
}
// По клику на кнопку выхода осуществляем выход
btnLogout.click {
viewLifecycleOwner.lifecycleScope.launch {
profileViewModel.obtainAction(ProfileAction.Logout)
}
}
}
override fun renderState(state: ProfileState) {
when (state) {
is ProfileState.Default -> {
state.navDirections?.let {
navigate(it)
profileViewModel.obtainState(state.copy(null))
}
state.profile?.let {
tvFriendsCount.text = it.friends.size.toString()
tvLogin.text = it.login
tvName.text = it.name
}
}
}
}
Комментарии (6)
zakkav
29.09.2021 17:16Не знаю почему, но от MVI reducer-ов и sealed class-ов просто кровь из глаз, нечитаемо
keekkenen
01.10.2021 12:22а можно примерный проект на основе данных классов, а то так трудно понять в некоторые идеи (котлина) без связанного кода
qbaddev
Спасибо, очень полезный пост.