Уже продолжительное время я размышляю над паттерном RxPM и даже успешно применяю его в «продакшне». Я планировал сначала выступить с этой темой на Mobius, но программный комитет отказал, поэтому публикую статью сейчас, чтобы поделиться с Android-сообществом своим видением нового паттерна.
Все знакомы с MVP и MVVM, но мало кто знает, что MVVM является логическим развитием паттерна Presentation Model. Ведь единственное отличие MVVM от PM – это автоматическое связывание данных (databinding).
В этой статье речь пойдет о паттерне Presentation Model с реактивной реализацией биндинга. Некоторые ошибочно называют его RxMVVM, но корректно будет называть его RxPM, потому что это модификация шаблона Presentation Model.
Этот паттерн удобно использовать в проектах с Rx, так как он позволяет сделать приложение по-настоящему реактивным. Кроме того, он не имеет многих проблем других паттернов. На диаграмме ниже представлены различные варианты и классификации шаблонов представления:
Прежде, чем перейти к описанию паттерна RxPM, давайте рассмотрим самые популярные из них — MVP (Passive View) и MVVM. Подробное описание всех паттернов и их различий вы можете прочитать в предыдущей статье.
MVP vs PM vs MVVM
Общую схему паттернов можно представить в виде диаграммы:
С первого взгляда может показаться, что принципиальной разницы между ними нет. Но это только на первый взгляд. Различия заключаются в обязанностях посредника и его способе связи со 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, который описал Martin Fowler. Суть этого паттерна заключается в том, что вводится специальная модель, называемая «моделью представления», которая хранит состояние UI и содержит UI-логику. PresentationModel следует рассматривать как абстрактное представление, которое не зависит от какого-либо GUI-фреймворка. PresentationModel хранит состояние в виде свойств (property), которые затем считывает View и отображает на экране. Основная проблема паттерна – это синхронизация состояния PresentationModel и View. Вам придется об этом позаботиться самостоятельно, применив паттерн «Наблюдатель». Скорее всего, потребуется отслеживать изменения каждого свойства, чтобы не обновлять UI целиком. Получится довольно много скучного и повторяющегося кода.
MVVM
Как вы могли заметить, MVVM очень похож на Presentation Model. Не удивительно, ведь он является его развитием. Только PresentationModel называется ViewModel, а синхронизация состояния ViewModel и View осуществляется с помощью автоматического связывания данных, т. е. датабиндинга. Но и этот паттерн не лишен недостатков. Например, в нем проблематично «чисто» реализовать какие-нибудь анимации или что-либо сделать со View из кода. Об этом подробнее можно почитать в статье моего коллеги Jeevuz.
Начав обсуждать и обдумывать 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 является реактивное property. Первым кандидатом на роль Rx-property напрашивается BehaviorSubject. Он хранит последнее значение и отдает его каждый раз при подписке.
Вообще Subject’ы уникальны по своей природе: с одной стороны, они являются расширением Observable, а с другой, реализуют интерфейс Observer. То есть мы можем использовать Subject как исходящий поток данных для View, а в PresentationModel он будет потребителем входящего потока данных.
Однако у Subject’ов есть недостатки, которые для нас неприемлемы. По контракту Observable они могут завершаться с событиями onComplete и onError. Соответственно, если Subject будет подписан на что-то, что завершится с ошибкой, то вся цепочка будет остановлена. View перестанет получать события и придется подписываться заново. Кроме того, Rx-property по определению не может посылать события onComplete и onError, так как является всего лишь источником данных (состояния) для View. Тут нам на помощь приходит Jake Wharton со своей библиотекой RxRelay. Что бы мы без него делали? Relay’и лишены описанных недостатков.
В арсенале у нас несколько подклассов:
-
BehaviorRelay – хранит последнее полученное значение и рассылает его каждый раз при подписке. Лучше всего подходит для хранения и изменения состояний.
-
PublishRelay – просто горячий Observable. Подойдет для каких-нибудь команд или событий для View. Например, чтобы показать диалог или запустить анимацию. Также используется для получения команд (событий) от View.
- ReplayRelay – сохраняет все полученные элементы в буфер и воспроизводит их все при подписке. Крайне редко используется, но может помочь для составных состояний. На ум приходит пример с рисованием: нарисовать линию, потом круг и т. д.
Но мы не можем предоставить доступ View к Relay’ям напрямую. Так как она может случайно положить значение в property или подписаться на Relay, который предназначен для получения команд от View. Поэтому требуется представить свойства в виде Observable, а слушатели событий от View как Consumer. Да, инкапсуляция потребует больше кода, но с другой стороны будет сразу понятно, где свойства, а где команды. Пример с прогрессом загрузки в 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. Когда он спит вообще?
pm.progressState.subscribe { progressBar.progress() } // привязываем состояние прогресса
downloadButton.clicks().subscribe { pm.downloadClicksConsumer } // прокидываем клики в PM
Если нет подходящего Observable, то можно вызывать consumer.accept()
– напрямую из слушателя виджета.
pm.downloadClicksConsumer.accept(Unit)
А теперь на практике
Теперь соберем все вышесказанное в кучу и разберем на примере. Проектирование PresentationModel можно разбить на следующие шаги:
- Определить, какие состояния будет хранить PresentationModel, которые потребуются для View: данные, состояние загрузки, ошибки, которые нужно отобразить и т. п.
- Определить, какие события могут происходить во View: клики на кнопки, заполнение полей ввода и т. д.
- При создании PresentationModel связать состояния, команды и модель в декларативном стиле, как это позволяет нам Rx.
- Привязать 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.
Далее спроектируем 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. Приведу слова из презентации Джейка Вортона, почему наши приложения должны быть реактивными:
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 постараемся рассказать о том, как начать использовать RxPM в реальном проекте и представим некоторое библиотечное решение, упрощающее его применение.
Автор: dmdev