Когда прежние инструменты не соответствуют растущей сложности программ, появляются новые подходы в программировании, паттерны проектирования. 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, исходя из результатов обработки предыдущих событий, например, повторно отправить запрос после ошибки.
Плюсы
- Масштабируемость. Можно использовать только Reducer. Но в базовой реализации, скорее всего, минимально будет использоваться Reducer + Actor.
- Поддержка SingleLiveEvent.
- Удобно тестировать как компонент Feature целиком, так и отдельные компоненты.
- Не применяет какую-либо конкретную архитектуру, дает возможность очень гибкого использования. Можно переиспользовать Feature для нескольких экранов.
- Логирование и Time Travel Debugging.
- Плагин для IDEA.
- Базовые классы для поддержки Android, но полностью библиотека не привязана.
Минусы
- Сложен для понимания.
- Нет поддержки Kotlin Multiplatform.
- Только RxJava.
Ознакомиться подробнее с MVICore и посмотреть примеры кода можно здесь.
MVIKotlin от Аркадия Иванова
Общая схема работы MVIKotlin
Вдохновлена библиотекой MVICore и в целом похожа на нее. Возможно, вы слышали о MVIKotlin под старым названием MVIDroid – переименование произошло после добавления поддержки Kotlin Multiplatform.
Основной компонент библиотеки – Store. Обратиться к нему мы можем с помощью Intent. После обработки Intent наружу мы получаем Label (аналог SingleLiveEvent) и State.
Вместо Actor в MVIKotlin используется компонент Executor. Его главное отличие состоит в том, что он может сам посылать SingleLifeEvent.
Bootstrapper тоже имеет некоторые различия. Он посылает события, отличные от Intent (которые приходят извне), поэтому мы сразу понимаем, откуда пришло событие.
Плюсы
- Масштабируемость.
- Поддержка SingleLiveEvent.
- Поддержка Kotlin Multiplatform.
- Есть версия как на RxJava, так и на корутинах.
- Не применяет какую-либо конкретную архитектуру, дает возможность очень гибкого использования. Можно переиспользовать Store для нескольких экранов.
- Логирование и Time Travel Debugging.
- Плагин для IDEA и Android Studio.
Минусы
- Не очень прост для понимания.
- Могут быть сложности с использованием 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())
}
Плюсы
- Невысокий порог входа.
- Легко испольховать любой DI.
- Поддержка SingleLiveEvent.
- Есть поддержка Kotlin multiplatform.
- Написан с использованием корутин (это может быть как плюс, так и минус).
- Есть сохранение состояния из коробки.
- Есть дополнительная библиотека для удобства написания unit-тестов.
Минус
- Нет удобного логирования и Time Travel Debugging. Но разработчики планируют добавить его в будущем.
Также подход MVVM+ используется в библиотеках Uniflow и Mavericks.
Реализовать подход MVVM+ самостоятельно достаточно просто, поэтому прилагаем две ссылки на этот случай :)
- Туториал, как можно самостоятельно реализовать MVI (MVVM+) 1
- Туториал, как можно самостоятельно реализовать MVI (MVVM+) 2
Архитектура 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 и может делать дальше все, что будет необходимо.
Плюсы
- Невысокий порог входа.
- Подключается напрямую во View – дополнительно не требуется Viewmodel и т.д.
- Масштабируемость, но есть отличие от MVICore или MVIKotlin. Можно использовать готовые реализации «пустых» Actor и Reducer.
- Поддержка SingleLiveEvent.
- Удобно тестировать Reducer.
- Есть логирование и Time Travel Debugging.
- Есть кодогенерация и плагин для Android Studio.
- Есть базовые классы для поддержки Android, но к Android библиотека не привязана.
Минусы
- Нет поддержки Kotlin Multiplatform.
- Только RxJava.
Узнать подробнее про Elmslie и посмотреть примеры кода можно здесь.
Есть и другие библиотеки архитектуры Elm – например, Elmo, Teapot, Puerh.
Заключение
Если посмотреть на примерную картину появления рассматриваемых в статье архитектур и библиотек, она выглядит так:
Последовательность создания решений для MVI
В описание вошли не все библиотеки, а только те, которые наиболее популярны или примечательны с исторической точки зрения. Исходя из практического опыта, можем выделить, при каких условиях какая библиотека будет оптимальной.
По итогам работы с MVICore на одном из крупных проектов, можно с уверенностью сказать – это библиотека отлично себя зарекомендовала. Несмотря на более высокий порог вхождения, она имеет огромное количество преимуществ, и что немаловажно – имеет большое комьюнити, что может играть существенную роль в решении нетривиальной проблемы.
Библиотека Elmslie отлично подойдет как для крупных, так и для небольших проектов – с ней намного легче разобраться, она более свежая и использует подход, немного отличающийся от MVI. Самое то для тех, кто любит поэкспериментировать. :)
Библиотека Orbit хорошо подходит для небольших проектов, так как имеет чуть меньший функционал. Здесь пока не реализован Time Travel Debugging, однако это компенсируется другими плюсами. Вполне вероятно, в будущем единственный минус будет исправлен.
Если же вам необходима поддержка Kotlin Multiplatform, хорошим вариантом для крупных проектов будет MVIKotlin, а для небольших – Orbit.
Чтобы вы могли продолжить знакомство с MVI-архитектурой, делимся дополнительными материалами по теме:
Спасибо за внимание! Полезные материалы для разработчиков мы также публикуем в наших соцсетях – ВК и Telegram.
Будем рады, если вы поделитесь в комментариях, какие библиотеки уже используете у себя на проектах.
zakkav
Люблю простые и эффективные решения, из этого списка под этот критерий подходит только Orbit. Остальные фреймворки слишком монструозны и предлагают писать код ради кода, а не для решения бизнес задач.