MVIDroid: обзор новой библиотеки MVI (Model-View-Intent)
Всем привет! В этой статье я хочу рассказать о новой библиотеке, которая привносит шаблон проектирования MVI в Android. Эта библиотека называется MVIDroid, написана 100% на языке Kotlin, легковесная и использует RxJava 2.x. Автор библиотеки лично я, исходный код её доступен на GitHub, а подключить её можно через JitPack (ссылка на репозиторий в конце статьи). Эта статья состоит из двух частей: общее описание библиотеки и пример её использования.
И так, в качестве предисловия, позвольте напомнить что такое вообще MVI. Model — View — Intent или, если по-русски, Модель — Представление — Намерение. Это такой шаблон проектирования, в котором Модель (Model) является активным компонентом, принимающим на вход Намерения (Intents) и производящая Состояния (State). Представление (View) в свою очередь принимает Модели Представления (View Model) и производит те самые Намерения. Состояние преобразуется в Модель Представления при помощи функции-трансформера (View Model Mapper). Схематически шаблон MVI можно представить следующим образом:
В MVIDroid Представление не производит Намерения напрямую. Вместо этого оно производит События Представления (UI Events), которые затем преобразуются в Намерения при помощи функции-трансформера.
Основные компоненты MVIDroid
Модель
Начнём с Модели. В библиотеке понятие Модели немного расширено, здесь она производит не только Состояния но и Метки (Labels). Метки используются для общения Моделей между собой. Метки одних Моделей могут быть преобразованы в Намерения других Моделей при помощи функций-трансформеров. Схематически Модель можно представить так:
В MVIDroid Модель представлена интерфейсом MviStore (название Store заимствовано из Redux):
И так, что мы имеем:
- Интерфейс имеет три Generic-параметра: State — тип Состояния, Intent — тип Намерений и Label — тип Меток
- Содержит три поля: state — текущее состояние Модели, states — Observable Состояний и labels — Observable Меток. Последние два поля дают возможность подписаться на изменения Состояния и на Метки соответственно.
- Является потребителем (Consumer) Намерений
- Является Disposable, что даёт возможность разрушить Модель и прекратить все происходящие в ней процессы
Обратите внимание, что все методы Модели должны выполняться на главном потоке. То же самое справедливо и для любого другого компонента. Выполнять фоновые задачи, разумеется, можно используя стандартные средства RxJava.
Компонент
Компонент в MVIDroid — это группа Моделей, объединённых общей целью. Например можно выделить в Компонент все Модели для какого-либо экрана. Иными словами, Компонент является фасадом для заключённых в него Моделей и позволяют скрыть детали реализации (Модели, функции-трансформеры и их связи). Давайте посмотрим на схему Компонента:
Как видно из схемы, компонент выполняет важную функцию преобразования и перенаправления событий.
Полный список функции Компонента выглядит следующим образом:
- Связывает входящие События Представлений и Метки с каждой Моделью используя предоставленные функции-трансформеры
- Выводит исходящие Метки Моделей наружу
- Разрушает все Модели и разрывает все связи при разрушении Компонента
Компонент тоже имеет свой интерфейс:
Рассмотрим интерфейс Компонента подробнее:
- Содержит два Generic-параметра: UiEvent — тип Событий Представления и States — тип Состояний Моделей
- Содержит поле states, дающее доступ к группе Состояний Моделей (например в виде интерфейса или data-класса)
- Является потребителем (Consumer) Событий Представления
- Является Disposable, что даёт возможность разрушить Компонент и все его Модели
Представление (View)
Как несложно догадаться, Представление нужно для отображения данных. Данные для каждого Представления группируются в Модель Представления (View Model) и обычно представляются в виде data-класса (Kotlin). Рассмотрим интерфейс Представления:
Здесь всё несколько проще. Два Generic-параметра: ViewModel — тип Модели Представления и UiEvent — тип Событий Представления. Одно поле uiEvents — Observable Событий Представления, дающее возможность клиентам подписаться на эти самые события. И один метод subscribe(), дающий возможность подписаться на Модели Представления.
Пример использования
Теперь самое время попробовать что-нибудь на деле. Предлагаю сделать что-то очень простое. Что-то, что не потребует больших усилий для понимания, и в то же время даст представление о том, как же это всё использовать и в каком направлении двигаться дальше. Пусть это будет генератор UUID: по нажатию кнопки будем генерировать UUID и отображать его на экране.
Представление
Для начала опишем Модель Представления:
И События Представления:
Теперь реализуем само Представление, для этого нам понадобится наследование от абстрактного класса MviAbstractView:
Всё предельно просто: подписываемся на изменения UUID и обновляем TextView при получении нового UUID, а по нажатию кнопки отправляем событие OnGenerateClick.
Модель
Модель будет состоять из двух частей: интерфейс и реализация.
Здесь всё просто: наш интерфейс расширяет интерфейс MviStore, указывая типы Состояния (State) и Намерений (Intent). Тип Меток — Nothing, т. к. у наша Модель их не производит. Также в интерфейсе содержатся классы Состояния и Намерений.
Для того что реализовать Модель, надо понять как она работает. На вход Модели поступают Намерения (Intent), которые преобразуются в Действия (Action) при помощи специальной функции IntentToAction. Действия поступают на вход Исполнителю (Executor), который выполняет их и производит Результаты (Result) и Метки (Label). Результаты затем поступают в Редуктор (Reducer), который преобразует текущее Состояние в новое.
Все четыре состовляющие Модели:
- IntentToAction — функция, преобразующая Намерения в Действия
- MviExecutor — исполняет Действия и производит Результаты и Метки
- MviReducer — преобразует пары (Состояние, Результат) в новые Состояния
- MviBootstrapper — специальный компонент, позволяющий инициализировать Модель. Выдаёт всё те же Действия, которые также поступают в Исполнитель (Executor). Можно выполнить разовое Действие, а можно подписаться на источник данных и выполнять Действия при определённых событиях. Bootstrapper запускается автоматически при создании Модели.
Чтобы создать саму Модель, необходимо использовать специальную фабрику Моделей. Она представлена интерфейсом MviStoreFactory и его реализацией MviDefaultStoreFactory. Фабрика принимает составляющие Модели и выдаёт готовую к использованию Модель.
Фабрика нашей Модели будет выглядеть следующим образом:
В этом примере представлены все четыре составляющие Модели. Сначала фабричный метод create, затем Действия и Результаты, за ними следует Исполнитель и в самом конце Редуктор.
Компонент
Состояния Компонента (группа Состояний) опишем data-классом:
При добавлении новых Моделей в Компонент, их Состояния следует также добавить в группу.
И, собственно, сама реализация:
Мы наследовали абстрактный класс MviAbstractComponent, указали типы Состояний и Событий Представления, передали нашу Модель в super класс и реализовали поле states. Кроме того мы создали функцию-трансформер, которая будет преобразовывать События Представления в Намерения нашей Модели.
Маппинг Модели Представления
У нас есть Состояния и Модель Представления, настало время преобразовать одно в другое. Для этого мы реализуем интерфейс MviViewModelMapper:
Связь (Binding)
Наличия самих по себе Компонента и Представления не достаточно. Чтобы всё начало работать, их необходимо связать. Пришло время создать Activity:
Мы использовали метод bind(), который принимает Компонент и массив Представлений с мапперами их Моделей. Этот метод является extension-методом над LifecycleOwner (коими являются Activity и Fragment) и использует DefaultLifecycleObserver из пакета Arch, который требует Java 8 source compatibility. Если по каким-либо причинам Вы не можете использовать Java 8, то Вам подойдёт второй метод bind(), который не являеся extension-методом и возвращает MviLifecyleObserver. В этом случае, Вам придётся вызывать методы жизненного цикла самостоятельно.
Ссылки
Исходный код библиотеки, а также подробную инструкцию по подключению и использованию можно найти на GitHub.
Источник
Приручая MVI
О том, как распутать джунгли MVI, используя Джунгли собственного производства, и получить простое и структурированное архитектурное решение.
Предисловие
Впервые наткнувшись на статью о Model-View-Intent (MVI) под Android, я даже не открыл ее.
— Серьезно!? Архитектура на Android Intents?
Это была глупая идея. Намного позже я прочел про MVI и узнал, что главным образом данная архитектура сосредоточена на однонаправленных потоках данных и управлении состояния.
Изучая MVI, я невольно столкнулся с проблемой, что весь подход выглядит как-то запутанно, как какие-то дебри. Да, на выходе получается решение с плюсами по отношению к MVP и MVVM, но, смотря на эту всю комплексность, задаешься вопросом: «А стоило ли?».
Просмотрев несколько популярных библиотек на эту тему, у меня так и не появилось фаворита из существующих решений, так как что-нибудь мне да не нравилось; появлялись различные вопросы, на которые не всегда можно было найти однозначные ответы.
Так я решил написать свое решение. Основные требования (по значимости):
- Простое;
- Покрывает все UI кейсы, которые я только могу придумать;
- Структурированное.
И что это?
Позвольте представить — Джунгли (Jungle). Под капотом — только RxJava с ее реактивным подходом.
Все отношения, показанные на картинке, — опциональны
Как это работает?
Как мне кажется, лучший способ понять это — разобрать пример. Представим, нам нужен экран со списком стран, который должен быть загружен из Интернета. Также существуют следующие условия:
- Показывать PrgoressBar во время загрузки;
- Отображать Button для перезагрузки списка и Toast с сообщением об ошибке в случае ошибки;
- Если страны были успешно загружены, отображать список стран;
- Попробовать загрузить страны на открытии окна автоматически, без каких-либо действий пользователя.
Давайте напишем нашу UI часть:
Что из этого (пока) можно понять? Мы можем послать DemoEvent.Load нашему DemoStore (по клику на Reload кнопку); получить DemoAction.ShowError (с данными об ошибке) и отобразить Toast; получить обновление по DemoState (с данными о странах и состоянии загрузки) и отобразить UI компоненты в соответствии с требованиями. Вроде бы не так уж и сложно.
Теперь приступим к нашему DemoStore. В первую очередь, унаследуем его от Store, разрешим получать DemoEvent, производить DemoAction и изменять DemoState:
Затем, создадим CountryMiddleware, который будет ответственным за предоставление данных о загрузке стран:
Что такое Command? Это специфичный сигнал, который побуждает «что-то» сделать. А CommandResult? Это результат выполнения этого «чего-то».
В нашем случае CountryMiddleware.Input сигнализирует, что логика CountryMiddleware должна быть выполнена. Каждое выполнение логики Middleware возвращает CommandResult; для лучшей структуры приложения можно хранить этот результат внутри sealed класса (CountryMiddleware.Output).
В нашем случае мы попросту возвращаем Observable, который испустит Output.Loading во время загрузки, Output.Loaded с данными на успешную загрузку, Output.Failed с информацией об ошибке на ошибку.
Давайте вернемся к DemoStore и заставим обработать CountryMiddleware на нажатие Reload кнопку:
Переопределяя поле middlewares мы указываем, какие Middlewares наш DemoStore может обработать. Под капотом Store использует Commands. Поэтому нам следует сконвертировать наш DemoEvent.Load в CountryMiddleware.Input (для того, чтобы принудить перезагрузку).
Итак, теперь мы можем получать результат от CountryMiddleware. Давайте позволим последнему изменять наш DemoState:
Прежде чем изменять State, необходимо указать его начальное состояние в initialState . После этого в методе reduceCommandResult описывается логика того, как каждый CommandResult изменяет State.
Для отображения ошибки загрузки используется DemoAction.ShowError. Чтобы сгенерировать последний, необходимо предоставить новую Command (из CommandResult) и связать ее с нашим Action:
Последнее, что осталось сделать — привязать автоматический запуск выполнения CountryMiddleware. Все, что нужно сделать, это добавить его Command в bootstrapCommands :
Просто?
Можно использовать конкретно только то, что вам нужно, без какой-либо лишней логики. Несколько классов и щепотка магии под капотом. Один Store, опционально несколько Middlewares, опционально имплементация MviView.
Ваша View должна только отображать обновления какой-то функциональности бизнес логики? Вам даже не нужны Events, только Store, Middleware и переопределение метода render функции в MviView.
Только кнопка, по клику которой происходит какая-то навигация? Окей, стоит только поиграться с Event внутри Store и ничего больше.
Как мне кажется, это простое решение, так как требует небольших усилий как для понимания, так и для использования.
Структурировано?
Для того, чтобы поддерживать структурированность, необходимо:
- Хранить Commands в sealed классах внутри Store, группируя их по назначения: генерирующие Actions или напрямую изменяющие State?
- Хранить Commands, относящихся к Middlewares, внутри последних.
Также стоит помнить, что Middleware — про одну функциональность, что делает его похожим на UseCase (Interactor). На мой взгляд, присутствие последнего (и, как следствие, какого-то domain layer) говорит о хорошо структурированном проекте. По этой же аналогии, я считаю, что использование Middleware способствует улучшению структуры проекта.
Заключение
С использованием Джунгей у меня есть четкое представление того, как организуется навигация внутри подхода. Я также уверен, что проблема SingleLiveEvent может быть легко разрешена с использованием Actions.
Более подробные разборы работы можно найти на wiki. Отвечу на любые вопросы. Буду рад, если вам данное решение покажется полезным!
Источник