Вступление

Это третья часть серии статей об архитектуре android приложения vivid.money. В ней мы расскажем об Elmslie - библиотеке для написания кода под android с использованияем ELM архитектуры. Мы назвали ее в честь Джорджа Эльмсли, шотландского архитектора. С сегодняшнего дня она доступна в open source. Это реализация TEA/ELM архитектуры на kotlin поддержкой android. В первой статье мы рассказали о том почему выбрали 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 - отправляет эффект во View

  • commands - запускает команду в 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 - создает Store

  • fun 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, порог входа в библиотеку невысокий. Также мы постарались сильно облегчить написание кода с помощью кодогенерации. Саму библиотеку мы без проблем используем уже около года в продакшене и полностью ей довольны. Будем рады фидбеку и надеемся, что она пригодится и вам.