- PVSM.RU - https://www.pvsm.ru -

Реактивные приложения с паттерном RxPM. Прощайте​ MVP и MVVM

Уже продолжительное время я размышляю над паттерном RxPM и даже успешно применяю его в «продакшне». Я планировал сначала выступить с этой темой на Mobius [1], но программный комитет отказал, поэтому публикую статью сейчас, чтобы поделиться с Android-сообществом своим видением нового паттерна.

Все знакомы с MVP и MVVM, но мало кто знает, что MVVM является логическим развитием паттерна Presentation Model [2]. Ведь единственное отличие MVVM от PM – это автоматическое связывание данных (databinding).

В этой статье речь пойдет о паттерне Presentation Model с реактивной реализацией биндинга. Некоторые ошибочно называют его RxMVVM, но корректно будет называть его RxPM, потому что это модификация шаблона Presentation Model.

Этот паттерн удобно использовать в проектах с Rx [3], так как он позволяет сделать приложение по-настоящему реактивным. Кроме того, он не имеет многих проблем других паттернов. На диаграмме ниже представлены различные варианты и классификации шаблонов представления:

Реактивные приложения с паттерном RxPM. Прощайте​ MVP и MVVM - 1

Прежде, чем перейти к описанию паттерна RxPM, давайте рассмотрим самые популярные из них — MVP (Passive View) и MVVM. Подробное описание всех паттернов и их различий вы можете прочитать в предыдущей статье [4].

MVP vs PM vs MVVM

Общую схему паттернов можно представить в виде диаграммы:

Реактивные приложения с паттерном RxPM. Прощайте​ MVP и MVVM - 2

С первого взгляда может показаться, что принципиальной разницы между ними нет. Но это только на первый взгляд. Различия заключаются в обязанностях посредника и его способе связи со View. Модель же выглядит во всех паттернах одинаково. Ее проектирование – это сложная и обширная тема, не будем сейчас останавливаться на ней. Начнем с самого популярного паттерна – MVP в варианте Passive View. Рассмотрим его основные проблемы.

MVP

В классическом MVP ответственность за сохранение и восстановление состояния UI лежит на View. Presenter только отслеживает изменения в модели, обновляет View через интерфейс и, наоборот, принимает команды от View и изменяет Model.

Однако при реализации сложных интерфейсов, помимо состояния данных в модели, есть дополнительные состояния UI, которые никак не связаны с данными. Например, какой элемент списка выделен на экране или какими данными заполнены форма ввода, информация о ходе процесса загрузки или запросов в сеть. Восстановление и сохранение UI-состояния во View доставляет большие проблемы, так как View имеет обыкновение «умирать». А информацию о сетевых запросах View в принципе не способна сохранить. Пока View отсоединена от презентера, запрос, скорее всего, завершится с каким-нибудь результатом.

Поэтому работу восстановления состояния UI выносят в презентер. Для этого требуется хранить в презентере дополнительные данные и флаги о текущем состоянии и воспроизводить его при каждом присоединении View.

Вторая проблема вытекает из того же условия, что View может быть в любой момент отсоединена от презентера, например, при повороте экрана. Соответственно, ссылка на интерфейс View в презентере будет обнулена. Поэтому нужно всегда делать проверку на null, когда требуется обновить View. Это довольно утомительно и захламляет код.

Третья проблема: необходимо довольно детально описывать интерфейс View, так как она должна быть как можно более «тупой». А презентеру приходится вызывать множество методов, чтобы привести View в нужное состояние. Это увеличивает количество кода.

PM

Существует другой паттерн под названием Presentation Model, который описал [2] Martin Fowler. Суть этого паттерна заключается в том, что вводится специальная модель, называемая «моделью представления», которая хранит состояние UI и содержит UI-логику. PresentationModel следует рассматривать как абстрактное представление, которое не зависит от какого-либо GUI-фреймворка. PresentationModel хранит состояние в виде свойств (property), которые затем считывает View и отображает на экране. Основная проблема паттерна – это синхронизация состояния PresentationModel и View. Вам придется об этом позаботиться самостоятельно, применив паттерн «Наблюдатель» [5]. Скорее всего, потребуется отслеживать изменения каждого свойства, чтобы не обновлять UI целиком. Получится довольно много скучного и повторяющегося кода.

