Опыт перехода на MVI в Android на базе собственного решения

c863b5d629ac178b01abc470d1879b92.png

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

   private val viewStateProcessor: BehaviorProcessor = BehaviorProcessor.create()

   // Текущее состояние экрана на момент вызова
   private val currentViewState: ServiceDetailViewState
       get() = viewStateProcessor.value ?: ServiceDetailViewState(false, true)

   private fun postViewState(vs: ServiceDetailViewState): Unit = viewStateProcessor.onNext(vs)

   // Поток состояний экрана
   val viewStates: Flowable = 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, приемлемое качество кода и невысокий порог входа.

Отсюда можно было выделить следующие требования:

  1. Применимость для различных экранов. TTM сокращается за счёт того, что разработчику проще разобраться, так как разработке нужно поддерживать фичи, построенные по одному шаблону;

  2. Переиспользование всего кода, что только можно (модели/события/репозитории/use-case, и др.);

  3. Unidirectional Data Flow — направление движения события по системе понятно в каждый момент времени, сокращаем время на отладку;

  4. Single Source of Truth — есть некоторое состояние* (поговорим о состояниях ниже), а отображение на экране — производная от него. Искать ошибки в первую очередь следует в компоненте, который отвечает за этот source of truth. UI логика не содержит, а лишь отображает пришедшее состояние —render(state: ViewState);

  5. Поддержка реактивной парадигмы: в нашем случае, экраны часто меняются из-за внешних данных. Например, лента меняется, когда пользователь применяет фильтры. Поиск — по вводу запроса или саджеста. Экран заказа может получить обновление статуса заказа с пуша или по веб-сокету. Мы ожидаем некоторого события (или серии событий) и реагируем на это.

MVI подходил под это как нельзя лучше. Однако мы переработали статьи Hannes«а из практических соображений. А именно:

  1. Мы не стали получать во ViewModel список Observable и комбинировать их между собой. Вместо этого ViewModel стала Consumer, где 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)
    	...
    }
  2. View содержит один метод —render(state: ViewState). View максимально простая, какое состояние пришло, такое и отображается. ViewState мы моделировали и через sealed-классы, и делали их «плоскими» (флажки о загрузке, ошибке, данных для отображения лежат в одном объекте), —  все варианты рабочие, огромных преимуществ у какого-то нет;

  3. Reducer зачастую заменяло копирование: data class copy(). Однако для сложных случаев отдельный класс с методом reduce(state: ViewState, event: UIEvent) не ленились написать;

  4. ViewModel предоставляла states: Flowable. Внутри это зачастую было реализовано через BehaviorProcessor;

  5. Из практических соображений: ViewModel также предоставляла routeEvents: Flowable и serviceEvent: Flowable.Специальный компонент Router: Consumer — является потребителем потока событий навигации, из композиции роутеров строится навигация всего приложения. ServiceEvent служит для событий «fire and forget», которые мы не хотим хранить во ViewState — показы тултипов, toast«ов, диалоги-подсказки и тому подобное;

    Дисклеймер:
    Мы не призываем вас делать так. Если вы можете вычислить переход (навигировать) по State или вам нужно восстановить показ toast, то используйте state, сможете избежать лишних подписок. В конце концов, это было не академически правильное, а простое и дешёвое решение

  6. ViewModel для простых экранов являлась сосредоточением бизнес-логики, и, естественно, проектировалась таким образом, чтобы не быть зависимой на фреймворк Android;

  7. ViewModel обращалась к repository, mapper«ам, комбинировала подписки, меняла треды, копировала итоговое состояние и отсылала его на UI;

  8. Разумеется,   для списков сразу применяли DiffUtil, добиваясь оптимальной отрисовки на UI.

Что получилось в итоге реализации:

  1. Стало проще находить ошибки. Нет подписки — см. ViewModel. Данные пришли, но кривой UI — см. View. Кривое состояние — см. логи при копировании/отправки state«а для View;

  2. Это относится и к классам с разной ответственностью, ведь после установления контакта можно отдать это разным разработчикам для сокращения ТТМ;

  3. Переиспользование базовых событий по всему приложению. Общие обработчики для базовых событий;

  4. Общие обработчики для повторяющихся 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-решения:

  1. Масштабируемость и независимость от платформы и внешних библиотек. Архитектура должна быть крайне гибкой и расширяемой. Как сказано выше, сейчас у нас в проекте используется RxJava, при этом мы планируем перейти на compose и использовать coroutines в недалеком будущем. Отсюда требование: решение не должно зависеть от сторонних библиотек;

  2. Сопровождаемость. Чем проще исправлять ошибки и управлять проектом после передачи в эксплуатацию, тем легче новым разработчикам поддерживать проект;

  3. Надежность. Внутри реализации системы исключены проблемы многопоточной среды. Единый контракт должен обеспечивать безопасность интерфейсов;

  4. Тестируемость;

  5. Возможность переиспользования;

  6. Легкая встраиваемость в проект;

  7. Детальные и хорошо читаемые логи.

