Уже продолжительное время я размышляю над паттерном RxPM и даже успешно применяю его в «продакшне». Я планировал сначала выступить с этой темой на Mobius, но программный комитет отказал, поэтому публикую статью сейчас, чтобы поделиться с Android-сообществом своим видением нового паттерна.
Все знакомы с MVP и MVVM, но мало кто знает, что MVVM является логическим развитием паттерна Presentation Model. Ведь единственное отличие MVVM от PM – это автоматическое связывание данных (databinding).
В этой статье речь пойдет о паттерне Presentation Model с реактивной реализацией биндинга. Некоторые ошибочно называют его RxMVVM, но корректно будет называть его RxPM, потому что это модификация шаблона Presentation Model.
Этот паттерн удобно использовать в проектах с Rx, так как он позволяет сделать приложение по-настоящему реактивным. Кроме того, он не имеет многих проблем других паттернов. На диаграмме ниже представлены различные варианты и классификации шаблонов представления:
Прежде, чем перейти к описанию паттерна RxPM, давайте рассмотрим самые популярные из них — MVP (Passive View) и MVVM. Подробное описание всех паттернов и их различий вы можете прочитать в предыдущей статье.
Общую схему паттернов можно представить в виде диаграммы:
С первого взгляда может показаться, что принципиальной разницы между ними нет. Но это только на первый взгляд. Различия заключаются в обязанностях посредника и его способе связи со View. Модель же выглядит во всех паттернах одинаково. Ее проектирование – это сложная и обширная тема, не будем сейчас останавливаться на ней. Начнем с самого популярного паттерна – MVP в варианте Passive View. Рассмотрим его основные проблемы.
В классическом MVP ответственность за сохранение и восстановление состояния UI лежит на View. Presenter только отслеживает изменения в модели, обновляет View через интерфейс и, наоборот, принимает команды от View и изменяет Model.
Однако при реализации сложных интерфейсов, помимо состояния данных в модели, есть дополнительные состояния UI, которые никак не связаны с данными. Например, какой элемент списка выделен на экране или какими данными заполнены форма ввода, информация о ходе процесса загрузки или запросов в сеть. Восстановление и сохранение UI-состояния во View доставляет большие проблемы, так как View имеет обыкновение «умирать». А информацию о сетевых запросах View в принципе не способна сохранить. Пока View отсоединена от презентера, запрос, скорее всего, завершится с каким-нибудь результатом.
Поэтому работу восстановления состояния UI выносят в презентер. Для этого требуется хранить в презентере дополнительные данные и флаги о текущем состоянии и воспроизводить его при каждом присоединении View.
Вторая проблема вытекает из того же условия, что View может быть в любой момент отсоединена от презентера, например, при повороте экрана. Соответственно, ссылка на интерфейс View в презентере будет обнулена. Поэтому нужно всегда делать проверку на null
, когда требуется обновить View. Это довольно утомительно и захламляет код.
Третья проблема: необходимо довольно детально описывать интерфейс View, так как она должна быть как можно более «тупой». А презентеру приходится вызывать множество методов, чтобы привести View в нужное состояние. Это увеличивает количество кода.
Существует другой паттерн под названием Presentation Model, который описал Martin Fowler. Суть этого паттерна заключается в том, что вводится специальная модель, называемая «моделью представления», которая хранит состояние UI и содержит UI-логику. PresentationModel следует рассматривать как абстрактное представление, которое не зависит от какого-либо GUI-фреймворка. PresentationModel хранит состояние в виде свойств (property), которые затем считывает View и отображает на экране. Основная проблема паттерна – это синхронизация состояния PresentationModel и View. Вам придется об этом позаботиться самостоятельно, применив паттерн «Наблюдатель». Скорее всего, потребуется отслеживать изменения каждого свойства, чтобы не обновлять UI целиком. Получится довольно много скучного и повторяющегося кода.
Как вы могли заметить, 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 – это синхронизация состояния между 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.
Но мы не можем предоставить доступ 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 можно разбить на следующие шаги:
Возьмем для примера задачу поиска слов в тексте:
Алгоритм поиска скроем за фасадом интерактора:
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) }
Полный пример вы можете посмотреть на GitHub.
Мы познакомились c новым паттерном RxPM и разобрали минусы других шаблонов представления. Но я не хочу однозначно сказать, что MVP и MVVM хуже или лучше RxPM. Я также, как и многие люблю MVP за его простоту и прямолинейность. А MVVM хорош наличием автоматического датабиндинга, хотя код в верстке – это на любителя.
Но в современных приложениях с динамичным UI очень много событийного и асинхронного кода. Поэтому мой выбор склоняется в сторону реактивного подхода и RxPM. Приведу слова из презентации Джейка Вортона, почему наши приложения должны быть реактивными:
Unless you can model your entire system synchronously, a single asynchronously source breaks imperative programming.
Если вы не можете смоделировать всю систему синхронно, то даже один асинхронный источник ломает императивное программирование.
Плюсы:
Минусы:
Это, наверное, не полный список. Напишите в комментариях, какие вы видите плюсы и минусы, будет интересно узнать ваше мнение.
Итак, если вы чувствуете себя уверенно c Rx и хотите писать реактивные приложения, если вы устали от MVP и MVVM c databinding, то вам стоит попробовать RxPM. Ну а если вам и так комфортно, то не буду вас уговаривать ;)
Искушенный Android-разработчик, скорее всего, заметил, что я ничего не говорил о жизненном цикле и о сохранении PresentationModel во время поворота. Эта проблема не специфична для данного паттерна и заслуживает отдельного рассмотрения. В своей статье я хотел сосредоточиться на самой сути паттерна: его плюсах и минусах в сравнении с MVP и MVVM. Также не были затронуты такие важные темы, как двусторонний databinding, навигация между экранами в контексте RxPM и некоторые другие. В следующей статье мы c Jeevuz постараемся рассказать о том, как начать использовать RxPM в реальном проекте и представим некоторое библиотечное решение, упрощающее его применение.
К сожалению, не доступен сервер mySQL