Это вторая часть серии статей об архитектуре android приложения vivid.money. В ней мы расскажем в деталях о том, что из себя представляет ELM архитектура. В связи с тем, что наша реализация доступна в open source в качестве библиотеки Elmslie, в статье будет использоваться нейминг из нее.
Вступление
Одной из основных проблем в разработке мобильных приложений с использованием MVP/MVVM/MVC паттернов становится раздутие презентеров. Часто в них скапливается абсолютно все управление асинхронной работой и состоянием приложения. С течением времени, усложнением логики и общим ростом кодовой базы их становится невероятно трудно менять. А понимание того что происходит в написанном другим разработчиком презентере может стать непосильной задачей и исправление багов лишь привносит больше новых ошибок.
С этой задачей призваны были справиться Unidirectional Flow архитектуры. Первое решение было описано уже больше 4х лет назад (!) Ханнесом Дорфманом в статье об MVI на андроид. Помимо MVI, самого популярного представителя Unidirectional архитектур в мобильном сообществе, существуют и другие. В рамках этой статьи остановимся на архитектуре, которая используется у нас - ELM.
О чем эта статья?
ELM архитектура достаточно общее решение, для которого есть множество применений. Однако, чтобы упростить понимание, опишем ее на примере реализации слоя представления. Разобравшись с поведением архитектуры на простом примере, можно поискать и другие применения.
Представьте, что мы проектируем архитектуру с нуля. Конечно, на момент написания статьи результат уже известен. В каждый момент времени можно было бы принять и другое решение, выделить другие детали или решить проблему по-другому. Такой способ изложения выбрал исключительно для упрощения понимания.
В описании мы не будем останавливаться на модели, рассказывать о том как писать бизнес-логику, делать запросы к API. Про организацию кода во View тоже не будет ни слова, эти моменты остаются на ваше усмотрение.
Слой представления снаружи
Когда мы рассматриваем слой представления в контексте разработки мобильных приложений, мы представляем экраны и способы их написания. Архитектура описывает то, как построить реализацию одного экрана. Попробуем сформулировать набор требований к тому, как будет устроено взаимодействие со слоем представления одного экрана в общем случае.
Основной составляющей ELM является одна сущность - Store. В простом варианте весь слой представления описывается одной сущностью. В более сложных случаях экран может состоять из нескольких Store, но об этом расскажем в одной из следующих статей. Попробуем описать свойства Store:
Любой слой представления должен общаться с моделью. Нам придется обращаться с бизнес логике, делать запросы к Api, сохранять данные в кеш и так далее. Представим это на диаграмме как возможность Store обращаться к модели, получать из нее данные и запускать операции.
Взаимодействие с View устроено немного сложнее. У него есть три составляющие:
В пользовательском интерфейсе происходят события - нажатия на кнопку, прокрутка списка, pull-to-refresh и другие. Назовем их Event.UI.
У экрана есть некоторое состояние. В него может входить информация о том, показывается ли сейчас состояние загрузки, данные для отображения в списке или текущее положение toggle switch. Все это включим в термин State.
Слой представления не только имеет состояние, но еще и может отдавать команды View. Например нужна возможность показать Toast, Snackbar или перейти на другой экран. Все это не получится описать в State, поскольку в нем хранится информация, а требуется представить некоторое действие. Для этого выделим отдельную сущность - Effect.
А теперь внутри
В предыдущем разделе мы описали поведение Store снаружи, то как оно выглядит для внешнего наблюдателя или пользователя. Теперь попробуем описать то, что происходит внутри него:
Обработка событий в пользовательском интерфейсе (Event.UI)
Изменение состояния экрана (State)
Запуск операций в UI (Effect)
Получение данных из модели
Запуск операций в модели
Сложные вычисления
Все эти вещи можно разделить на две группы, которые мы объединили в две сущности - Actor и Reducer
Опишем сущности, которые у нас получились:
Actor
В Actor находятся все асинхронные операции, вычисления и работа с моделью. Опишем это с помощью Command, которая в общем случае запускает некоторую операцию и Event, который вернет результат этой операции.
Например:
Подписка на обновление данных в модели
Выполнение запроса к API
Запуск таймера на выполнение операции
Reducer
По сути в Reducer осталась вся логика работы экрана. Он знает о текущем состоянии экрана, узнает о происходящих событиях и вычисляет реакцию на них. События могут приходить из UI и из Actor, как результат работы операции. Реакция состоит из Effect - команды для UI, State текущего состояния для отрисовки на экране и Command - запуска операции в Actor
Например:
При Event - нажатие на кнопку загрузки в State выставится флаг isLoading на true и запустится Command - сделать запрос к API
Про Event - произошла ошибка при загрузке данных в State выставится флаг isLoading в false и отправится Effect - показать ошибку в UI
Отличным качеством Reducer является то, что его можно реализовать не используя асинхронных операций. Его можно представить как pure function. То есть функцией, которая не создает побочных эффектов и всегда выдает одинаковый результат для одних и тех же входных данных.
Result
Выделим так же отдельную сущность, которая будет представлять ту самую реакцию Reducer на Event и назовем ее Result.
Она состоит из:
Effect - команды для UI
State - текущее состояния экрана
Command - команды запуска операций в Actor
Собираем все вместе
Если объединить все эти компоненты получится примерно следующая картина:
View и Actor являются источниками событий. Это представлено в виде Event. События разделяются по типу источника, для View это Event.Ui, а для Actor это Event.Internal. События побуждают изменения состояния экрана, одиночные эффекты, а также новые события. Состояние экрана представлено State, которое доставляется View для отрисовки. Одиночные эффекты обозначены как Effect и так же обрабатываются View. Actor в свою очередь работает с моделью, запускает операции и получает из нее данные. А Store связывает все это вместе.
Как это работает?
На GIF диаграмме схематично представлена работа простого экрана. Слева - UI, в центре то что происходит в ELM, справа - текущий State экрана.
Если расписывать то же самое по шагам, то получится:
Пользователь нажимает на кнопку Reload
UI отправляет Event.UI обозначенный CLICK
CLICK приходит в Reducer
Результатом работы Reducer становится изменение isLoading на true в State и отправка Command обозначенная как LOAD
Из-за изменения State в UI отрисовывается текст LOADING...
В Actor выполняется Command загрузки данных - LOAD
Результатом выполнения команды становится Event.Internal со значением VALUE
Reducer обрабатывает событие VALUE и изменяет в State у поле value значение на 123, а у поля isLoading на false
В UI отрисовывается текст VALUE = 123
Пользователь снова нажимает на кнопку Reload и отправляется Event.UI обозначенный CLICK
CLICK приходит в Reducer
Результатом работы Reducer становится изменение isLoading на true в State и отправка Command обозначенная как LOAD
Из-за изменения State в UI отрисовывается текст LOADING...
В Actor выполняется Command загрузки данных - LOAD
Результатом выполнения команды становится Event.Internal со значением ERROR
Reducer обрабатывает событие ERROR и изменяет в State значение у поля isLoading на false, а также отправляет Effect под названием ERROR
UI обрабатывает Effect обозначенный ERROR и показывает Snackbar с ошибкой
В итоге
ELM архитектура пришла из веба и пока не столь популярна в мобильном сообществе. Однако она определенно заслуживает внимания, наравне с более привычным MVI, благо в них не так много различий. По сравнению с популярными MVP и MVVM она удобнee в тестировании, позволяет писать более простой код и лучше масштабируется. Подробнее о причинах нашего выбора ELM архитектуры мы рассказывали в предыдущей части серии.
Поскольку существующие реализации ELM показались нам недостаточно локаничными и простыми в использовании мы создали Elmslie. Мы постарались вобрать достоинства существующих реализаций, максимально упростив написание кода. В следующей части мы расскажем о том, как пользоваться нашей библиотекой.