Через полгода после выхода прошлой статьи о сравнении 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 databinding


Пока мы рассматривали только одностороннее изменение 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. Приходите — пообщаемся.

Комментарии (3)


  1. xGromMx
    22.11.2017 16:31

    Чем картинки сделаны? я про марблы


    1. dmdev Автор
      22.11.2017 17:32

      все картинки сделаны в Sketch


  1. andrikeev
    23.11.2017 14:00

    Знакомый подход :)
    www.youtube.com/watch?v=0IKHxjkgop4

    Спасибо, очень интересно!