Архитектура EBA aka реактивность на всю катушку

в 9:48, , рубрики: android, architecture, EBA, kotlin, tinkoff, Блог компании Tinkoff.ru, разработка мобильных приложений, Разработка под android

Я пришел в Tinkoff пару лет назад, на новый проект Клиенты и проекты, который тогда только запускался.
Сейчас уже не помню своих ощущений от новой тогда для меня архитектуры. Но точно помню: было непривычно, что Rx используется еще где-то, за пределами обычных походов в сеть и в базу. Сейчас, когда эта архитектура уже прошла некоторый эволюционный путь развития, хочется наконец рассказать о том, что было и к чему пришло.

Архитектура EBA aka реактивность на всю катушку - 1

По моему мнению, все популярные ныне архитектуры — MVP, MVVM и даже MVI — уже давно на арене и не всегда заслуженно. Разве у них нет недостатков? Я вижу их немало. Мы у себя решили, что хватит это терпеть, и (пере)изобрели новую, максимально асинхронную архитектуру.

Тезисно опишу, что мне не нравится в текущих архитектурах. Некоторые пункты могут быть спорными. Возможно, вы с таким никогда не сталкивались, пишете идеально и вообще джедай программирования. Тогда простите меня, грешного.
Итак, моя боль — это:

  • Огромные Presenter/ViewModel.
  • Огромное количество switch-case в MVI.
  • Невозможность переиспользовать части Presenter/ViewModel и, как следствие, необходимость дублировать код.
  • Кучи мутабельных переменных, которые можно модифицировать откуда угодно. Соответственно, такой код сложно поддерживать и изменять.
  • Не декомпозированное обновление экрана.
  • Сложно писать тесты.

Проблематика

В каждый момент времени у приложения есть определенное состояние, которое задает его поведение и то, что видит пользователь. Это состояние включает в себя все значения переменных — от простых флагов до отдельных объектов. Каждая из этих переменных живет своей жизнью и управляется различными частями кода. Определить текущее состояние приложения можно, лишь проверив их все, одну за другой.
Статья о современной MVI-архитектуре на Kotlin

Глава 1. Эволюция — наше всё

Изначально мы писали на MVP, но немного мутированной. Это была какая-то смесь MVP и MVI. Были сущности из MVP в виде презентера и интерфейса View:

interface NewTaskView {
   val newTaskAction: Observable<NewTaskAction>
   val taskNameChangeAction: Observable<String>
   val onChangeState: Consumer<SomeViewState>
}

Уже тут можно заметить подвох: View здесь очень далека от канонов MVP. В презентере был метод:

fun bind(view: SomeView): Disposable

Снаружи передавалась реализация интерфейса, которая реактивно подписывалась на изменения UI. И это уже попахивает MVI!

Дальше — больше. В Presenter’e создавались и подписывались на изменения View разные интеракторы, но они не вызывали методы UI напрямую, а возвращали некоторый глобальный State, в котором были все возможные состояния экрана:

compositeDisposable.add(
    Observable.merge(firstAction, secondAction)
               .observeOn(AndroidSchedulers.mainThread())
               .subscribe(view.onChangeState))
return compositeDisposable

class SomeViewState(val progress: Boolean? = null,
                            val error: Throwable? = null,
                            val errorMessage: String? = error?.message,
                            val result: TaskUi? = null)

Активити была наследником интерфейса SomeViewStateMachine:

interface SomeViewStateMachine {
   fun toSuccess(task: SomeUiModel)
   fun toError(error: String?)
   fun toProgress()
   fun changeSomeButton(buttonEnabled: Boolean)
}

Когда пользователь нажимал на что-то на экране, в презентер приходило событие и он создавал новую модель, которую отрисовывал специальный класс:

class SomeViewStateResolver(private val stateMachine: SomeViewStateMachine) :
       Consumer<SomeViewState> {

   override fun accept(stateUpdate: SomeViewState) {
       if (stateUpdate.result != null) {
           stateMachine.toSuccess(stateUpdate.result)
       } else if (stateUpdate.error != null && stateUpdate.progress == false) {
           stateMachine.toError(stateUpdate.errorMessage)
       } else if (stateUpdate.progress == true) {
           stateMachine.toProgress()
       } else if (stateUpdate.someButtonEnabled != null) {
           stateMachine.changeSomeButton(stateUpdate.someButtonEnabled)
       }
   }
}