MVVM

Как вы могли заметить, MVVM очень похож на Presentation Model. Не удивительно, ведь он является его развитием. Только PresentationModel называется ViewModel, а синхронизация состояния ViewModel и View осуществляется с помощью автоматического связывания данных, т. е. датабиндинга. Но и этот паттерн не лишен недостатков. Например, в нем проблематично «чисто» реализовать какие-нибудь анимации или что-либо сделать со View из кода. Об этом подробнее можно почитать в статье [6] моего коллеги Jeevuz [7].

Немного забегая вперед, вот его комментарий из наших обсуждений MVVM и RxPM

Начав обсуждать и обдумывать RxPM я понял, что этот паттерн объединяет в себе то, что мне нравилось в MVVM — понятие ViewModel'и как интерфейса над View, но в то же время не содержит в себе основного недостатка — двойственности. Что логично, ведь нет databinding'a. Но при этом биндинг при помощи Rx не намного сложнее автоматического биндинга c Databinding Library, и при этом очень хорошо подходит для применения в реактивных приложениях.
Как следствие, RxPM решает и проблему состояний. Помните про кубик рубик из моей статьи? Я описывал, что состояние можно описать либо набором полей, либо набором действий… Так вот, RxPM интересным способом объединяет в себе эти два способа: PresentationModel хранит состояния View как набор полей, но так как эти поля представлены BehaviorSubject'ами (которые испускают последнее событие при подписке), то они одновременно являются и «действиями». И получается, что любое событие произошедшее в фоне (пока не было View) прилетит во время подписки. Отлично!

Но самым главным и решающим недостатком всех вышеперечисленных паттернов является то, что взаимодействие View и посредника осуществляется в императивном стиле. Тогда как наша цель – это написание реактивных приложений. UI-слой – это довольной большой источник потока данных, особенно в динамичных интерфейсах, и было бы опрометчиво использовать Rx только для асинхронной работы с моделью.

Реактивный Presentation Model

Мы уже выяснили, что основная проблема паттерна Presentation Model – это синхронизация состояния между PresentationModel и View. Очевидно, что необходимо использовать observable property – свойство, которое умеет уведомлять о своих изменениях. В решении этой задачи нам как раз и поможет RxJava, а заодно мы получим все плюсы реактивного подхода.

Для начала посмотрим на схему паттерна и далее будем разбираться в деталях реализации:
Реактивные приложения с паттерном RxPM. Прощайте​ MVP и MVVM - 3

Итак, ключевым элементом RxPM является реактивное property. Первым кандидатом на роль Rx-property напрашивается BehaviorSubject [8]. Он хранит последнее значение и отдает его каждый раз при подписке.

Вообще Subject’ы уникальны по своей природе: с одной стороны, они являются расширением Observable, а с другой, реализуют интерфейс Observer. То есть мы можем использовать Subject как исходящий поток данных для View, а в PresentationModel он будет потребителем входящего потока данных.

Однако у Subject’ов есть недостатки, которые для нас неприемлемы. По контракту Observable они могут завершаться с событиями onComplete и onError. Соответственно, если Subject будет подписан на что-то, что завершится с ошибкой, то вся цепочка будет остановлена. View перестанет получать события и придется подписываться заново. Кроме того, Rx-property по определению не может посылать события onComplete и onError, так как является всего лишь источником данных (состояния) для View. Тут нам на помощь приходит Jake Wharton со своей библиотекой RxRelay [9]. Что бы мы без него делали? Relay’и лишены описанных недостатков.

В арсенале у нас несколько подклассов:

  • BehaviorRelay – хранит последнее полученное значение и рассылает его каждый раз при подписке. Лучше всего подходит для хранения и изменения состояний.

  • PublishRelay – просто горячий Observable. Подойдет для каких-нибудь команд или событий для View. Например, чтобы показать диалог или запустить анимацию. Также используется для получения команд (событий) от View.

  • ReplayRelay – сохраняет все полученные элементы в буфер и воспроизводит их все при подписке. Крайне редко используется, но может помочь для составных состояний. На ум приходит пример с рисованием: нарисовать линию, потом круг и т. д.