Дополнительные критерий — активное сообщество, поскольку библиотека должна сохранять актуальность, её должны обновлять, проверять и поддерживать в порядке.

Если вы ищете такое решение, то посмотрите статью «Сравниваем готовые решения для реализации MVI-архитектуры на Android», актуальную на 2022 год. В начале 2021 года, мы отмели MVICore от Badoo, как слишком сложный. MVIKotlin не имел такой популярности, как сейчас. Итого, мы завели демо-проект на github, в котором сравнивали RxRedux и первую версию Reduktor, которую нам принёс на рассмотрение наш коллега. Поскольку Reduktor покрывал все наши нужды, в отличие от RxRedux, фреймворк был значительно доработан и состоялось внедрение в проект. В продакшн версия на RxJava существует более года, полёт нормальный.

Про Reduktor

В Reduktor всё взаимодействие происходит через объект Store. Класс Store параметризован двумя типами: ACTION — базовым классом событий, и STATE — состоянием системы.

Какие параметры есть у Store:

class Store(
   initialState: STATE,
   private val reducer: Reducer,
   initializers: Iterable> = emptyList(),
   sideEffects: Iterable> = 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 сразу придёт актуальное состояние в текущем потоке. Однако дальнейшие обновления могут приходить в других потоках.

df8a14ed32249ac383a6476f7ebf8ece.png

Для удобства, у класса Store есть несколько базовых реализаций для поддержки потока состояний через сущности StateFlow (Coroutines) и Flowable (RxJava2, RxJava3).

Принцип работы:

  • Действие может попасть в систему через Initializer. Это может быть событие от взаимодействия с пользователем или любое другое внешнее событие (например, новое сообщение из сокета).

  • Далее действие проходит через Reducer. Тут оно может повлиять на изменение состояния. Если состояние изменилось, то информация об этом будет отправлена тем, кто на него подписался.

  • После этого действие и новое (либо не изменившееся) состояние попадают в SideEffect. Оттуда могут возвращаться новые цепочки с действиями. Подписываемся на них и ожидаем новые действия, которые будут снова отправлены в Reducer. В SideEffect можно создавать и выполнять фоновые задачи, результатом работы которых становится новое действие.

9c54a66ce8433660351632466e211819.png

Reduktor на примере

В этом разделе разберем принцип работы Reduktor на примере экрана со списком пользователей. Он может иметь три состояния: загрузка, ошибка загрузки и успешно полученный из сети список пользователей. По клику на элемент в списке будет открываться webview. Для распараллеливания будем использовать RxJava. Состояние такого экрана:

data class FeatureViewState(
   val isLoading: Boolean = false,
   val error: Throwable? = null,
   val data: List = 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) : FeatureSideAction()
   class Error(val error: Throwable) : FeatureSideAction()
}

В ответ на действия FeatureViewAction.Init и FeatureViewAction.Retry должна начаться загрузка пользователей, в ответ на FeatureViewAction.Click должна открываться webview. Всё это будет происходить в одном side-эффекте. Для этого необходимо наследоваться от функционального интерфейса SideEffect и переопределить метод invoke:

class FeatureLogicSideEffect : SideEffect {

   override fun Environment.invoke(action: FeatureAction, state: FeatureViewState) {
       // ..
   }
}

Метод invoke принимает текущее действие (action) и актуальное состояние (state).

Сущность Environment предоставляет доступ к задачам (tasks) и действиям (actions). Чтобы в Reduktor создать фоновую задачу, необходимо наследоваться от класса Task и поместить в массив tasks. Завершить выполнение работы и отменить все задачи можно через метод release().

Для того, чтобы начать загрузку экрана, определим задачу tasks[”load_data”]:

private fun Environment.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 {

   override fun Environment.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.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.invoke(action: FeatureAction, state: FeatureViewState) {
       when (action) {
           is FeatureAction.Init -> {
               doSomething()
               actions post FeatureAction.Init // Зацикливание!
           }
       }
    }
  • Неактуальное состояние. Нужно помнить, что состояние актуально только в момент вызова метода invoke. Если попытаться забрать данные из поля state после переключения потока, то в системе они могут оказаться уже другими. Нужно разделить такие блоки и использовать актуальное состояние из следующей итерации:

    override fun Environment.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.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 {

   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()

val store: Store = 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.

© Habrahabr.ru