Когда прежние инструменты не соответствуют растущей сложности программ, появляются новые подходы в программировании, паттерны проектирования. MVI помогает там, где не справляются MVP- и MVVM-подходы. Сейчас, в том числе, этот подход активно используется и на многих крупных приложениях. В одних случаях MVI задействован только на части экранов, в других – все экраны построены на нем. В нашей команде знание MVI – одно из обязательных требований.

В этой статье мы разберем решения для реализации MVI-архитектуры на Android, большинство из которых применимы в работе. Описали их преимущества и недостатки, основываясь на опыте коллег и собственной практике. Материал будет полезен для разработчиков, ещё не знакомых с паттерном MVI, и для тех, кто выбирает библиотеку для своего проекта.

Общая схема MVI



Разберем паттерн MVI (Model-View-Intent). Изначально его разработал для фреймворка Cycle.js Андре Cтальц (André Staltz).



Общая схема работы MVI

Разберем принцип работы подробнее. Например, мы находимся на экране и хотим послать какое-то событие. Intent получит это событие и сконвертирует его в необходимые данные. Model обработает эти новые данные, например, совершит запрос к серверу, и вернет новую, уже готовую для отображения модель, которую View может отобразить.

За пример можем взять отправку сообщения в чате. Пусть в нашем случае событием будет клик от View. Тогда Intent преобразует этот клик в действие для модели – отправить новое сообщение. Model обработает эти данные, сходит на сервер и вернет нам новую модель. View отобразит нам, что новое сообщение было успешно отправлено.

Почему удобно работать с MVI?


  • Однонаправленность. С данными работает только одна сущность, и мы знаем, кто изменяет данные, зачем и почему.
  • Неизменяемость состояния. Новое состояние складывается из предыдущего и какого-то действия. Изменить данные мы не можем, можем только получить новые.

Эти два пункта составляют один главный – мы имеем единственный источник истины. Полученные данные неизменны, и мы знаем, кто за них в ответе.

Указанные выше пункты относятся не только к MVI, но и в целом к Unidirectional Data Flow Architecture.

Есть и другие преимущества:

  • Удобство логирования и отладки. Легко воспроизвести, где была ошибка, и собрать все условия.

К этому пункту относится понятие Time Travel Debugging. Например, после краша от юзера мы можем отследить в Crashlytics его текущий State и тот, что был до краша.

  • Удобство тестирования – компоненты можно тестировать независимо друг от друга.
  • Потокобезопасность. Есть в большинстве фреймворков – с данными работает только одна сущность.
  • Удобно работать с Jetpack Compose.

Также отметим, что MVI применим не только для Web- или Android-платформ. К примеру, для проектов на Android и iOS можно взять одинаковые решения. Но в данной статье мы рассмотрим только библиотеки, используемые на Аndroid. Ознакомиться с вариантом реализации на iOS можно в этом видео.

Если во время работы с MVI вы обнаружили причины, по которым подход не удобен для вас, поделитесь мнением в комментариях.

Разновидности MVI в Android


Предлагаем обратиться к различным реализациям MVI. Мы расскажем о наиболее интересных и удобных вариантах и тех, что заслуживают внимания в контексте истории развития подхода.

Redux


Фреймворк для Java Script, написан Даниилом Абрамовым и Эндрю Кларком (Andrew Clark) в 2015 году.

В Redux eсть понятие State или State Tree – непосредственно само состояние или дерево состояний. Также существует Store – хранилище этих состояний. У этого компонента мы можем потребовать текущее состояние, подписаться на изменения или поменять состояния. Обновить состояние можно с помощью функции dispatch, передав туда действие. Внутри dispatch будет происходить вызов функции-reducer и сохранение нового состояния. А получить обновленное состояние мы сможем с помощью метода getState.

Mosby от Ханнеса Дорфмана (Hannes Dorfmann)



Общая схема работы Mosby


Схематичный пример реализации

override fun bindIntents() {
    val viewStateObservable = intent(SomeView::someIntent)
                           .switchMap(::doSomethingAndReturnState)

    subscribeViewState(viewStateObservable, SomeView::render)
}


Библиотека создана на базе RxJava в 2016 и впервые была представлена в цикле статей Ханнеса Дорфмана в 2017 году. Это первая библиотека MVI на Android, она легко ложится на классическую схему MVI. Но сейчас не столь интересна с точки зрения продуктовой разработки и не поддерживается, поэтому останавливаться подробно на ней не будем.

MVICore от Badoo




Общая схема работы MVICore


Эта библиотека известна многим и кто-то из читателей ее наверняка использовал. Основной компонент, внутри которого сокрыта логика работы – Feature. Мы можем послать ему какой-либо Wish – сообщить, что мы хотим выполнить какое-либо действие. После его обработки фича может вернуть либо новый State (который мы потом отобразим пользователю), либо News (аналог SingleLiveEvent).

