
Мы активно применяем 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.
ссылка на оригинал статьи https://habr.com/ru/company/youla/blog/685888/
Добавить комментарий