Это вторая часть серии статей об архитектуре android приложения vivid.money. В ней мы расскажем в деталях о том, что из себя представляет ELM архитектура. В связи с тем, что наша реализация доступна в open source в качестве библиотеки Elmslie, в статье будет использоваться нейминг из нее.
Оглавление
Часть первая. Как мы выбрали архитектуру слоя представления на новом проекте и не прогадали.
Часть вторая. Разбираем ELM архитектуру в рамках мобильного приложения.
Вступление
Одной из основных проблем в разработке мобильных приложений с использованием MVP/MVVM/MVC паттернов становится раздутие презентеров. Часто в них скапливается абсолютно все управление асинхронной работой и состоянием приложения. С течением времени, усложнением логики и общим ростом кодовой базы их становится невероятно трудно менять. А понимание того что происходит в написанном другим разработчиком презентере может стать непосильной задачей и исправление багов лишь привносит больше новых ошибок.
С этой задачей призваны были справиться Unidirectional Data Flow архитектуры. Первое решение для android было описано уже больше 4х лет назад (!) Ханнесом Дорфманом в статье об MVI на андроид. Помимо MVI, самого популярного представителя Unidirectional архитектур в мобильном сообществе, существуют и другие. В рамках этой статьи остановимся на архитектуре, которая используется у нас - ELM.
О чем эта статья?
ELM архитектура достаточно общее решение, для которого есть множество применений. Однако, чтобы упростить понимание, опишем ее на примере реализации слоя представления. Разобравшись с поведением архитектуры на простом примере, можно поискать и другие применения.
![Место ELM в архитектуре Место ELM в архитектуре](https://habrastorage.org/getpro/habr/upload_files/30e/358/e1b/30e358e1b8ef524bb2b5725c435c64c4.png)
Представьте, что мы проектируем архитектуру с нуля. Конечно, на момент написания статьи результат уже известен. В каждый момент времени можно было бы принять и другое решение, выделить другие детали или решить проблему по-другому. Такой способ изложения выбрал исключительно для упрощения понимания.
В описании мы не будем останавливаться на модели, рассказывать о том как писать бизнес-логику, делать запросы к API. Про организацию кода во View тоже не будет ни слова, эти моменты остаются на ваше усмотрение.
Слой представления снаружи
Когда мы рассматриваем слой представления в контексте разработки мобильных приложений, мы представляем экраны и способы их написания. Архитектура описывает то, как построить реализацию одного экрана. Попробуем сформулировать набор требований к тому, как будет устроено взаимодействие со слоем представления одного экрана в общем случае.
Основной составляющей ELM является одна сущность - Store. В простом варианте весь слой представления описывается одной сущностью. В более сложных случаях экран может состоять из нескольких Store, но об этом расскажем в одной из следующих статей. Попробуем описать свойства Store:
![Требования к слою представления Требования к слою представления](https://habrastorage.org/getpro/habr/upload_files/c4a/317/d8b/c4a317d8bca1d701249cd3280d68168e.png)
Любой слой представления должен общаться с моделью. Нам придется обращаться с бизнес логикой, делать запросы к Api, сохранять данные в кеш и так далее. Представим это на диаграмме как возможность Store обращаться к модели, получать из нее данные и запускать операции.
Взаимодействие с View устроено немного сложнее. У него есть три составляющие:
В пользовательском интерфейсе происходят события - нажатия на кнопку, прокрутка списка, pull-to-refresh и другие. Назовем их Event.UI.
У экрана есть некоторое состояние. В него может входить информация о том, показывается ли сейчас состояние загрузки, данные для отображения в списке или текущее положение toggle switch. Все это включим в термин State.
Слой представления не только имеет состояние, но еще и может отдавать команды View. Например нужна возможность показать Toast, Snackbar или перейти на другой экран. Все это не получится описать в State, поскольку в нем хранится информация, а требуется представить некоторое действие. Для этого выделим отдельную сущность - Effect.
А теперь внутри
В предыдущем разделе мы описали поведение Store снаружи, то как оно выглядит для внешнего наблюдателя или пользователя. Теперь попробуем описать то, что происходит внутри него:
Обработка событий в пользовательском интерфейсе (Event.UI)
Изменение состояния экрана (State)
Запуск операций в UI (Effect)
Получение данных из модели
Запуск операций в модели
Сложные вычисления
Все эти вещи можно разделить на две группы, которые мы объединили в две сущности - Actor и Reducer
![Разделение работы экрана Разделение работы экрана](https://habrastorage.org/getpro/habr/upload_files/120/197/08d/12019708dea05b054918ac256411dffc.png)
Опишем сущности, которые у нас получились:
Actor
![Схема работы Actor Схема работы Actor](https://habrastorage.org/getpro/habr/upload_files/532/553/e2a/532553e2aa5f2d41f95e5b579ed29444.png)
В Actor находятся все асинхронные операции, вычисления и работа с моделью. Опишем это с помощью Command, которая в общем случае запускает некоторую операцию и Event, который вернет результат этой операции.
Например:
Подписка на обновление данных в модели
Выполнение запроса к API
Запуск таймера на выполнение операции
Reducer
![Схема работы Reducer Схема работы Reducer](https://habrastorage.org/getpro/habr/upload_files/b5d/20e/588/b5d20e588f1078586514518f3c099f77.png)
По сути в Reducer осталась вся логика работы экрана. Он знает о текущем состоянии экрана, узнает о происходящих событиях и вычисляет реакцию на них. События могут приходить из UI и из Actor, как результат работы операции. Реакция состоит из Effect - команды для UI, State текущего состояния для отрисовки на экране и Command - запуска операции в Actor
Например:
При Event - нажатие на кнопку загрузки в State выставится флаг isLoading на true и запустится Command - сделать запрос к API
При Event - произошла ошибка при загрузке данных в State выставится флаг isLoading в false и отправится Effect - показать ошибку в UI
Отличным качеством Reducer является то, что его можно реализовать не используя асинхронных операций. Его можно представить как pure function. То есть функцией, которая не создает побочных эффектов и всегда выдает одинаковый результат для одних и тех же входных данных.
Result
![Комапоненты Result Комапоненты Result](https://habrastorage.org/getpro/habr/upload_files/319/ae1/756/319ae175634ff3a35ccc8b2d7423398d.png)
Выделим так же отдельную сущность, которая будет представлять ту самую реакцию Reducer на Event и назовем ее Result.
Она состоит из:
Effect - команды для UI
State - текущее состояния экрана
Command - команды запуска операций в Actor
Собираем все вместе
Если объединить все эти компоненты получится примерно следующая картина:
![Внутренности Store Внутренности Store](https://habrastorage.org/getpro/habr/upload_files/3a3/8b4/b93/3a38b4b93c6f61e5f8376347e68c4d0a.png)
View и Actor являются источниками событий. Это представлено в виде Event. События разделяются по типу источника, для View это Event.Ui, а для Actor это Event.Internal. События побуждают изменения состояния экрана, одиночные эффекты, а также запуск асинхронных операций. Состояние экрана представлено State, которое доставляется View для отрисовки. Одиночные эффекты обозначены как Effect и так же обрабатываются View. Actor в свою очередь работает с моделью, запускает операции и получает из нее данные. А Store связывает все это вместе.
Как это работает?
Далее на GIF диаграммах схематично представлена работа простого экрана. Слева - UI, в центре то что происходит в ELM, справа - текущий State экрана.
Сценарий успешной загрузки
Разберем сценарий, когда при нажатии на кнопку значение успешно загружается и отображается в UI.
![Работа ELM во время успешного сценария Работа ELM во время успешного сценария](https://habrastorage.org/getpro/habr/upload_files/22c/19f/a7b/22c19fa7bc2ddf6d59e2bad4761245f0.gif)
Пользователь нажимает на кнопку 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
Сценарий неуспешной загрузки
А теперь неуспешной
Разберем сценарий, когда при нажатии на кнопку значение не получается загрузить, после чего показывается Snackbar с ошибкой.
![Работа ELM в случае ошибки Работа ELM в случае ошибки](https://habrastorage.org/getpro/habr/upload_files/f79/431/cf8/f79431cf8070b1e948ea32f824ab535f.gif)
Пользователь снова нажимает на кнопку 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. Мы постарались вобрать достоинства существующих реализаций, максимально упростив написание кода. В следующей части мы расскажем о том, как пользоваться нашей библиотекой.