Но мы не можем предоставить доступ View к Relay’ям напрямую. Так как она может случайно положить значение в property или подписаться на Relay, который предназначен для получения команд от View. Поэтому требуется представить свойства в виде Observable [10], а слушатели событий от View как Consumer [11]. Да, инкапсуляция потребует больше кода, но с другой стороны будет сразу понятно, где свойства, а где команды. Пример с прогрессом загрузки в PresentationModel (pm):

//State
private val progress = BehaviorRelay.create<Int>()
// можно в виде property
val progressState: Observable<Int> = progress.hide()
// или в виде функции, если хочется такое же название
fun progress(): Observable<Int> = progress.hide()

//Action
private val downloadClicks = PublishRelay.create<Unit>()
// можно в виде property
val downloadClicksConsumer: Consumer<Unit> = downloadClicks
// или в виде функции, если хочется такое же название
fun downloadClicks(): Consumer<Unit> = downloadClicks

Теперь, когда мы определили стейты и экшены, нам остается только привязаться к ним во View. Для этого нам нужна еще одна библиотека Джейка Вортона — RxBinding [12]. Когда он спит вообще?

pm.progressState.subscribe { progressBar.progress() } // привязываем состояние прогресса
downloadButton.clicks().subscribe { pm.downloadClicksConsumer } // прокидываем клики в PM

Если нет подходящего Observable, то можно вызывать consumer.accept() – напрямую из слушателя виджета.

pm.downloadClicksConsumer.accept(Unit)

А теперь на практике

Теперь соберем все вышесказанное в кучу и разберем на примере. Проектирование PresentationModel можно разбить на следующие шаги:

  1. Определить, какие состояния будет хранить PresentationModel, которые потребуются для View: данные, состояние загрузки, ошибки, которые нужно отобразить и т. п.
  2. Определить, какие события могут происходить во View: клики на кнопки, заполнение полей ввода и т. д.
  3. При создании PresentationModel связать состояния, команды и модель в декларативном стиле, как это позволяет нам Rx.
  4. Привязать View к PresentationModel.

Возьмем для примера задачу поиска слов в тексте:

  • Есть поле ввода для текста, в котором будем искать.
  • Есть поле ввода для слова/части, которое будем искать.
  • По клику на кнопку мы запускаем поиск.
  • Отображаем прогресс во время поиска, на это время блокируем кнопку.
  • После получения ответа отображаем список найденных слов.

Алгоритм поиска скроем за фасадом интерактора:

data class SearchParams(val text: String, val query: String)

interface Interactor {
    fun findWords(params: SearchParams): Single<List<String>>
}

class InteractorImpl : Interactor {
    override fun findWords(params: SearchParams): Single<List<String>> {
        return Single
                .just(params)
                .map { (text, query) ->
                    text
                            .split(" ", ",", ".", "?", "!", ignoreCase = true)
                            .filter { it.contains(query, ignoreCase = true) }
                }
                .subscribeOn(Schedulers.computation())
    }
}

В конкретном примере можно было бы обойтись вообще без Single и Rx, но мы сохраним однообразность интерфейсов. Тем более в реальных приложениях мог быть запрос в сеть через Retrofit [13].

Далее спроектируем PresentationModel.

Состояния для View: список найденых слов, состояние загрузки, флаг активности кнопки поиска. Состояние enabled для кнопки мы можем привязать к флагу загрузки в PresentationModel, но для View мы должны предоставить отдельное свойство. Почему бы просто не привязаться к флагу загрузки во View? Тут мы должны определить, что состояния у нас два: loading и enabled, но в данном случае так совпало, что PresentationModel их связывает. Хотя в общем случае они могут быть независимыми. Например, если бы понадобилось блокировать кнопку до тех пор, пока пользователь не введет минимальное количество символов.

События от View: ввод текста, ввод поискового запроса и клик по кнопке. Тут все просто: фильтруем тексты, объединяем текст и строку поиска в один объект — SearchParams. По клику на кнопку делаем поисковый запрос.

Вот как это выглядит в коде:

class TextSearchPresentationModel {