Как это происходит внутри Feature? Сам Wish обрабатывается в Actor – сущности, которая управляет основной логикой. Actor отвечает за асинхронные задачи, например, запросы к серверу. Результат работы Actor – Effect, будет обработан в Reducer. Reducer отвечает за формирование нового состояния на основе предыдущего состояния и полученных данных. В случае, если у нас не существует Actor, Wish будет обработан Reducer.

При необходимости мы можем отслеживать изменения состояния и отправлять события, которые выполнятся только один раз. В таком случае мы можем воспользоваться сущностью NewsPublisher, которая может создавать такие события – News.

Если мы хотим отправить Wish внутри Feature, то нам потребуется компонент Bootstrapper. Например, он может запустить какой-то запрос, когда мы только зашли на экран.

PostProcessor может помочь в случае, когда нужно отправить новый Wish, исходя из результатов обработки предыдущих событий, например, повторно отправить запрос после ошибки.

Плюсы

  1. Масштабируемость. Можно использовать только Reducer. Но в базовой реализации, скорее всего, минимально будет использоваться Reducer + Actor.
  2. Поддержка SingleLiveEvent.
  3. Удобно тестировать как компонент Feature целиком, так и отдельные компоненты.
  4. Не применяет какую-либо конкретную архитектуру, дает возможность очень гибкого использования. Можно переиспользовать Feature для нескольких экранов.
  5. Логирование и Time Travel Debugging.
  6. Плагин для IDEA.
  7. Базовые классы для поддержки Android, но полностью библиотека не привязана.

Минусы

  1. Сложен для понимания.
  2. Нет поддержки Kotlin Multiplatform.
  3. Только RxJava.

Ознакомиться подробнее с MVICore и посмотреть примеры кода можно здесь.

MVIKotlin от Аркадия Иванова




Общая схема работы MVIKotlin


Вдохновлена библиотекой MVICore и в целом похожа на нее. Возможно, вы слышали о MVIKotlin под старым названием MVIDroid – переименование произошло после добавления поддержки Kotlin Multiplatform.

Основной компонент библиотеки – Store. Обратиться к нему мы можем с помощью Intent. После обработки Intent наружу мы получаем Label (аналог SingleLiveEvent) и State.

Вместо Actor в MVIKotlin используется компонент Executor. Его главное отличие состоит в том, что он может сам посылать SingleLifeEvent.

Bootstrapper тоже имеет некоторые различия. Он посылает события, отличные от Intent (которые приходят извне), поэтому мы сразу понимаем, откуда пришло событие.

Плюсы

  1. Масштабируемость.
  2. Поддержка SingleLiveEvent.
  3. Поддержка Kotlin Multiplatform.
  4. Есть версия как на RxJava, так и на корутинах.
  5. Не применяет какую-либо конкретную архитектуру, дает возможность очень гибкого использования. Можно переиспользовать Store для нескольких экранов.
  6. Логирование и Time Travel Debugging.
  7. Плагин для IDEA и Android Studio.

Минусы

  1. Не очень прост для понимания.
  2. Могут быть сложности с использованием Koin из-за жизненного цикла Controller (сущности, которая связывает все воедино). Подробнее это описано в статье.

Почитать подробнее про MVIKotlin и посмотреть примеры кода можно здесь.

Также Redux-подход используется в библиотеках Roxy, Redux-Kotlin, EBA и других.

MVVM+




Общая схема работы MVVM+


Подход MVVM+ появился в 2020 году. Название его объясняется тем, что он больше похож на MVVM, чем на классический MVI, и объединяет оба решения. Важное отличие MVVM+ в том, что для каждого Intent есть свой Transfomer и Reducer. Таким образом, они выполнятся для каждого события и уже после объединятся в общий State.

Orbit от Мэтью Долана (Matthew Dolan) и Миколая Лещинского (Mikołaj Leszczyński)



Общая схема работы Orbit

В этой библиотеке мы обращаемся к сущности ContainerHost. Для обращения к ней мы просто совершаем вызовы, а после какой-либо проделанной работы наружу получаем Side Effect (аналог SingleLiveEvent) и State.

Эта библиотека очень сильно отличается от предыдущих. Когда мы получили вызов в ContainerHost, он будет передан Containter. В свою очередь, Containter может либо испустить Side Effect, либо произвести какую-либо трансформацию (к примеру, сходить на сервер) и передать событие дальше Reducer. Reducer уже сформирует новое состояние.

Пример обновления состояния

host.intent {
       reduce {
        state.copy(...)
    }
}


Пример отправки Side Effect

host.intent {
    postSideEffect(SideEffect())
}