Согласитесь, какой-то странный MVP, да и от MVI далеко. Ищем вдохновение.

Глава 2. Redux

Архитектура EBA aka реактивность на всю катушку - 2

Общаясь о своих проблемах с другими разработчиками, наш (тогда еще) лид Сергей Боиштян узнал про Redux.

Посмотрев доклад Дорфмана про все архитектуры и поигравшись с Redux, мы решили с её помощью модернизировать нашу архитектуру.
Но сначала давайте взглянем на архитектуру поближе и рассмотрим ее плюсы и минусы.

Action
Описывает действие.

ActionCreator
Он как системный аналитик: форматирует, дополняет ТЗ заказчика так, чтобы его понимали программисты.
Когда пользователь кликает на экран, ActionsCreator формирует Action, который идет в middleware (какая-то бизнес-логика). Бизнес-логика отдает нам новые данные, которые получает и отрисовывает определенный Reducer.

Если вы еще раз посмотрите на картинку, то можете заметить такой объект, как Store. Store хранит в себе Reducer’ы. То есть мы видим, что фронтендеры — братья по несчастью — догадались, что можно один большой объект распилить на много маленьких, каждый из которых будет отвечать за свою часть экрана. И это просто замечательная мысль!

Примеры кода простых ActionCreator’ов (осторожно, JavaScript!):

export function addTodo(text) {
  return { type: ADD_TODO, text }
}
export function toggleTodo(index) {
  return { type: TOGGLE_TODO, index }
}
export function setVisibilityFilter(filter) {
  return { type: SET_VISIBILITY_FILTER, filter }
}

Reducer

Actions описывает факт, что что-то произошло, но не указывает, как состояние приложения должно измениться в ответ, это работа для Reducer'а.

Короче говоря, Reducer знает, как декомпозированно обновлять экран/view.

Плюсы:

  • Декомпозированное обновление экрана.
  • Однонаправленный поток данных.

Минусы:

  • Снова любимый switch.
    function todoApp(state = initialState, action) {
    switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    case ADD_TODO:
      return Object.assign({}, state, {
        todos: [
          ...state.todos,
          {
            text: action.text,
            completed: false
          }
        ]
      })
    default:
      return state
    }
  • Куча объектов состояния.
  • Разделение логики на ActionCreator и Reducer.

Да, нам показалось, что разделение на ActionCreator и Reducer не самый удачный вариант связки модели и экрана, потому что писать instanceof(is) — плохой подход. И тут-то мы и изобрели НАШУ архитектуру!

Глава 3. EBA

Архитектура EBA aka реактивность на всю катушку - 3

Что такое Action и ActionCreator в контексте EBA:

typealias Action = () -> Unit
typealias ActionMapper<T> = (T) -> Action
interface ActionCreator<T> : (T) -> (Observable<Action>)

Да, половина архитектуры — это typealias’ы и интерфейс. Простота равно изящность!

Action нужен для того, чтобы что-то вызвать без передачи каких-то данных. Так как ActionCreator возвращает Observable, нам пришлось обернуть Action в еще одну лямбду для передачи каких-то данных. Так и получился ActionMapper — типизированный Action, через который мы можем передавать то, что нам нужно для обновления экрана/view.

Основные постулаты:

Один ActionCreator — одна часть экрана

С первым пунктом все ясно: чтобы не было ада из непонятных перекрестных обновлений, мы договорились, что один ActionCreator может обновлять только свою часть экрана. Если это список — он обновляет только список, если кнопка — только ее.

Dagger не нужен

Но, спрашивается, чем нам Dagger не угодил? Рассказываю.
Типичная история, когда на проекте есть абстрактный Сергей aka даггер-мастер aka «А что эта аннотация делает?».

