- PVSM.RU - https://www.pvsm.ru -
Уже продолжительное время я размышляю над паттерном RxPM и даже успешно применяю его в «продакшне». Я планировал сначала выступить с этой темой на Mobius [1], но программный комитет отказал, поэтому публикую статью сейчас, чтобы поделиться с Android-сообществом своим видением нового паттерна.
Все знакомы с MVP и MVVM, но мало кто знает, что MVVM является логическим развитием паттерна Presentation Model [2]. Ведь единственное отличие MVVM от PM – это автоматическое связывание данных (databinding).
В этой статье речь пойдет о паттерне Presentation Model с реактивной реализацией биндинга. Некоторые ошибочно называют его RxMVVM, но корректно будет называть его RxPM, потому что это модификация шаблона Presentation Model.
Этот паттерн удобно использовать в проектах с Rx [3], так как он позволяет сделать приложение по-настоящему реактивным. Кроме того, он не имеет многих проблем других паттернов. На диаграмме ниже представлены различные варианты и классификации шаблонов представления:
Прежде, чем перейти к описанию паттерна RxPM, давайте рассмотрим самые популярные из них — MVP (Passive View) и MVVM. Подробное описание всех паттернов и их различий вы можете прочитать в предыдущей статье [4].
Общую схему паттернов можно представить в виде диаграммы:
С первого взгляда может показаться, что принципиальной разницы между ними нет. Но это только на первый взгляд. Различия заключаются в обязанностях посредника и его способе связи со 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, который описал [2] Martin Fowler. Суть этого паттерна заключается в том, что вводится специальная модель, называемая «моделью представления», которая хранит состояние UI и содержит UI-логику. PresentationModel следует рассматривать как абстрактное представление, которое не зависит от какого-либо GUI-фреймворка. PresentationModel хранит состояние в виде свойств (property), которые затем считывает View и отображает на экране. Основная проблема паттерна – это синхронизация состояния PresentationModel и View. Вам придется об этом позаботиться самостоятельно, применив паттерн «Наблюдатель» [5]. Скорее всего, потребуется отслеживать изменения каждого свойства, чтобы не обновлять UI целиком. Получится довольно много скучного и повторяющегося кода.
Как вы могли заметить, MVVM очень похож на Presentation Model. Не удивительно, ведь он является его развитием. Только PresentationModel называется ViewModel, а синхронизация состояния ViewModel и View осуществляется с помощью автоматического связывания данных, т. е. датабиндинга. Но и этот паттерн не лишен недостатков. Например, в нем проблематично «чисто» реализовать какие-нибудь анимации или что-либо сделать со View из кода. Об этом подробнее можно почитать в статье [6] моего коллеги Jeevuz [7].
Начав обсуждать и обдумывать 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 [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.
Но мы не можем предоставить доступ 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 можно разбить на следующие шаги:
Возьмем для примера задачу поиска слов в тексте:
Алгоритм поиска скроем за фасадом интерактора:
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.
Если вы не можете смоделировать всю систему синхронно, то даже один асинхронный источник ломает императивное программирование.
Плюсы:
Минусы:
Это, наверное, не полный список. Напишите в комментариях, какие вы видите плюсы и минусы, будет интересно узнать ваше мнение.
Итак, если вы чувствуете себя уверенно c Rx и хотите писать реактивные приложения, если вы устали от MVP и MVVM c databinding, то вам стоит попробовать RxPM. Ну а если вам и так комфортно, то не буду вас уговаривать ;)
Искушенный 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/
Нажмите здесь для печати.