
Мы активно применяем 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.