Получается так, что, если ты экспериментировал с даггером, приходится объяснять каждый раз каждому новому (да и не только новому) разработчику. А может, ты и сам уже забыл, что эта аннотация делает, и идешь гуглить.

Все это сильно усложняет процесс создания фичи, не привнося особого удобства. Поэтому мы решили, что будем создавать необходимые нам вещи руками, так это будет быстрее собираться, ведь нет никакой кодогенерации. Да, мы потратим лишних пять минут на написание руками всех зависимостей, но сэкономим много времени на компиляции. Да, мы не везде отказались от даггера, он используется на глобальном уровне, создает какие-то общие вещи, но и их — для большей оптимизации — мы пишем на Java, чтобы не привлекать kapt.

Схема архитектуры:

Архитектура EBA aka реактивность на всю катушку - 4

Component это аналог того самого компонента из Dagger’a, только без Dagger’a. Его задача — создать Binder. Binder связывает воедино ActionCreator’ы. Из View в Binder приходят Events о том, что произошло, а из Binder’a во View отправляются Actions, которые обновляют экран.

ActionCreator

Архитектура EBA aka реактивность на всю катушку - 5

Теперь давайте разберемся, что это за штука такая — ActionCreator. В самом простом случае он просто однонаправленно обрабатывает действие. Допустим, есть такой сценарий: пользователь кликнул на кнопку «Создать задачу». Должен открыться другой экран, где мы будем ее описывать, без всяких дополнительных запросов.

Для этого мы просто подписываемся на кнопку с помощью RxBinding от нашего любимого Джейка и ждем, когда пользователь на нее кликнет. Как только произойдет клик — Binder отправит Event в конкретный ActionCreator, который вызовет наш Action, который откроет нам новый экран. Заметьте, тут не было никаких switch. Дальше я покажу в коде, почему именно так.
Если нам вдруг надо сходить в сеть или в базу, мы делаем эти запросы тут же, но через интеракторы, которые мы передали в конструктор ActionCreator’a по интерфейсу их вызова:

Дисклеймер: форматирование кода у нас не совсем такое, я его правил для статьи, чтобы код хорошо читался.

class LoadItemsActionCreator(
          private val getItems: () -> Observable<List<ViewTyped>>,
          private val showLoadedItems: ActionMapper<DiffResult<ViewTyped>>,
          private val diffCalculator: DiffCalculator<ViewTyped>,
          private val errorItem: ErrorView,
          private val emptyItem: ViewTyped? = null) : ActionOnEvent

Под словами «по интерфейсу их вызова» я имел в виду как раз то, как объявляется getItems (здесь ViewTyped — это наш интерфейс для работы со списками). Кстати, этот ActionCreator у нас переиспользуется в восьми разных частях приложения, потому что он написан максимально универсально.

Так как события имеют реактивную природу, мы можем собирать цепочку, добавляя туда другие операторы, например startWith(showLoadingAction), чтобы показать загрузку, и onErrorReturn(errorAction), чтобы показать стейт экрана с ошибкой.
И все это реактивно!

Пример

class AboutFragment : CompositionFragment(R.layout.fragment_about) {
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
     val component = AboutComponent(
                setVersionName = { { appVersion.text = it } },
                openPdfAction = { (url, name) -> { openPdf(url, name) } })
     val events = AboutEventsImpl(
             bindEvent = bindEvent,
             openPolicyPrivacyEvent = confidentialityPolicy.clicks(),
             openProcessingPersDataEvent = personalDataProtection.clicks(),
             unbindEvent = unBindEvent)

     component.binder().bind(events)
   }

Давайте уже наконец рассмотрим архитектуру на примере кода. Для начала я выбрал один из самых простых экранов — о приложении, потому что это статичный экран.
Рассмотрим создание компонента:

val component = AboutComponent(
        setVersionName = { { appVersion.text = it } },
        openPdfAction = { (url, name) -> { openPdf(url, name) } }
                              )

Аргументы компонента — Action’ы/ActionMapper’ы — помогают связать View с ActionCreator’ами. В ActionMapper’e setVersionName мы передаем версию проекта и присваиваем тексту на экране это значение. В openPdfAction — пару из ссылки на документ и имени для открытия следующего экрана, где пользователь может прочитать этот документ.