    private val interactor: Interactor = InteractorImpl()

    // --- States ---
    private val foundWords = BehaviorRelay.create<List<String>>()
    val foundWordState: Observable<List<String>> = foundWords.hide()

    private val loading = BehaviorRelay.createDefault<Boolean>(false)
    val loadingState: Observable<Boolean> = loading.hide()

    val searchButtonEnabledState: Observable<Boolean> = loading.map { !it }.hide()
    // --------------

    // --- UI-events ---
    private val searchQuery = PublishRelay.create<String>()
    val searchQueryConsumer: Consumer<String> = searchQuery

    private val inputTextChanges = PublishRelay.create<String>()
    val inputTextChangesConsumer: Consumer<String> = inputTextChanges

    private val searchButtonClicks = PublishRelay.create<Unit>()
    val searchButtonClicksConsumer: Consumer<Unit> = searchButtonClicks

    // ---------------

    private var disposable: Disposable? = null

    fun onCreate() {

        val filteredText = inputTextChanges.filter(String::isNotEmpty)
        val filteredQuery = searchQuery.filter(String::isNotEmpty)

        val combine = Observable.combineLatest(filteredText, filteredQuery, BiFunction(::SearchParams))

        val requestByClick = searchButtonClicks.withLatestFrom(combine,
                BiFunction<Unit, SearchParams, SearchParams> { _, params: SearchParams -> params })

        disposable = requestByClick
                .filter { !isLoading() }
                .doOnNext { showProgress() }
                .delay(3, TimeUnit.SECONDS) // делаем задержку чтобу увидеть прогресс
                .flatMap { interactor.findWords(it).toObservable() }
                .observeOn(AndroidSchedulers.mainThread())
                .doOnEach { hideProgress() }
                .subscribe(foundWords)
    }

    fun onDestroy() {
        disposable?.dispose()
    }

    private fun isLoading() = loading.value
    private fun showProgress() = loading.accept(true)
    private fun hideProgress() = loading.accept(false)
}

В роли View у нас будет выступать фрагмент:

class TextSearchFragment : Fragment() {

    private val pm = TextSearchPresentationModel()
    private var composite = CompositeDisposable()

    private lateinit var inputText: EditText
    private lateinit var queryEditText: EditText
    private lateinit var searchButton: Button
    private lateinit var progressBar: ProgressBar
    private lateinit var resultText: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true //не умираем при поворотах экрана
        pm.onCreate()
    }

    // ... onCreateView

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // ... init widgets

        onBindPresentationModel()
    }

    fun onBindPresentationModel() {

        // --- States ---
        pm.foundWordState
                .subscribe {
                    if (it.isNotEmpty()) {
                        resultText.text = it.joinToString(separator = "n")
                    } else {
                        resultText.text = "Nothing found"
                    }
                }
                .addTo(composite)

        pm.searchButtonEnabledState
                .subscribe(searchButton.enabled())
                .addTo(composite)

        pm.loadingState
                .subscribe(progressBar.visibility())
                .addTo(composite)
        // ---------------

        // --- Ui-events ---
        queryEditText
                .textChanges()
                .map { it.toString() }
                .subscribe(pm.searchQueryConsumer)
                .addTo(composite)

        inputText
                .textChanges()
                .map { it.toString() }
                .subscribe(pm.inputTextChangesConsumer)
                .addTo(composite)

        searchButton.clicks()
                .subscribe(pm.searchButtonClicksConsumer)
                .addTo(composite)
        //------------------
    }

    fun onUnbindPresentationModel() {
        composite.clear()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        onUnbindPresentationModel()
    }

    override fun onDestroy() {
        super.onDestroy()
        pm.onDestroy()
    }
}

// Расширение из RxKotlin
/**
 * Add the disposable to a CompositeDisposable.
 * @param compositeDisposable CompositeDisposable to add this disposable to
 * @return this instance
 */
fun Disposable.addTo(compositeDisposable: CompositeDisposable): Disposable
        = apply { compositeDisposable.add(this) }

Подведем итоги

