Вступление
Это третья часть серии статей об архитектуре android приложения vivid.money. В ней мы расскажем об Elmslie - библиотеке для написания кода под android с использованияем ELM архитектуры. Мы назвали ее в честь Джорджа Эльмсли, шотландского архитектора. С сегодняшнего дня она доступна в open source. Это реализация TEA/ELM архитектуры на kotlin поддержкой android. В первой статье мы рассказали о том почему выбрали ELM. Перед прочтением этой статьи лучше ознакомиться как минимум со второй частью, в которой мы более подробно рассказывали том собственно такое ELM.
Оглавление
Часть первая. Как мы выбрали архитектуру слоя представления на новом проекте и не прогадали.
Часть вторая. Разбираем ELM архитектуру в рамках мобильного приложения.
Что будем писать
В предыдущей статье мы рассказывали о том что такое ELM архитектура. В ней приводился пример, его и будем реализовывать. В нем происходит загрузка числового значения и есть возможность его обновлять. Исходный код для этого примера доступен на GitHub.
Модель
Написание экрана проще начинать с проектирования моделей. Для каждого экрана нужны State, Effect, Command и Event. Рассмотрим каждый из них по очереди:
State
State описывает полное состояние экрана. В каждый момент времени по нему можно полностью восстановить то что сейчас показывается пользователю. Правильнее делать исключения, если это усложняет логику. Не нужно сохранять, показывается ли сейчас диалог на экране или что сейчас находится в каждом EditText.
На нашем экране будет отображаться либо числовое значение, либо состояние загрузки. Это можно задать двумя полями в классе: val isLoading: Boolean
и val value: Int?
. Для удобства изменения, State лучше реализовывать как data class. В итоге получается так:
data class State(
val isLoading: Boolean = false,
val value: Int? = null
)
Effect
Каждый Effect описывает side-effect в работе экрана. То есть это события, связанные с UI, происходящие ровно один раз, причем только когда экран виден пользователю. Например, это могут быть навигация, показ диалога или отображение ошибки.
В нашем примере единственной командой UI будет показ Snackbar при ошибке загрузки value. Для этого заведем Effect ShowError. Для удобства Effect можно создавать как sealed class, чтобы не забыть обработать новые добавленные эффекты:
sealed class Effect {
object ShowError : Effect()
}
Command
Каждая Command обозначает одну асинхронную операцию. Подробнее о том как они обрабатываются расскажем чуть позже. В результате выполнения команды получаются события, которые в свою очередь повлияют на состояние экрана.
У нас будет одна операция - загрузить данные. Эту Command назовем LoadValue. Команды так же удобнее задавать как sealed class:
sealed class Command {
object LoadValue : Command()
}
Event
События описываюся все что происходит пока работает экран. Ничего не может случиться без того, чтобы был получен новый Event. Каждое изменение State, Command или Effect могут быть вызваны только каким-нибудь событием/на состояние экрана и выполняемые операции. В нашем проекте мы разделяем события на две категории:
Event.UI: Все события, которые происходят во View слое. Такие как Жизненный цикл экрана или взаимодействие с пользователем
Event.Internal: результаты выполнения команд в Actor
Однако это раделение делать не обязательно, оно лишь упрощает понимание.
В этом примере будет два UI события: Init - открытие экрана и ReloadClick - нажатие на кнопку обновления значение. Internal события тоже два: ValueLoadingSuccess - успешный результат Command LoadValue и ValueLoadingError, которое будет отправляться при ошибке загрузки значения.
Если использовать разделение на UI и Internal, то Event удобнее задавать как иерархию sealed class:
sealed class Event {
sealed class Ui : Event() {
object Init : Ui()
object ReloadClick : Ui()
}
sealed class Internal : Event() {
data class ValueLoadingSuccess(val value: Int) : Internal()
object ValueLoadingError : Internal()
}
}
Реализуем Store
Закончив с моделями, перейдем собственно к написанию кода. Сам Store реализовывать не нужно, он предоставляется библиотекой классом ElmStore
.
Repository
Для нашего примера напишем симуляцию работы с моделью, которая будет возвращать либо случайный Int, либо ошибку:
object ValueRepository {
private val random = Random()
fun getValue() = Single.timer(2, TimeUnit.SECONDS)
.map { random.nextInt() }
.doOnSuccess { if (it % 3 == 0) error("Simulate unexpected error") }
}
Actor
Actor - место, в котором выполняются долгосрочные операции на экране - запросы в сеть, подписка на обновление данных, итд. В большинстве случаев, как и в нашем примере просто маппит результаты запросов в Event.
Для его создания нужно реализовать интерфейс Actor
, который предоставляется библиотекой. Actor получает на вход Command, а результатом его работы должен быть Observable<Event>
, с событиями, которые сразу будут отправлены в Reducer. Для удобства в библиотеке есть функции mapEvents
, mapSuccessEvent
, mapErrorEvent
и ignoreEvents
, которые позволяют преобразовать данные в Event.
В нашем случае Actor будет выполнять только одну команду. При выполнении команды загрузки мы будем обращаться к репозиторию. В случае получения успешного значения будет оправляться событие ValueLoadingSuccess, а при ошибке ErrorLoadingValue. B итоге получается такая реализация:
class Actor : Actor<Command, Event> {
override fun execute(command: Command): Observable<Event> = when (command) {
is Command.LoadNewValue -> ValueRepository.getValue()
.mapEvents(Internal::ValueLoadingSuccess, Internal.ValueLoadingError)
}
}
Reducer
В этом классе содержится вся логика работы экрана и только она. Это позволяет в одном месте увидеть все что происходит на экране, не отвлекаясь на детали реализации. Так же для него удобно писать тесты, поскольку он не содержит многопоточного кода и представляет из себя чистую функцию. В нем на основании предыдущего состояния экрана и нового события рассчитывается новое состояние экрана, команды и эффекты.
В этом классе нужно реализовать функцию reduce
для обработки событий. Помимо вашей логики в Reducer можно использовать 3 функции:
state
- позволяет изменить состояние экранаeffects
- отправляет эффект во Viewcommands
- запускает команду в Actor
class Reducer : DslReducer<Event, State, Effect, Command>() {
override fun Result.reducer(event: Event) = when (event) {
is Internal.ValueLoaded -> {
state { copy(isLoading = false, value = event.value) }
}
is Internal.ErrorLoadingValue -> {
state { copy(isLoading = false) }
effects { +Effect.ShowError }
}
is Ui.Init -> {
state { copy(isLoading = true) }
commands { +Command.LoadNewValue }
}
is Ui.ClickReload -> {
state { copy(isLoading = true, value = null) }
commands { +Command.LoadNewValue }
}
}
}
Собираем Store
После того как написаны все компоненты нужно создать сам Store:
fun storeFactory() = ElmStore(
initialState = State(),
reducer = MyReducer(),
actor = MyActor()
).start()
Экран
Для написания android приложений в elmslie есть отдельный модуль elmslie-android, в котором предоставляются классы ElmFragment
и ElmAсtivity
. Они упрощают использование библиотеки и имеют схожий вид. В них нужно реализовать несколько методов:
val initEvent: Event
- событие инициализации экранаfun createStore(): Store
- создает Storefun render(state: State)
- отрисовывает State на экранеfun handleEffect(effect: Effect)
- обрабатывает side Effect
В нашем примере получается такая реализация:
class MainActivity : ElmActivity<Event, Effect, State>() {
override val initEvent: Event = Event.Ui.Init
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<Button>(R.id.reload).setOnClickListener {
store.accept(Event.Ui.ClickReload)
}
}
override fun createStore() = storeFactory()
override fun render(state: State) {
findViewById<TextView>(R.id.currentValue).text = when {
state.isLoading -> "Loading..."
state.value == null -> "Value = Unknown"
else -> "Value = ${state.value}"
}
}
override fun handleEffect(effect: Effect) = when (effect) {
Effect.ShowError -> Snackbar
.make(findViewById(R.id.content), "Error!", Snackbar.LENGTH_SHORT)
.show()
}
}
Заключение
В нашей библиотеке мы постарались реализовать максимально простой подход к ELM архитектуре, который был бы максимально удобен в использовании. По ощущениям нашим разработчиков, впервые сталкивающихся с ELM, порог входа в библиотеку невысокий. Также мы постарались сильно облегчить написание кода с помощью кодогенерации. Саму библиотеку мы без проблем используем уже около года в продакшене и полностью ей довольны. Будем рады фидбеку и надеемся, что она пригодится и вам.
BellaLugoshi
я очень далёк от вэба (html не считается же) и от мобильных приложений (простите, но даже hello world в виде apk так и не смог освоить), но пишу для себя на С++/C#. Но я вижу ООП уже почти 30 лет и совершенно в неизменном виде и не могу до сих пор понять, почему для мобилок всё пишется в таком низкоуровневом виде? Одно дело написать например игру, другое дело клиент-серверное ПО для банка, то есть когда у вас клиентское приложение это скорее тонкий клиент. Так вот зачем вообще в мире занимаются низкоуровневой разработкой таких приложений? Это же банально устарело, непродуктивно, каждый городит какие-то библиотеки. Почему не писать так: на одном уровне рисуется набор интерфейсов, на другом в элементах интерфейса изменяются данные полученные с сервера, все равно без интернета такое приложение бесполезно. Зато вы получаете многоязыковое приложение из коробки, кастомный дизайн под каждого клиента. И разработка сводится к описанию того что именно вывести на экран. Я не знаю есть ли такой язык вообще, но это логичное развитие, а то что вы предлагаете для меня выглядит как сова натянутая на глобус джавы.