Вот так выглядит сам компонент:

class AboutComponent(
                 private val setVersionName: ActionMapper<String>,
                 private val openPdfAction: ActionMapper<Pair<String, String>>) {

   fun binder(): AboutEventsBinder {
       val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, someUrlString)
       val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, 
                                                             anotherUrlString)
       val setVersionName = setVersionName.toSimpleActionCreator(
                                                moreComponent::currentVersionName
                                                              )
       return AboutEventsBinder(setVersionName, 
                                openPolicyPrivacy, 
                                openProcessingPersonalData)
   }
}

Напомню, что:

typealias Action = () -> Unit
typealias ActionMapper<T> = (T) -> Action

Окей, идем дальше.

fun binder(): AboutEventsBinder

Давайте рассмотрим AboutEventsBinder подробнее.

class AboutEventsBinder(private val setVersionName: ActionOnEvent,
                        private val openPolicyPrivacy: ActionOnEvent,
                        private val openProcessingPersonalData: ActionOnEvent) :
       BaseEventsBinder<AboutEvents>() {

   override fun bindInternal(events: AboutEvents): Observable<Action> {
       return Observable.merge(
         setVersionName(events.bindEvent),
         openPolicyPrivacy(events.openPolicyPrivacyEvent),
         openProcessingPersonalData(events.openProcessingPersDataEvent))
   }
}

ActionOnEvent — это очередной typealias, чтобы не писать каждый раз.

ActionCreator<Observable<*>>

В AboutEventsBinder мы передаем ActionCreator’ы и, вызывая их, связываем с конкретным событием. Но, чтобы понять, как всё это связывается, давайте рассмотрим базовый класс — BaseEventsBinder.

abstract class BaseEventsBinder<in EVENTS : BaseEvents>(
   private val uiScheduler: Scheduler = AndroidSchedulers.mainThread()
                                                       ) {

   fun bind(events: EVENTS) {
       bindInternal(events).observeOn(uiScheduler)
               .takeUntil(events.unbindEvent)
               .subscribe(Action::invoke)
   }

   protected abstract fun bindInternal(events: EVENTS): Observable<Action>
}

Видим знакомый метод bindInternal, который мы переопределили в наследнике. Теперь рассмотрим метод bind. Вся магия заключена тут. Мы принимаем наследника интерфейса BaseEvents, передаем его в bindInternal для связи Events и Actions. Один раз говорим, что всё, что бы ни пришло, исполняем на ui-потоке и подписываемся. Также видим интересный хак — takeUntil.

interface BaseEvents {
   val unbindEvent: EventObservable
}

Определив в BaseEvents поле unbindEvent для контроля отписки, мы обязаны реализовывать ее во всех наследниках. Это замечательное поле позволяет отписываться от цепочки автоматически, как только данный эвент выполнится. Это же просто великолепно! Теперь можно не следить и не париться по поводу жизненного цикла и спать спокойно.

val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, policyPrivacyUrl)
val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, 
                                                      personalDataUrl)

Вернемся к компоненту. И уже тут виден способ переиспользования. Мы написали один класс, который умеет открывать экран просмотра pdf, и нам без разницы — с каким url. Никакой дубликации кода.

class OpenPdfActionCreator(
                   private val openPdfAction: ActionMapper<Pair<String, String>>, 
                   private val pdfUrl: String) : ActionOnEvent {

   override fun invoke(event: EventObservable): Observable<Action> {
       return event.map {
           openPdfAction(pdfUrl to pdfUrl.substringAfterLast(FILE_NAME_DELIMITER))
       }
   }
}

Код ActionCreator’а тоже максимально простой, тут мы просто производим некоторые манипуляции со строкой.

Снова вернемся к компоненту и рассмотрим следующий ActionCreator:

setVersionName.toSimpleActionCreator(moreComponent::currentVersionName)

Однажды нам стало лень писать одинаковые и простые по своей сути ActionCreator’ы. Мы воспользовались мощью котлина и написали extension’ы. Например, в этом случае нам нужно было просто передать в ActionMapper статическую строку.