Мы познакомились c новым паттерном RxPM и разобрали минусы других шаблонов представления. Но я не хочу однозначно сказать, что MVP и MVVM хуже или лучше RxPM. Я также, как и многие люблю MVP за его простоту и прямолинейность. А MVVM хорош наличием автоматического датабиндинга, хотя код в верстке – это на любителя.

Но в современных приложениях с динамичным UI очень много событийного и асинхронного кода. Поэтому мой выбор склоняется в сторону реактивного подхода и RxPM. Приведу слова из презентации [14] Джейка Вортона, почему наши приложения должны быть реактивными:

Unless you can model your entire system synchronously, a single asynchronously source breaks imperative programming.
Если вы не можете смоделировать всю систему синхронно, то даже один асинхронный источник ломает императивное программирование.

Разумеется, у RxPM есть как плюсы, так и минусы.

Плюсы:

  • Позволяет не разрывать реактивные цепочки Observable и протягивать их от модели до View и наоборот. Это избавляет от императивного взаимодействия со View.
  • Декларативное описание логики в PresentationModel.
  • PresentationModel представляет собой абстракцию View, не завязана на конкретные виджеты.
  • Не нужно беспокоиться о том, присоединена View или нет. Просто меняем значение Rx-property. View автоматически получит стейт, когда подпишется.
  • События от View получаем в реактивном стиле, удобно применять операторы Rx, чтобы фильтровать, объединять и т. д.

Минусы:

  • Необходимо писать код связывания, но c Rx это легко.
  • Требуется инкапсулировать Relay’и, представлять их для View в виде Observable и Consumer. Это пока единственное, что напрягает меня.
  • Обилие Rx. Можно рассматривать и как минус, и как плюс. Реактивность – это другая парадигма, поэтому не удивительно, что начав использовать Rx, приходится использовать его повсюду. Никто же не трубит по поводу множества объектов при программировании на Java – это парадигма ООП.

Это, наверное, не полный список. Напишите в комментариях, какие вы видите плюсы и минусы, будет интересно узнать ваше мнение.

Итак, если вы чувствуете себя уверенно c Rx и хотите писать реактивные приложения, если вы устали от MVP и MVVM c databinding, то вам стоит попробовать RxPM. Ну а если вам и так комфортно, то не буду вас уговаривать ;)

P. S.

Искушенный Android-разработчик, скорее всего, заметил, что я ничего не говорил о жизненном цикле и о сохранении PresentationModel во время поворота. Эта проблема не специфична для данного паттерна и заслуживает отдельного рассмотрения. В своей статье я хотел сосредоточиться на самой сути паттерна: его плюсах и минусах в сравнении с MVP и MVVM. Также не были затронуты такие важные темы, как двусторонний databinding, навигация между экранами в контексте RxPM и некоторые другие. В следующей статье мы c Jeevuz [7] постараемся рассказать о том, как начать использовать RxPM в реальном проекте и представим некоторое библиотечное решение, упрощающее его применение.

Автор: dmdev

Источник [15]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/android-development/253273

Ссылки в тексте:

[1] Mobius: https://mobiusconf.com

[2] Presentation Model: https://www.martinfowler.com/eaaDev/PresentationModel.html

[3] Rx: http://reactivex.io

[4] статье: https://habrahabr.ru/company/mobileup/blog/313538/

[5] «Наблюдатель»: https://ru.wikipedia.org/wiki/Наблюдатель_(шаблон_проектирования)

[6] статье: https://habrahabr.ru/company/mobileup/blog/312548/

[7] Jeevuz: https://habrahabr.ru/users/jeevuz/

[8] BehaviorSubject: http://reactivex.io/documentation/subject.html

[9] RxRelay: https://github.com/JakeWharton/RxRelay

[10] Observable: https://github.com/ReactiveX/RxJava/blob/2.x/src/main/java/io/reactivex/Observable.java

[11] Consumer: https://github.com/ReactiveX/RxJava/blob/2.x/src/main/java/io/reactivex/functions/Consumer.java

[12] RxBinding: https://github.com/JakeWharton/RxBinding

[13] Retrofit: http://square.github.io/retrofit/

[14] презентации: https://speakerdeck.com/jakewharton/exploring-rxjava-2-for-android-gotocph-october-2016?slide=34

[15] Источник: https://habrahabr.ru/post/326962/