Плюсы

  1. Невысокий порог входа.
  2. Легко испольховать любой DI.
  3. Поддержка SingleLiveEvent.
  4. Есть поддержка Kotlin multiplatform.
  5. Написан с использованием корутин (это может быть как плюс, так и минус).
  6. Есть сохранение состояния из коробки.
  7. Есть дополнительная библиотека для удобства написания unit-тестов.

Минус

  1. Нет удобного логирования и Time Travel Debugging. Но разработчики планируют добавить его в будущем.

Также подход MVVM+ используется в библиотеках Uniflow и Mavericks.

Реализовать подход MVVM+ самостоятельно достаточно просто, поэтому прилагаем две ссылки на этот случай :)



Архитектура Elm (TEA)


Она имеет определенное сходство с MVI, хотя фактически к ней не относится. Это архитектура языка Elm, созданного в 2012 году.

Если MVI это Model-View-Intent (от intent – «‎намерение»), то ELM – это Model-View-Update (update – «‎обновление»). В случае с MVI логика распределяется между Reducer и Intent, а в случае с ELM вся логика находится в Update. Ниже на примере разберем это детальнее.

Elmslie от Vivid



Общая схема работы Elmslie


Библиотека имеет основную сущность Store, к которой мы обращаемся через событие – Event.UI. Снаружи мы будем получать Effect (SingleLiveEvent), State или и то и другое.

Рассмотрим подробнее компоненты библиотеки. Например, мы получили какой-то Event.UI, обработали. Но в отличие от других реализаций, обработали именно в Reducer. Здесь он будет отвечать за все – будет знать, нужно ли ему послать какую-то команду в Actor, или нужно сформировать новый State, или нужно послать Effect, или сделать всё сразу. Например, Reducer может сказать поменять состояние с обычного на состояние загрузки и начать отправлять запрос в Actor. Как только Actor выполнит свою работу, он пошлет Event.Enternal, его поймает Reducer и может делать дальше все, что будет необходимо.

Плюсы

  1. Невысокий порог входа.
  2. Подключается напрямую во View – дополнительно не требуется Viewmodel и т.д.
  3. Масштабируемость, но есть отличие от MVICore или MVIKotlin. Можно использовать готовые реализации «пустых» Actor и Reducer.
  4. Поддержка SingleLiveEvent.
  5. Удобно тестировать Reducer.
  6. Есть логирование и Time Travel Debugging.
  7. Есть кодогенерация и плагин для Android Studio.
  8. Есть базовые классы для поддержки Android, но к Android библиотека не привязана.

Минусы

  1. Нет поддержки Kotlin Multiplatform.
  2. Только RxJava.

Узнать подробнее про Elmslie и посмотреть примеры кода можно здесь.

Есть и другие библиотеки архитектуры Elm – например, Elmo, Teapot, Puerh

Заключение


Если посмотреть на примерную картину появления рассматриваемых в статье архитектур и библиотек, она выглядит так:



Последовательность создания решений для MVI

В описание вошли не все библиотеки, а только те, которые наиболее популярны или примечательны с исторической точки зрения. Исходя из практического опыта, можем выделить, при каких условиях какая библиотека будет оптимальной.

По итогам работы с MVICore на одном из крупных проектов, можно с уверенностью сказать – это библиотека отлично себя зарекомендовала. Несмотря на более высокий порог вхождения, она имеет огромное количество преимуществ, и что немаловажно – имеет большое комьюнити, что может играть существенную роль в решении нетривиальной проблемы.

Библиотека Elmslie отлично подойдет как для крупных, так и для небольших проектов – с ней намного легче разобраться, она более свежая и использует подход, немного отличающийся от MVI. Самое то для тех, кто любит поэкспериментировать. :)

Библиотека Orbit хорошо подходит для небольших проектов, так как имеет чуть меньший функционал. Здесь пока не реализован Time Travel Debugging, однако это компенсируется другими плюсами. Вполне вероятно, в будущем единственный минус будет исправлен.

Если же вам необходима поддержка Kotlin Multiplatform, хорошим вариантом для крупных проектов будет MVIKotlin, а для небольших – Orbit.

Чтобы вы могли продолжить знакомство с MVI-архитектурой, делимся дополнительными материалами по теме:



Спасибо за внимание! Полезные материалы для разработчиков мы также публикуем в наших соцсетях – ВК и Telegram.


Будем рады, если вы поделитесь в комментариях, какие библиотеки уже используете у себя на проектах.

Комментарии (1)


  1. zakkav
    18.04.2022 12:06

    Люблю простые и эффективные решения, из этого списка под этот критерий подходит только Orbit. Остальные фреймворки слишком монструозны и предлагают писать код ради кода, а не для решения бизнес задач.