fun <R> ActionMapper<R>.toSimpleActionCreator(
                                  mapper: () -> R): ActionCreator<Observable<*>> {
   return object : ActionCreator<Observable<*>> {
       override fun invoke(event: Observable<*>): Observable<Action> {
           return event.map { this@toSimpleActionCreator(mapper()) }
       }
   }
}

Бывают случаи, когда нам вообще ничего передавать не надо, а только вызвать какой-то Action — например, чтобы открыть следующий экран:

fun Action.toActionCreator(): ActionOnEvent {
   return object : ActionOnEvent {
       override fun invoke(event: EventObservable): Observable<Action> {
           return event.map { this@toActionCreator }
       }
   }
}

Итак, с компонентом покончено, возвращаемся во фрагмент:

val events = AboutEventsImpl(
  bindEvent = bindEvent,
  openPolicyPrivacyEvent = confidentialityPolicy.throttleFirstClicks(),
  openProcessingPersDataEvent = personalDataProtection.throttleFirstClicks(),
  unbindEvent = unBindEvent)

Здесь мы видим создание класса, отвечающего за прием событий от пользователя. А unbind и bind — это просто события жизненного цикла экрана, которые мы забираем с помощью библиотеки Navi от Trello.

fun <T> NaviComponent.observe(event: Event<T>): Observable<T> = 
                                                    RxNavi.observe(this, event)

val unBindEvent: Observable<*> = observe(Event.DESTROY_VIEW)
val bindEvent: Observable<*> = Observable.just(true)
или
val bindEvent = observe(Event.POST_CREATE)

В интерфейсе Events описываются события конкретного экрана, плюс он обязан наследовать BaseEvents. Ниже всегда следует реализация интерфейса. В данном случае эвенты получились один в один с теми, что приходят с экрана, но бывает, что нужно смержить два события.

Например, события загрузки экрана при открытии и повторной загрузке в случае ошибки должны быть объединены в один — просто загрузку экрана.

interface AboutEvents : BaseEvents {
   val bindEvent: EventObservable
   val openPolicyPrivacyEvent: EventObservable
   val openProcessingPersDataEvent: EventObservable
}

class AboutEventsImpl(override val bindEvent: EventObservable,
                     override val openPolicyPrivacyEvent: EventObservable,
                     override val openProcessingPersDataEvent: EventObservable,
                     override val unbindEvent: EventObservable) : AboutEvents

Возвращаемся во фрагмент и соединяем все воедино! Просим у компонента создать и вернуть нам binder, далее вызываем на нем метод bind, куда передаем объект, наблюдающий за событиями экрана.

component.binder().bind(events)

На этой архитектуре мы пишем проект уже около двух лет. И нет предела счастью менеджеров в скорости деливеринга фич! Они не успевают новую придумать, как мы уже заканчиваем старую. Архитектура очень гибкая и позволяет переиспользовать много кода.
Минусом данной архитектуры можно назвать несохранение состояния. У нас нет целой модели, описывающей состояние экрана, как в MVI, но мы с этим справляемся. Как — смотрите ниже.

Глава 4. Бонус

Думаю, всем знакома проблема аналитики: никто не любит ее писать, потому что она лезет через все слои и уродует вызовы. Некоторое время назад и нам пришлось с этим столкнуться. Но благодаря нашей архитектуре получилась очень красивая реализация.

Итак, какая у меня была идея: аналитика обычно уходит в ответ на действия пользователя. А у нас как раз есть класс, который аккумулирует действия пользователя. Окей, приступим.

Шаг 1. Немного меняем базовый класс BaseEventsBinder, обернув events в trackAnalytics:

abstract class BaseEventsBinder<in EVENTS : BaseEvents>(
   private val trackAnalytics: TrackAnalytics<EVENTS> = EmptyAnalyticsTracker(),
   private val uiScheduler: Scheduler = AndroidSchedulers.mainThread()) {

   @SuppressLint("CheckResult")
   fun bind(events: EVENTS) {
       bindInternal(trackAnalytics(events)).observeOn(uiScheduler)
               .takeUntil(events.unbindEvent)
               .subscribe(Action::invoke)
   }

   protected abstract fun bindInternal(events: EVENTS): Observable<Action>
}

