Пишем под android с Elmslie

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Вступление

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

Оглавление

  • Часть первая. Как мы выбрали архитектуру слоя представления на новом проекте и не прогадали.

  • Часть вторая. Разбираем ELM архитектуру в рамках мобильного приложения.

  • Часть третья. Пишем под android с Elmslie.

Что будем писать

В предыдущей статье мы рассказывали о том что такое 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

Все события, которые влияют на состояние и действия на экране: Ui: ЖЦ экрана, взаимодействие с пользователем, все что приходит из View слоя Internal: Результаты операций с бизнес логикой

Теперь перейдем к событиям. В нашем проекте мы разделяем события на две категории:

  • 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 будет выполнять только одну команду. При выполнении команды загрузки мы будем обращаться к репозиторию. В случае получения успешного значения будет оправляться событие ValueLoaded, а при ошибке ErrorLoadingValue. B итоге получается такая реализация:

class Actor : Actor<Command, Event> {

override fun execute(command: Command): Observable&lt;Event&gt; = when (command) {
    is Command.LoadNewValue -&gt; ValueRepository.getValue()
        .mapEvents(Internal::ValueLoaded, Internal.ErrorLoadingValue)
}

}

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 -&gt; {
        state { copy(isLoading = false, value = event.value) }
    }
    is Internal.ErrorLoadingValue -&gt; {
        state { copy(isLoading = false) }
        effects { +Effect.ShowError }
    }
    is Ui.Init -&gt; {
        state { copy(isLoading = true) }
        commands { +Command.LoadNewValue }
    }
    is Ui.ClickReload -&gt; {
        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&lt;Button&gt;(R.id.reload).setOnClickListener { 
       store.accept(Event.Ui.ClickReload) 
    }
}

override fun createStore() = storeFactory()

override fun render(state: State) {
    findViewById&lt;TextView&gt;(R.id.currentValue).text = when {
        state.isLoading -&gt; &quot;Loading...&quot;
        state.value == null -&gt; &quot;Value = Unknown&quot;
        else -&gt; &quot;Value = ${state.value}&quot;
    }
}

override fun handleEffect(effect: Effect) = when (effect) {
    Effect.ShowError -&gt; Snackbar
        .make(findViewById(R.id.content), &quot;Error!&quot;, Snackbar.LENGTH_SHORT)
        .show()
}

}

Заключение

В нашей библиотеке мы постарались реализовать максимально простой подход к ELM архитектуре, который был бы максимально удобен в использовании. По ощущениям нашим разработчиков, впервые сталкивающихся с ELM, порог входа в библиотеку невысокий. Также мы постарались сильно облегчить написание кода с помощью кодогенерации. Саму библиотеку мы без проблем используем уже около года в продакшене и полностью ей довольны. Будем рады фидбеку и надеемся, что она пригодится и вам.

Источник: https://habr.com/ru/company/vivid_money/blog/553232/


Интересные статьи

Интересные статьи

Недавно я столкнулся с проблемой поддельного жирного и курсивного текста при использовании семейства шрифтов в Android разработке. В этой статье хочу рассказать об этой проблеме и о её р...
Привет, Хабр! Представляю вашему вниманию перевод статьи "Pythonで0からディシジョンツリーを作って理解する (1. 概要編)". 1.1 Что такое Decision Tree? 1.1.1 Пример Decision Tree Например, у нас...
Мне нравится раскладка клавиатур на Mac: Cmd(Ctrl) под большим пальцем и возможность, без шаманства, прямо в настройках изменить поведение CapsLock. Такого же результата легко добитьс...
Хабр, привет! Сегодня я хочу рассказать об архитектуре, которой я следую в своих Android приложениях. За основу я беру Clean Architecture, а в качестве инструментов использую Android Architect...
Первая часть Пример на Github В этой статье расскажу о том, как решал проблемы, с которыми столкнулся в предыдущей части при реализации проекта. Во-первых, при анализе трансформируемого...