Через полгода после выхода прошлой статьи о сравнении RxPM c другими презентационными паттернами мы с Jeevuz, наконец, готовы представить библиотеку RxPM — реактивную реализацию паттерна Presentation Model. Давайте сделаем небольшой обзор основных компонентов библиотеки и покажем, как их использовать.
Для начала посмотрим на общую схему:
- PresentationModel хранит состояние для View, реагирует на UI-события, изменяя модель и состояние View.
- View подписывается на изменения состояния и отправляет действия пользователя в PresentationModel.
- Model — это слой, за которым скрывается бизнес-логика, хранение и получение данных.
Перейдем к рассмотрению основных компонентов библиотеки.
State
Основная задача RxPM — описать все состояния в PresentationModel и предоставить возможность взаимодействовать с ними в реактивном стиле. Зачастую нам необходимо не только получать доступ к состоянию, но и реагировать на его изменения для синхронизации представления (View). Для этого в библиотеке есть класс State, который реализует реактивное свойство.
Реактивное свойство — это разновидность свойства, которое уведомляет о своих изменениях и предоставляет реактивные интерфейсы для взаимодействия с ним.
В статье про паттерн мы говорили что, нужно описать два свойства, чтобы скрыть от View доступ к изменению состояния:
private val inProgressRelay = BehaviorRelay.create()
val inProgressObservable = inProgressRelay.hide()
Это был один из раздражающих моментов в паттерне, поэтому мы решили обернуть BehaviorRelay
в State и предоставить observable
и consumer
для взаимодействия с ним. Теперь можем писать в одну строчку:
val inProgress = State<Boolean>(initialValue = false)
Во View подписываемся на изменения состояния:
pm.inProgress.observable.bindTo(progressBar.visibility())
bindTo
— расширение в библиотеке для привязки к реактивным свойствам
Изменить состояние можно через consumer, который доступен только внутри PresentationModel:
inProgress.consumer.accept(true)
Так же как и у обычного свойства мы можем взять текущее значение состояния:
inProgress.value
Преимущество реактивного свойства не только в том, что можно наблюдать за его изменением, но также связывать и компоновать с другими реактивными свойствами. Так мы получаем новое состояние, которое будет зависеть и реагировать на изменения других. Например, можно блокировать кнопку на время выполнения запроса в сеть:
val inProgress = State(initialValue = false)
val buttonEnabled = State(initialValue = true)
inProgress.observable
.map { progress -> !progress }
.subscribe(buttonEnabled.consumer)
.untilDestroy()
untilDestroy
— расширение в PresentationModel, которое добавляет Disposable
в CompositeDisposable
.
Ещё один пример — включать и отключать кнопку в зависимости от заполненности полей в форме:
// Реактивные свойства для получения событий от View:
val nameChanges = Action<String>()
val phoneChanges = Action<String>()
val buttonEnabled = State(initialValue = false)
Observable.combineLatest(nameChanges.observable,
phoneChanges.observable,
BiFunction { name: String, phone: String ->
name.isNotEmpty() && phone.isNotEmpty()
})
.subscribe(buttonEnabled.consumer)
.untilDestroy()
Таким образом, мы можем декларативно связывать одни реактивные свойства (состояния) и получать другие — зависимые. В этом и есть суть реактивного программирования.
Action
Аналогично State этот класс инкапсулирует доступ к PublishRelay
и предназначен для описания пользовательских действий, таких как нажатия на кнопки, переключения и т. п.
val buttonClicks = Action<Unit>()
buttonClicks.observable
.subscribe {
// handle click
}
.untilDestroy()
Логичным вопросом будет, а не легче описать метод в PresentationModel, зачем объявлять свойство и подписываться на него? В некоторых случаях это справедливо. Например, если действие очень простое, такое как открытие следующего экрана или прямой вызов модели. Однако если нужно по клику сделать запрос в сеть, и при этом фильтровать нажатия во время прогресса, то в этом случае взаимодействие через Action предпочтительнее. Основное преимущество Action — это то, что он не разрывает Rx-цепочку. Объясню на примере.
Вариант с методом:
private var requestDisposable: Disposable? = null
fun sendRequest() {
requestDisposable?.dispose()
requestDisposable = model.sendRequest().subscribe()
}
override fun onDestroy() {
super.onDestroy()
requestDisposable?.dispose()
}
Как видно из примера выше, необходимо на каждый запрос объявлять переменную Disposable
, чтобы при каждом новом клике завершать предыдущий запрос. А также не забыть отписаться в onDestroy
. Это следствие того, что каждый раз когда вызывается метод sendRequest
по клику на кнопку, создается новая Rx-цепочка.
Вариант с Action:
buttonClicks.observable
.switchMapSingle {
model.sendRequest()
}
.subscribe()
.untilDestroy()
Используя Action, нужно только один раз инициализировать Rx-цепочку и подписаться на нее. Кроме того, мы можем использовать многочисленные полезные Rx-операторы, такие как debounce
, filter
, map
и т. д.
Для примера рассмотрим задержку запроса при вводе строки для поиска:
val searchResult = State<List<Item>>()
val searchQuery = Action<String>()
searchQuery.observable
.debounce(100, TimeUnit.MILLISECONDS)
.switchMapSingle {
// send request
}
.subscribe(searchResult.consumer)
.untilDestroy()
А в сочетании с RxBinding связывать View и PresentationModel ещё удобнее:
button.clicks().bindTo(pm.buttonClicks.consumer)
Command
Ещё одна важная проблема — это отображение ошибок и диалогов, либо других команд. Они не являются состоянием, так как должны выполниться один раз. Например, чтобы показать диалог, нам State не подойдет, так как при каждой подписке к State будет получено последнее значение, соответственно, каждый раз будет показываться новый диалог. Для решения этой проблемы был создан класс Command, который реализует желаемое поведение, инкапсулируя PublishRelay
.
Но что же произойдет, если послать команду в тот момент, когда View ещё не привязана к PresentationModel? Мы потеряем эту команду. Чтобы не допустить этого, мы предусмотрели буфер, который накапливает команды, пока View отсутствует, и посылает их когда View привязывается. Когда View привязана к PresentationModel, то Command работает так же, как PublishRelay
.
По умолчанию буфер накапливает неограниченное количество команд, но можно задать конкретный размер буфера:
val errorMessage = Command<String>(bufferSize = 3)
Если нужно сохранять только последнюю команду:
val errorMessage = Command<String>(bufferSize = 1)
Если указать 0, то Command будет работать как PublishRelay
:
val errorMessage = Command<String>(bufferSize = 0)
Привязываемся во View:
errorMessage.observable().bindTo { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
Наиболее наглядно работу Command демонстрирует marble-диаграмма:
По умолчанию буфер включается при привязке View к PresentationModel. Но можно реализовать свой механизм, задав открывающий/закрывающий observable
.
Так, например, при работе с Google Maps, признаком готовности View является не только привязка к PresentationModel, но и готовность карты. В библиотеке уже есть готовая команда для работы с картой:
val moveToLocation = mapCommand<LatLng>()
PresentationModel
Мы описали основные примитивы RxPM: State, Action и Command, из которых строится PresentationModel. Теперь разберём базовый класс PresentationModel
. В нём ведётся вся основная работа с жизненным циклом. Всего у нас имеется 4 callback-а:
onCreate
— вызывается при первом создании, это хорошее место для инициализации Rx-цепочек и связывания состояний.onBind
— вызывается, когда View привязывается к PresentationModel.onUnbind
— вызывается, когда View отвязывается от PresentationModel.onDestroy
— PresentationModel завершает свою работу. Подходящее место для освобождения ресурсов.
Также можно отслеживать жизненный цикл через lifecycleObservable
.
Для удобной отписки есть расширения Disposable
, доступные в PresentationModel
:
protected fun Disposable.untilUnbind() {
compositeUnbind.add(this)
}
protected fun Disposable.untilDestroy() {
compositeDestroy.add(this)
}
На onBind
и onDestroy
очищаются compositeUnbind
и compositeDestroy
соответственно.
Давайте рассмотрим пример работы с PresentationModel
:
Необходимо по Pull To Refresh посылать запрос в сеть и обновлять данные на экране, во время запроса отображать прогресс, а в случае ошибки показывать пользователю диалог с сообщением.
Для начала нужно определить, какие состояния и команды нужны для View и какие пользовательские события мы можем получать от View:
class DataPresentationModel(
private val dataModel: DataModel
) : PresentationModel() {
val data = State<List<Item>>(emptyList())
val inProgress = State(false)
val errorMessage = Command<String>()
val refreshAction = Action<Unit>()
// ...
}
Теперь нужно связать свойства и модель в методе onCreate
:
class DataPresentationModel(
private val dataModel: DataModel
) : PresentationModel() {
// ...
override fun onCreate() {
super.onCreate()
refreshAction.observable
// расширение в библиотеке
.skipWhileInProgress(inProgress.observable)
.flatMapSingle {
dataModel.loadData()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
// расширение в библиотеке
.bindProgress(inProgress.consumer)
.doOnError {
errorMessage.consumer.accept("Loading data error")
}
}
.retry()
.subscribe(data.consumer)
.untilDestroy()
// обновляем данные при входе на экран
refreshAction.consumer.accept(Unit)
}
}
Обратите внимание на операторretry
, он тут необходим, так как при получении ошибки, цепочка завершит свою работу и экшены больше не будут обрабатываться. Операторretry
переподпишет цепочку в случае ошибки. Но будьте осторожны и не используйте его, если начинаете цепочку от State.
PmView
Когда PresentationModel спроектирована, то остается только привязать её ко View.
В библиотеке уже реализованы базовые классы для реализации PmView: PmSupportActivity
, PmSupportFragment
и PmController
(для пользователей фреймворка Conductor). Каждый из них реализует интерфейс AndroidPmView
и прокидывает нужные callback-и в соответствующий делегат, который управляет жизненным циклом PresentationModel и обеспечивает корректное сохранение её во время поворота экрана.
Наследуемся от PmSupportFragment
и реализуем всего два обязательных метода:
providePresentationModel
— вызывается при создании PresentationModel.onBindPresentationModel
— в этом методе нужно привязаться к свойствам PresentationModel (используйте RxBinding и расширениеbindTo
).
class DataFragment : PmSupportFragment<DataPresentationModel>() {
override fun providePresentationModel() = DataPresentationModel(DataModel())
override fun onBindPresentationModel(pm: DataPresentationModel) {
pm.inProgress.observable.bindTo(swipeRefreshLayout.refreshing())
pm.data.observable.bindTo {
// adapter.setItems(it)
}
pm.errorMessage.observable.bindTo {
// show alert dialog
}
swipeRefreshLayout.refreshes().bindTo(pm.refreshAction.consumer)
}
}
bindTo
— это удобное расширение в AndroidPmView
. Используя его, вам не нужно беспокоится об отписке от свойств из PresentationModel и переключении на главный поток.
Для работы с Google Maps в библиотеке есть дополнительные базовые классы: MapPmSupportActivity
, MapPmSupportFragment
и MapPmController
. В них добавляется отдельный метод для привязки GoogleMap
:
fun onBindMapPresentationModel(pm: PM, googleMap: GoogleMap)
В этом методе мы можем отображать пины на карте, передвигать и анимировать местоположение и т. п.
Two-way Data Binding
Пока мы рассматривали только одностороннее изменение State, когда PresentationModel изменяет состояние, а View подписывается на него. Но достаточно часто возникает потребность изменять состояние с двух сторон. Классический пример — это поле ввода: его значение может изменить как пользователь так и PresentationModel, инициализируя начальным значением или форматируя ввод. Такая связка носит название двустороннего датабиндинга. Покажем на схеме, как она реализуется в RxPM:
Пользователь вводит текст ➔ срабатывает слушатель ➔ изменение передается в Action ➔ PresentationModel фильтрует и форматирует текст и подставляет его в State ➔ измененное состояние получает View ➔ текст подставляется в поле ввода ➔ срабатывает слушатель ➔ круг замыкается и система впадает в бесконечный цикл.
Мы написали класс InputControl
, который реализует эту двустороннюю связку для полей ввода и решает проблему зацикливания.
Объявляем в PresentationModel:
val name = inputControl()
Привязываемся во View через привычный bindTo
pm.name bindTo editText
Также можно задать форматтер:
val name = inputControl(
formatter = {
it.take(50).capitalize().replace("[^a-zA-Z- ]".toRegex(), "")
}
)
Аналогичную проблему зацикливания и двустороннего связывания для CheckBox
решает CheckControl
.
RxPM
Мы рассмотрели основные классы и особенности библиотеки. Вот далеко не полный список фич, которые есть в RxPM:
- Базовая реализация
PresentationModel
. - Сохранение
PresentationModel
во время поворота экрана. - Обработка жизненного цикла, подписка и отписка.
- Базовые классы для реализации
PmView
, в том числе и для Conductor. State
,Action
,Command
.InputControl
,CheckContol
,ClickControl
.- Связывание свойств через
bindTo
и другие полезные расширения. - Базовые классы для работы с Google Maps.
Библиотека написана на Kotlin и использует RxJava2.
RxPM используется уже в нескольких приложениях в продакшне и показала стабильность в своей работе. Но мы продолжаем над ней работать, есть много идей для дальнейшего развития и улучшения. Недавно вышла версия 1.1 с очень полезной фичей для навигации, но об этом мы поговорим в следующей статье.
Только одной статьи будет недостаточно, чтобы понять возможности RxPM. Поэтому пробуйте, смотрите исходники и примеры, задавайте свои вопросы. Мы будем рады обратной связи.
RxPM: https://github.com/dmdevgo/RxPM
Sample: https://github.com/dmdevgo/RxPM/tree/develop/sample
Чат в телеграм: https://t.me/Rx_PM
P. S.
24 ноября (в эту пятницу) я выступлю с мини-докладом про RxPM на Droidcon Moscow 2017. Приходите — пообщаемся.