Шаг 2. Создаем стабовую реализацию переменной trackAnalytics, чтобы поддержать обратную совместимость и не сломать наследников, которым пока не нужна аналитика:

interface TrackAnalytics<EVENTS : BaseEvents> {
   operator fun invoke(events: EVENTS): EVENTS
}

class EmptyAnalyticsTracker<EVENTS : BaseEvents> : TrackAnalytics<EVENTS> {
   override fun invoke(events: EVENTS): EVENTS = events
}

Шаг 3. Пишем реализацию интерфейса TrackAnalytics для нужного экрана — допустим, для экрана списка проектов:

class TrackProjectsEvents : TrackAnalytics<ProjectsEvents> {
   override fun invoke(events: ProjectsEvents): ProjectsEvents {
       return object : ProjectsEvents by events {
           override val boardClickEvent = events.boardClickEvent.trackTypedEvent {
               allProjectsProjectClick(it.title)
           }
           override val openBoardCreationEvent = 
                                        events.openBoardCreationEvent.trackEvent {
               allProjectsAddProjectClick()
           }
           override val openCardsSearchEvent = 
                                          events.openCardsSearchEvent.trackEvent {
               allProjectsSearchBarClick()
           }
       }
   }
}

Тут мы опять используем мощь котлина в виде делегатов. У нас уже есть созданный нами наследник интерфейса — в данном случае ProjectsEvents. Но для некоторых событий надо переопределить то, как идут события, и добавить вокруг них обвязку с отсылкой аналитики. На деле trackEvent это просто doOnNext:

inline fun <T> Observable<T>.trackEvent(crossinline event: AnalyticsSpec.() -> Unit): Observable<T> =
       doOnNext { event(analyticsSpec) }

inline fun <T> Observable<T>.trackTypedEvent(crossinline event: AnalyticsSpec.(T) -> Unit): Observable<T> =
       doOnNext { event(analyticsSpec, it) }

Шаг 4. Осталось передать это в Binder. Так как мы его конструируем в компоненте, у нас есть возможность, если вдруг понадобится, докинуть в конструктор дополнительные зависимости. Теперь конструктор ProjectsEventsBinder будет выглядеть так:

class ProjectsEventsBinder(
                        private val loadItems: LoadItemsActionCreator,
                        private val refreshBoards: ActionOnEvent,
                        private val openBoard: ActionCreator<Observable<BoardId>>,
                        private val openScreen: ActionOnEvent,
                        private val openCardSearch: ActionOnEvent,
                        trackAnalytics: TrackAnalytics<ProjectsEvents>) :
       BaseEventsBinder<ProjectsEvents>(trackAnalytics)

Другие примеры можете посмотреть на GitHub .

Вопросы и ответы

Как вы храните стейт экрана?

Никак. Мы блочим ориентацию. Но также используем arguments/intent и сохраняем туда переменную OPENED_FROM_BACKSTACK. И при конструировании Binder’а смотрим на нее. Если она false — грузим данные из сети. Если true — из кэша. Это позволяет быстро пересоздать экран.

Для всех, кто против блокирования ориентации: попробуйте провести тест и залогировать в аналитику, как часто ваши пользователи переворачивают телефон и сколько находятся в другой ориентации. Результаты могут удивить.

Я не хочу писать компоненты, как мне задружить это все с даггером?

Не советую, но если не жалко времени на компиляцию — можно создавать Component и через даггер. Но мы не пробовали.

Я не пишу на котлин, какие сложности с реализацией на Java?

Все то же самое можно написать и на Java, просто выглядеть будет не так красиво.

Если вам понравится статья — следующая часть будет про то, как на такой архитектуре писать тесты (тут-то и станет ясно, зачем столько интерфейсов). Спойлер — писать легко и можно писать на все слои, кроме компонента, но его и не нужно тестировать, он просто создает объект binder’а.

Спасибо коллегам из команды мобильной разработки Тинькофф Бизнес за помощь в составлении статьи.

Автор: Александр Гузенко

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js