Мы активно применяем MVI для проектирования взаимодействия состояния экрана и бизнес-логики. Сегодня хотим рассказать, почему у нас появилась собственная MVI-библиотека — Reduktor.
Предисловие
На сегодняшний день архитектурный подход MVI пользуется большой популярностью, его выбирают разработчики приложений по многим причинам. О том, что такое MVI и почему его следует использовать, написано много, например, статьи Ханнеса Дорфмана в восьми частях. Также можно посмотреть доклад Сергея Рябова «Как приготовить хорошо прожаренный MVI под Android» и прочитать о реализации MVI-библиотеки в Badoo.
Что такое MVI
Вкратце, MVI (Model-View-Intent) — это архитектурный паттерн, который входит в семейство паттернов Unidirectional Data Flow — подход к проектированию системы, в котором всё представляется в виде однонаправленного потока действий и управления состоянием. В отличие от MVVM, MVI подразумевает только один источник данных (Single source of true или SSOT). MVI состоит из трёх компонентов: слоя логики, данных и состояния (Model); UI-слоя, отображающего состояние (View); и намерения (Intent).
Например, если пользователь кликнет на кнопку «Откликнуться на заявку», то клик преобразуется в событие (Intent), необходимое для Model. В этом слое будет выполнен запрос на сервер, а полученный результат обновит состояние экрана. UI-слой в соответствии с новым состоянием скроет кнопку и покажет текст о том, что заявка отправлена.
Как это было в Юле
Рассмотрим на примере с кнопкой «Откликнуться на заявку», как это было реализовано у нас до появления Reduktor. Заранее уточню, что мы в проекте используем RxJava.
Определяем интерфейс-маркер UIEvent
, который отвечает за какое-либо событие на экране, и описываем классы (объекты) событий, которые наследуются от UIEvent
. Слой Model описывается внутри стандартной Android ViewModel, которая может принимать извне события UIEvent
. Внутри ViewModel есть viewStates: Flowable
— состояние экрана. На изменения состояния подписывается слой View.
Intent
interface UIEvent
object RespondClick : UIEvent()
State
data class ServiceDetailViewState(
val isLoading: Boolean,
val isRespondAvailable: Boolean
)
Model
class ServiceDetailViewModel : ViewModel(), Consumer<UIEvent> {
private val viewStateProcessor: BehaviorProcessor<ServiceDetailViewState> = BehaviorProcessor.create()
// Текущее состояние экрана на момент вызова
private val currentViewState: ServiceDetailViewState
get() = viewStateProcessor.value ?: ServiceDetailViewState(false, true)
private fun postViewState(vs: ServiceDetailViewState): Unit = viewStateProcessor.onNext(vs)
// Поток состояний экрана
val viewStates: Flowable<ServiceDetailViewState> = viewStateProcessor.toSerialized()
// Обработка событий извне
override fun accept(event: UIEvent) {
when (event) {
is RespondClick -> handleRespondClick()
}
}
// Обработка клика и изменение состояние экрана
private fun handleRespondClick() {
someNetworkCall()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { postViewState(currentViewState.copy(isLoading = true)) }
.subscribeBy(
onSuccess = { postViewState(ServiceDetailViewState(isRespondAvailable = false)) },
onError = { postViewState(currentViewState.copy(isLoading = false, isRespondAvailable = true)) }
)
}
}
View
class ServiceDetailView {
// ..
// Подписка на изменения состояния
viewModel.viewStates.subscribe { state ->
showLoading(state.isLoading)
showRespondButton(state.isRespondAvailable)
}
// Отправка события RespondClick по клику на кнопку
respondButton.setOnClickListener { viewModel.accept(RespondClick) }
}
Выше описана упрощённая структура, на основе которой было реализовано множество экранов Юлы.
Подробнее о том, как появился MVI в Юле
В 2018-м году в Android разработке понятия MVVM и MVP были на слуху. Для реализации MVVM data-binding был рекомендованным подходом от Google, но параллельно с этим, популярность MVP начинала спадать. Однако, серебряной пули не существует, поэтому были и недостатки, и вопросы к часто встречающимся реализациям данных паттернов.
Что не так с MVP?
В MVP практически никогда не удавалось переиспользовать ни интерфейс Presenter, ни интерфейс View. Это объясняется тем, что в приложении зачастую нет одинаковых экранов, а данные интерфейсы привязаны именно к экрану (или его части).
А что с MVVM?
В MVVM на data-binding генерировалось довольно много кода. Кроме того, разные куски экрана привязывались к разным источникам во ViewModel. О синхронизации между ними обычно не задумывались, что могло приводить к проблемам c UX. Например, на экране проигрывается стартовая анимация в каком-нибудь верхнем блоке, а нижний блок уже получил стандартную ошибку, которую начинал сразу же показывать пользователю.
И главный вопрос - что же такое «Model» в MVP/MVVM? Обычно в примерах кода класс «Model» отсутствовал, присутствовали репозитории. Состояние в репозитории? Состояние во ViewModel/Presenter?
В 2018-м нам попалась серия статей от Hannes Dorfman, где он рассказывал про ключевые особенности паттерна MVI (ViewState, метод render(), reducer, intent() от пользователя) и делал особый акцент на том, что модель должна обеспечивать консистентность данных. К сожалению, оригинал той серии статей не сохранился (автор существенно доработал начальные версии), но получить представление об общей картине можно здесь. Самое существенное отличие — Presenter в примерах кода отсутствовал, была ViewModel.
В это же время в Android-команде «Юлы» случилось пополнение — итого в команде стало аж целых три разработчика вместо 2-х. :) При амбициозном плане по количеству и качеству фич и отсутствии временного ресурса на рефакторинг, стало очевидно, что архитектура нашего проекта должна обеспечивать нам низкий time to market, приемлемое качество кода и невысокий порог входа.
Отсюда можно было выделить следующие требования:
Применимость для различных экранов. TTM сокращается за счёт того, что разработчику проще разобраться, так как разработке нужно поддерживать фичи, построенные по одному шаблону;
Переиспользование всего кода, что только можно (модели/события/репозитории/use-case, и др.);
Unidirectional Data Flow — направление движения события по системе понятно в каждый момент времени, сокращаем время на отладку;
Single Source of Truth — есть некоторое состояние* (поговорим о состояниях ниже), а отображение на экране — производная от него. Искать ошибки в первую очередь следует в компоненте, который отвечает за этот source of truth. UI логика не содержит, а лишь отображает пришедшее состояние —
render(state: ViewState)
;Поддержка реактивной парадигмы: в нашем случае, экраны часто меняются из-за внешних данных. Например, лента меняется, когда пользователь применяет фильтры. Поиск — по вводу запроса или саджеста. Экран заказа может получить обновление статуса заказа с пуша или по веб-сокету. Мы ожидаем некоторого события (или серии событий) и реагируем на это.
MVI подходил под это как нельзя лучше. Однако мы переработали статьи Hannes’а из практических соображений. А именно:
-
Мы не стали получать во ViewModel список
Observable<UserIntent>
и комбинировать их между собой. Вместо этого ViewModel сталаConsumer<UIEvent>
, гдеUIEvent
– интерфейс (изначальноsealed class
), всего того, что происходило во View: это и клики пользователя, и старт сценария, и восстановление экрана, и ответы от внешних sdk (которые зачастую приходят вonActivityResult())
. Таким образом, View взаимодействует с ViewModel через один метод —consume()
(илиaccept()
, если мы берем интерфейс RxJava);override fun accept(event: UIEvent) { when (event) { is FilterUiEvent.Init -> handleInit(event) is BaseUiEvent.SaveState -> handleSaveState(event) is BaseUiEvent.RestoreState ->handleRestoreState(event) ... }
View содержит один метод —
render(state: ViewState)
. View максимально простая, какое состояние пришло, такое и отображается. ViewState мы моделировали и через sealed-классы, и делали их «плоскими» (флажки о загрузке, ошибке, данных для отображения лежат в одном объекте), — все варианты рабочие, огромных преимуществ у какого-то нет;Reducer зачастую заменяло копирование:
data class copy()
. Однако для сложных случаев отдельный класс с методомreduce(state: ViewState, event: UIEvent)
не ленились написать;ViewModel предоставляла
states: Flowable<ViewState>
. Внутри это зачастую было реализовано черезBehaviorProcessor<ViewState>;
Из практических соображений: ViewModel также предоставляла
routeEvents: Flowable<RouteEvent>
иserviceEvent: Flowable<ServiceEvent>.
Специальный компонентRouter: Consumer<RouteEvent>
— является потребителем потока событий навигации, из композиции роутеров строится навигация всего приложения. ServiceEvent служит для событий «fire and forget», которые мы не хотим хранить во ViewState – показы тултипов, toast’ов, диалоги-подсказки и тому подобное;
Дисклеймер:
Мы не призываем вас делать так. Если вы можете вычислить переход (навигировать) по State или вам нужно восстановить показ toast, то используйте state, сможете избежать лишних подписок. В конце концов, это было не академически правильное, а простое и дешёвое решениеViewModel для простых экранов являлась сосредоточением бизнес-логики, и, естественно, проектировалась таким образом, чтобы не быть зависимой на фреймворк Android;
ViewModel обращалась к repository, mapper’ам, комбинировала подписки, меняла треды, копировала итоговое состояние и отсылала его на UI;
Разумеется, для списков сразу применяли DiffUtil, добиваясь оптимальной отрисовки на UI.
Что получилось в итоге реализации:
Стало проще находить ошибки. Нет подписки — см. ViewModel. Данные пришли, но кривой UI — см. View. Кривое состояние — см. логи при копировании/отправки state’а для View;
Это относится и к классам с разной ответственностью, ведь после установления контакта можно отдать это разным разработчикам для сокращения ТТМ;
Переиспользование базовых событий по всему приложению. Общие обработчики для базовых событий;
Общие обработчики для повторяющихся fire-and-forget событий.
После релиза пилотной фичи на MVI мы завели 25 багов и поправили их за 4 часа – нам стало понятно как, где и что конкретно править. Именно это убедило нас в том, что реализация паттерна соответствует нашим требованиям. Но предстоял ещё и рефакторинг основных экранов приложения, который был совмещен с переработкой дизайна и функциональностью. И даже здесь нам удалось всё успешно объединить: рефакторинги, запустить новый дизайн и сделать так, чтобы не упасть по crash-free. Profit!
Время шло, фичи усложнялись, и мы обратили внимание на чистую архитектуру. Стало понятно, что ViewModel более не может сочетать столько ответственности, и переместили бизнес-логику в интеракторы. При этом, некоторые интеракторы были довольно сложными - см. доклад А. Червякова о state-машинах на слое domain. Кроме того, следует понять, что появилось 2 состояния: доменное состояние фичи и ViewState для отображения, который получается в результате маппинга доменного состояния. При этом, у разработчика сохраняется свобода в организации связей интеракторов внутри ViewModel.
Фредерик Брукс считал, что: “...получение архитектуры извне усиливает, а не подавляет творческую активность группы исполнителей”. Давайте добавим в нашу схему недостающий кусочек: а именно, сделаем общий шаблон организации любого количества (use-case’ов / interactor’ов), ViewModel с ViewState, и наших подписок с RouteEvent/ServiceEvent. Этот общий кусочек мы хотели получить в виде фреймворка/библиотеки.
Поиск готового MVI-решения
Конец 2020 / начало 2021-го года. Команда Android-разработки выросла по количеству. Появился запрос на гибкий, простой, небольшой по коду MVI-фреймворк для команды, в котором можно было бы поддержать нужные нам кейсы, внедрить в краткие сроки; который бы не имел ощутимый порог входа.
Мы начали искать готовые open source решения, чтобы встроить их в наш проект. Руководствовались следующими требованиями к коду, написанному на основе готового MVI-решения:
Масштабируемость и независимость от платформы и внешних библиотек. Архитектура должна быть крайне гибкой и расширяемой. Как сказано выше, сейчас у нас в проекте используется RxJava, при этом мы планируем перейти на compose и использовать coroutines в недалеком будущем. Отсюда требование: решение не должно зависеть от сторонних библиотек;
Сопровождаемость. Чем проще исправлять ошибки и управлять проектом после передачи в эксплуатацию, тем легче новым разработчикам поддерживать проект;
Надежность. Внутри реализации системы исключены проблемы многопоточной среды. Единый контракт должен обеспечивать безопасность интерфейсов;
Тестируемость;
Возможность переиспользования;
Легкая встраиваемость в проект;
Детальные и хорошо читаемые логи.
Дополнительные критерий — активное сообщество, поскольку библиотека должна сохранять актуальность, её должны обновлять, проверять и поддерживать в порядке.
Если вы ищете такое решение, то посмотрите статью «Сравниваем готовые решения для реализации MVI-архитектуры на Android», актуальную на 2022 год. В начале 2021 года, мы отмели MVICore от Badoo, как слишком сложный. MVIKotlin не имел такой популярности, как сейчас. Итого, мы завели демо-проект на github, в котором сравнивали RxRedux и первую версию Reduktor, которую нам принёс на рассмотрение наш коллега. Поскольку Reduktor покрывал все наши нужды, в отличие от RxRedux, фреймворк был значительно доработан и состоялось внедрение в проект. В продакшн версия на RxJava существует более года, полёт нормальный.
Про Reduktor
В Reduktor всё взаимодействие происходит через объект Store
. Класс Store
параметризован двумя типами: ACTION
— базовым классом событий, и STATE
— состоянием системы.
Какие параметры есть у Store
:
class Store<ACTION, STATE>(
initialState: STATE,
private val reducer: Reducer<ACTION, STATE>,
initializers: Iterable<Initializer<ACTION, STATE>> = emptyList(),
sideEffects: Iterable<SideEffect<ACTION, STATE>> = emptyList(),
private val logger: Logger = Logger {},
private val newStatesCallback: (state: STATE) -> Unit
)
initialState
— начальное состояние экрана;reducer
— сущность, которая в зависимости от нового действия преобразовывает текущее состояние в новое;initializers
— сущности, которые могут передавать действия из внешних источников. Их может быть любое количество (в том числе 0). У классаInitializer
есть стандартная реализацияActionsInitializer
с публичным полемactions
, через которое необходимо отправлять события методомpost
;sideEffects
— сущности, которые преобразовывают новое действие и текущее состояние в поток новых действий. Их может быть любое количество (в том числе 0). Могут не возвращать новое действие, если оно не требуется. Именно в них необходимо описывать бизнес-логику, например, отправлять запросы в сеть;logger
— сущность для логирования всего, что происходит в системе: какое действие пришло, какое сейчас состояние и какое получилось новое состояние (или что не изменилось). По умолчаниюnull
;newStatesCallback
— коллбэк, в который приходит новое состояние. Внутри системы есть проверка состояний на эквивалентность. Когда создан объектStore
, вnewStatesCallback
сразу придёт актуальное состояние в текущем потоке. Однако дальнейшие обновления могут приходить в других потоках.
Для удобства, у класса Store
есть несколько базовых реализаций для поддержки потока состояний через сущности StateFlow
(Coroutines) и Flowable
(RxJava2, RxJava3).
Принцип работы:
Действие может попасть в систему через
Initializer
. Это может быть событие от взаимодействия с пользователем или любое другое внешнее событие (например, новое сообщение из сокета).Далее действие проходит через
Reducer
. Тут оно может повлиять на изменение состояния. Если состояние изменилось, то информация об этом будет отправлена тем, кто на него подписался.После этого действие и новое (либо не изменившееся) состояние попадают в
SideEffect
. Оттуда могут возвращаться новые цепочки с действиями. Подписываемся на них и ожидаем новые действия, которые будут снова отправлены вReducer
. ВSideEffect
можно создавать и выполнять фоновые задачи, результатом работы которых становится новое действие.
Reduktor на примере
В этом разделе разберем принцип работы Reduktor на примере экрана со списком пользователей. Он может иметь три состояния: загрузка, ошибка загрузки и успешно полученный из сети список пользователей. По клику на элемент в списке будет открываться webview. Для распараллеливания будем использовать RxJava. Состояние такого экрана:
data class FeatureViewState(
val isLoading: Boolean = false,
val error: Throwable? = null,
val data: List<User> = listOf()
)
Теперь определим действия, в нашей системе они могут быть двух типов:
внешние, исходящие от пользователя —
FeatureViewAction
;внутренние, исходящие внутри системы, например, результат загрузки данных —
FeatureSideAction
.
Все действия наследуем от интерфейса FeatureAction
, чтобы потом им параметризовать другие сущности Reduktor:
interface FeatureAction
sealed class FeatureViewAction : FeatureAction {
object Init : FeatureViewAction()
object Retry : FeatureViewAction()
class Click(val user: User) : FeatureViewAction()
}
sealed class FeatureSideAction : FeatureAction {
class Data(val data: List<String>) : FeatureSideAction()
class Error(val error: Throwable) : FeatureSideAction()
}
В ответ на действия FeatureViewAction.Init
и FeatureViewAction.Retry
должна начаться загрузка пользователей, в ответ на FeatureViewAction.Click
должна открываться webview. Всё это будет происходить в одном side-эффекте. Для этого необходимо наследоваться от функционального интерфейса SideEffect
и переопределить метод invoke
:
class FeatureLogicSideEffect : SideEffect<FeatureAction, FeatureViewState> {
override fun Environment<FeatureAction>.invoke(action: FeatureAction, state: FeatureViewState) {
// ..
}
}
Метод invoke
принимает текущее действие (action
) и актуальное состояние (state
).
Сущность Environment
предоставляет доступ к задачам (tasks
) и действиям (actions
). Чтобы в Reduktor создать фоновую задачу, необходимо наследоваться от класса Task
и поместить в массив tasks
. Завершить выполнение работы и отменить все задачи можно через метод release()
.
Для того, чтобы начать загрузку экрана, определим задачу tasks[”load_data”]
:
private fun Environment<FeatureAction>.loadData() {
tasks["load_data"] = repository.loadData()
.subscribeOn(Schedulers.io())
.toTask(
onSuccess = { actions.post(FeatureSideAction.Data(it)) },
onError = { actions.post(FeatureSideAction.Error(it)) }
)
}
Ключ задачи задаётся для её отмены с таким же ключом. Метод toTask
преобразует тип Single (RxJava) в тип Task
(сущность задачи в Reduktor). В обратных вызовах onSuccess
и onError
отправляем действия результатов загрузки в систему с помощью actions.post
.
Финальный FeatureLogicSideEffect
будет выглядеть так:
class FeatureLogicSideEffect(
private val repository: FeatureRepository,
private val router: FeatureRouter
) : SideEffect<FeatureAction, FeatureViewState> {
override fun Environment<FeatureAction>.invoke(action: FeatureAction, state: FeatureViewState) {
when (action) {
is FeatureViewAction.Init,
is FeatureViewAction.Retry -> loadData()
is FeatureViewAction.Click -> router.openBrowser(action.user.url)
}
}
private fun Environment<FeatureAction>.loadData() {
tasks["load_data"] = repository.loadData()
.subscribeOn(Schedulers.io())
.toTask(
onSuccess = { actions.post(FeatureSideAction.Data(it)) },
onError = { actions.post(FeatureSideAction.Error(it)) }
)
}
}
В этом side-эффекте мы также определили, что будет происходить с системой по клику на элемент в списке (см. FeatureViewAction.Click
).
Нюансики
Давайте немного отвлечёмся от нашего примера и рассмотрим нюансы, которые могут встретиться в side-эффектах.
-
Зацикливание. Не уникальная для Reduktor проблема, система может зациклиться, если вы обработали действие и отправили такое же обратно:
override fun Environment<FeatureAction>.invoke(action: FeatureAction, state: FeatureViewState) { when (action) { is FeatureAction.Init -> { doSomething() actions post FeatureAction.Init // Зацикливание! } } }
-
Неактуальное состояние. Нужно помнить, что состояние актуально только в момент вызова метода
invoke
. Если попытаться забрать данные из поляstate
после переключения потока, то в системе они могут оказаться уже другими. Нужно разделить такие блоки и использовать актуальное состояние из следующей итерации:override fun Environment<FeatureAction>.invoke(action: FeatureAction, state: FeatureViewState) { when (action) { is FeatureAction.Load -> { tasks["load_and_save"] = load(state.id) .subscribeOn(ioScheduler) .flatMap { // Обращаемся к state за данными после переключения потока. // На момент вызова id может быть уже другим. return@flatMap save(state.id, it) .subscribeOn(ioScheduler) } .toTask(onSuccess = { actions post FeatureAction.Complete() }) } } }
// Исправляем ситуацию override fun Environment<FeatureAction>.invoke(action: FeatureAction, state: FeatureViewState) { when (action) { is FeatureAction.Load -> { tasks["load_data"] = load(state.id) .subscribeOn(ioScheduler) .toTask(onSuccess = { actions post FeatureAction.Save(it) }) } is FeatureAction.Save -> { tasks["save_data"] = save(state.id, action.param) .subscribeOn(ioScheduler) .toTask(onSuccess = { actions post FeatureAction.Complete() }) } } }
Ранее мы описали состояние экрана, действия и их обработку в SideEffect
. В side-эффекте в свою очередь тоже отправляются новые события.
Для изменения состояния в зависимости от действий в Reduktor есть сущность Reducer
. Реализуем Reducer
для нашего экрана. В зависимости от действия создаём новый state
на основе предыдущего:
class FeatureReducer : Reducer<FeatureAction, FeatureViewState> {
override fun FeatureViewState.invoke(action: FeatureAction): FeatureViewState {
val state = this
return when (action) {
is FeatureViewAction.Init,
is FeatureViewAction.Retry -> state.copy(isLoading = true, error = null)
is FeatureSideAction.Data -> state.copy(isLoading = false, error = null, data = action.data)
is FeatureSideAction.Error -> state.copy(isLoading = false, error = action.error)
else -> state
}
}
}
Все необходимые составляющие мы описали, теперь свяжем их в объекте Store
:
private val logicSideEffects = FeatureLogicSideEffect(repository, router)
private val featureReducer = FeatureReducer()
private val actionsInitializer = ActionsInitializer<FeatureAction, FeatureViewState>()
val store: Store<FeatureAction, FeatureViewState> = Store(
initialState = FeatureViewState(),
reducer = featureReducer,
initializers = listOf(actionsInitializer),
sideEffects = listOf(logicSideEffects),
logger = { Timber.d("FEATURE_TAG | $it") }
)
fun accept(action: FeatureViewAction) {
actionsInitializer.actions.post(action)
}
Чтобы состояние экрана сохранялось при смене конфигурации, положим объект Store
в Android ViewModel. И, наконец, подпишемся и обработаем изменения состояния экрана и опишем отправку действий в необходимые моменты:
disposable = viewModel.store.states
.observeOn(AndroidSchedulers.mainThread())
.subscribe { state ->
// обновляем UI
}
....
// инициализация
viewModel.accept(FeatureViewAction.Init)
....
// клик по элементу списка
val action = FeatureViewAction.Click(user)
viewModel.accept(action)
....
// клик по кнопке повтора загрузки
viewModel.accept(FeatureViewAction.Retry)
....
Запускаем экран и изучаем лог
FEATURE_TAG | --------INIT--------
FEATURE_TAG | STATE : FeatureViewState(isLoading=false, error=null, data=[])
FEATURE_TAG | THREAD : main
FEATURE_TAG | --------------------
FEATURE_TAG | -------ACTION-------
FEATURE_TAG | ACTION > FeatureViewActionReload@f3f1eab
FEATURE_TAG | STATE > FeatureViewState(isLoading=false, error=null, data=[])
FEATURE_TAG | STATE < FeatureViewState(isLoading=true, error=null, data=[])
FEATURE_TAG | THREAD : main
FEATURE_TAG | --------------------
FEATURE_TAG | -----TASK-ADDED-----
FEATURE_TAG | ID : 1
FEATURE_TAG | KEY : load_data
FEATURE_TAG | THREAD : main
FEATURE_TAG | --------------------
FEATURE_TAG | -------ACTION-------
FEATURE_TAG | ACTION > FeatureSideAction" class="formula inline">Data@2dffdb4
FEATURE_TAG | STATE > FeatureViewState(isLoading=true, error=null, data=[])
FEATURE_TAG | STATE < FeatureViewState(isLoading=false, error=null, data=[item 1, item 2])
FEATURE_TAG | THREAD : RxCachedThreadScheduler-1
FEATURE_TAG | --------------------
FEATURE_TAG | ----TASK-REMOVED----
FEATURE_TAG | ID : 1
FEATURE_TAG | KEY : load_data
FEATURE_TAG | THREAD : RxCachedThreadScheduler-1
FEATURE_TAG | --------------------
В независимости от сложности задачи можно проявить фантазию и встроить Reduktor в любое место приложения. Это может быть состояние экрана, состояние загрузки медиафайлов, состояние фичи, состоящей из нескольких экранов, и т.д. Одновременно можно подключать к системе side-эффекты, задачи которых запускаются с помощью coroutines и RxJava, что особенно полезно для плавного перехода одного к другому.
Наш опыт
Нашей команде было несложно разобраться в принципах работы библиотеки. Мы не стали ставить жёсткое условие писать все экраны на Reduktor, поскольку в совсем простых случаях код, скорее всего, будет излишне нагружен сущностями. Как правило, мы используем Reduktor, если необходимо отделить состояние фичи от состояния экрана.
Возьмём для примера экран карточки продукта: он состоит из одного RecyclerView и ViewPager для фотографии товара в шапке. Каждый блок в списке экрана описывается отдельной доменной сущностью. Все сущности необходимо хранить в течение всего жизненного цикла фичи и использовать их в зависимости от действий пользователя. Поэтому получаем два состояния: одно для экрана со списком элементов, подготовленных для отрисовки, другое — доменное с дополнительными параметрами экрана, ненужными для отрисовки. Каждый раз, когда меняется доменное состояние, модель преобразуется в состояние экрана, а оно, в свою очередь, отправляется на отрисовку. С переходом на Reduktor бизнес-логика этого экрана была декомпозирована на множество side-эффектов, отчего код стал гораздо чище и проще в поддержке и расширении. Есть отдельные side-эффекты, которые используются и в других экранах.
Отладка подобных экранов стала занимать гораздо меньше времени, так как любое происходящее изменение внутри Reduktor логируется в удобочитаемом виде (см. пример выше).
В недалеком будущем плавный переход на compose и coroutines не должен быть проблематичным с MVI-структурой, которую мы получили в результате. Чтобы поменять поток состояний типа Flowable
(RxJava) на StateFlow
(coroutines), необходимо будет поменять импорт класса Store
на тот, что находится в пакете reduktor.coroutines. На поток состояний StateFlow
будет подписка в composable view. В SideEffect
необходимо будет переписать асинхронные задачи c RxJava на coroutines. Чтобы поддержать тип Reduktor Task
можно будет воспользоваться extention-методом к CoroutineScope
: CoroutineScope.newTask
.
* * *
Исходный код библиотеки Reduktor, а также подробную инструкцию по подключению и примеры использования можно найти на GitHub.