В этой заметке я покажу несколько идей, как можно использовать RxJava в программировании под Android, и зачем нужна библиотека RxBinding.

Этот рассказ, как и весь цикл моих заметок про Rx, — это исследование и попытка ответить на вопрос, в чём прелесть Rx и зачем оно нам нужно в Android? Лучший способ ответить на этот вопрос — попробовать.

Изменение (Апрель 2019)

Много воды утекло с момента написания первой статьи.

  1. В разработке под Android стал использоваться Kotlin, что дало нам более чистый и читаемый код. В частности, Extensions дали возможность расширять существующие классы, не используя наследование или еще более громоздких паттерны.
  2. Google, в свою очередь, выпустил библиотеку на основе этого механизма Android KTX, которая позволяет избежать нагромождений анонимных объектов и лиснеров в коде.
  3. JetBrains создал gradle plugin Kotlin Android extensions, который предоставляет более удобный и надежный способ избавиться от множества findViewById() в коде (мне он нравится больше, чем ButterKnife и DataBinding).

В свете новых идей статья скорее приобрела актуальность, чем потеряла, но примеры пришлось переписать с использованием современных инструментов.

Задача

Итак. Учебная задача — написать форму для авторизации или регистрации. Мини-ТЗ:

  1. Интерфейс состоит из трёх элементов: EditText для ввода e-mail, EditText для ввода пароля, Button для выполнения необходимого действия после заполнения формы.
  2. Если поле ввода содержит непригодный текст, то он выделяется красным. Если хотя бы одно из полей содержит непригодный текст, то кнопка должна быть неактивна.
  3. Пригодность определим так: e-mail должен проверяться с помощью регулярного выражения, пароль должен быть длиннее 4 символов.

С пользовательским интерфейсом все понятно, нужно реализовать работу.

<EditText android:id="@+id/email" .... />
<EditText android:id="@+id/password" .... />
<Button android:id="@+id/button" .... />

Подписка на события

Идея первая: можно вместо традиционного использования лиснеров все события пользовательского интерфейса превратить в поток событий RxJava и предоставить для дальнейшего исследования как Observable. Любой Observable можно далее дополнить цепочкой модификаций, которая превратит “сырые” события в нужные нам данные.

По ТЗ пароль должен быть более 4 символов. Проверим это:

val isPasswordValid = 
    password // <-- это непосредственно элемент нашего UI, EditText
        .textChanges() // <-- Observable, который будет передавать текст после каждого изменения
        .map { text -> (text.length > 4) }
        .distinctUntilChanged()

Переменная password содержит ссылку на EditText интерфейса. Но если вы внимательно посмотрите на полный текст примера, то ни определения этой переменной, ни findViewById() вы не найдете. Это все скрыто “под капотом” Kotlin Android extensions. Единственный след этого в коде - импорт автогенеренного класса:

import kotlinx.android.synthetic.main.activity_main.*

Метод textChanges() на самом деле не метод, а extension, предоставленный библиотекой RxBinding. Какого-то полного списка доступных расширений этой библиотеки в документации нет. Но она достаточно хорошо продумана, и если ее подключить, то autocompletion в Android Studio будет предлагать варианты естественно и уместно. Это не мешает залезть в исходники и обнаружить, что “под капотом” обычные аккуратно написанные лиснеры, без какой-либо черной магии.

Полученным Observable можно воспользоваться (причем, неоднократно). Например, для отображения валидности введенных данных:

isPasswordValid
    .map { b -> if (b) Color.BLACK else Color.RED }
    .subscribe { c -> password.setTextColor(c) }

Аналолгично мы обойдемся с e-mail:

val isEmailValid = email.textChanges()
    .map { t -> EMAIL_ADDRESS.matcher(t).matches() }
    .distinctUntilChanged()
            
isEmailValid
    .map { b -> if (b) Color.BLACK else Color.RED }
    .subscribe { c -> email.setTextColor(c) }            

EMAIL_ADDRESS - регулярное выражение для проверки валидности e-mail, входит в SDK.

Дальше на основнии проверки полей нужно принять решение, доступна или недоступна регистрация.

val isSignUpPossible = Observables
        .combineLatest(isEmailValid, isPasswordValid) { e, p -> e && p }
        .distinctUntilChanged()

Обратите внимание, что используется класс Observables из библиотеки RxKotlin. Проблема в том, что компилятор Kotlin не всегда может самостоятельно справиться с типизацией. Чтобы не возвращаться обратно к явному указанию типов, в библиотеку были добавлены разные расширения и классы для решения тех же задач, но более понятным компилятору способом.

Также обратите внимание, что источники событий isEmailValid и isPasswordValid используются несколько раз. Это очень удобно, потому что теперь не нужно всю обработку валить в один лиснер, а можно вместо этого организовывать код по смыслу.

Наконец, кнопка:

isSignUpPossible
    .subscribe { b ->
        button.isEnabled = b
        button.isClickable = b
    }

button.clicks()
    .subscribe { signUpButtonAction() }  

Весь код в этом случае выглядит вот так. Когда я первый раз написал этот пример, краткость и читаемость меня восхитили.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val isEmailValid = email.textChanges()
            .map { t -> EMAIL_ADDRESS.matcher(t).matches() }
            .distinctUntilChanged()

        val isPasswordValid = password.textChanges()
            .map { t -> (t.length > 4) }
            .distinctUntilChanged()

        isPasswordValid
            .map { b -> if (b) Color.BLACK else Color.RED }
            .subscribe { c -> password.setTextColor(c) }

        isEmailValid
            .map { b -> if (b) Color.BLACK else Color.RED }
            .subscribe { c -> email.setTextColor(c) }

        val isSignUpPossible = Observables
                .combineLatest(isEmailValid, isPasswordValid) { e, p -> e && p }
                .distinctUntilChanged()

        isSignUpPossible
            .subscribe { b ->
                button.isEnabled = b
                button.isClickable = b
            }

        button.clicks()
            .subscribe { signUpButtonAction() }
    }

    private fun signUpButtonAction() =
        Toast.makeText(this@MainActivity, "Button clicked", Toast.LENGTH_LONG).show()
}

Но, к сожалению, это еще не все

Отписка от событий

Гигиена андроид-разработчика в общем случае предписывает: если на что-то подписался в onCreate — сразу отпишись в onDestroy, иначе тебяпо ночам будут преследовать баги и memory leak.

Для экспериментов сделаем независимый от UI источник событий и подпишемся на него:

        val ticker = Observable
            .interval(1, TimeUnit.SECONDS, Schedulers.io())
            .share()

        ticker
            .subscribe { o -> Log.d("happy", "tick " + o) }

Если вы покрутите телефон, то увидите, что при пересоздании активити старая пописка никуда не девается, а создается еще одна. Мы получили классический memory leak, который начинающие разработчики получали раньше с помощью неумелого использования AsyncTask. Те же грабли, вид сбоку.

Очевидно, нужно при разрушении Activity удалять все подписки. Самым распространенным путем является сохранить все Disposable, которые возвращает метод subscribe(), и расправиться с ними в onDestroy(). CompositeDisposable удобен для этого.

lateinit var compositeDisposable: CompositeDisposable

fun Disposable.disposeAtTheEnd() {
    compositeDisposable.add(this)
}

override fun onCreate(savedInstanceState: Bundle?) {
    ....
    compositeDisposable = CompositeDisposable()

    ....
    isPasswordValid
        .subscribe { .... }
        .disposeAtTheEnd()

    isEmailValid
        .subscribe { .... }
        .disposeAtTheEnd()

    isSignUpPossible
        .subscribe { .... }
        .disposeAtTheEnd()

    button.clicks()
        .subscribe { .... }
        .disposeAtTheEnd()
        
    ticker
        .subscribe { .... }
        .disposeAtTheEnd()
}

override fun onDestroy() {
    super.onDestroy()
    compositeDisposable.dispose()
}

Но если нужно что-то сложнее, то размер бойлерплейта опять начинает расти. Есть другой способ.

Во-первых, Google в рамках Android Jetpack сделал библиотеку для получения текущего этапа жизнинного цикла разных объектов. Читать надо про Lifecycle и LifecycleOwner. Многие компоненты, включая Activity, уже реализуют интерфейс LifecycleOwner.

Во-вторых, библиотека RxLifecycle предоставляет инструменты, с помощью которых можно операторами RxJava следить за жизненным циклом, а также прерывать подписку при достижении определенных событий.

В простейшем случае это может быть сделано так:

val lifecycleProvider = AndroidLifecycle.createLifecycleProvider(this)

ticker
    .bindToLifecycle(lifecycleProvider)
    .subscribe { o -> Log.d("happy", "tick " + o) }

Если же нужно сохранять подписку только на время состояния RESUME, то поможет источник событий lifecycleProvider.lifecycle() и средство отписки .bindUntilEvent(lifecycleProvider, ON_PAUSE). Собрать этот конструктор можно так:

lifecycleProvider.lifecycle()
    .bindToLifecycle(lifecycleProvider)
    .filter { it == Lifecycle.Event.ON_RESUME }
    .subscribe {
        ticker
            .bindUntilEvent(lifecycleProvider, Lifecycle.Event.ON_PAUSE)
            .subscribe { o -> Log.d("happy", "tack " + o) }
    }

Обратите внимание, что в этом фрагменте есть две независимые подписки на события, и два условия прерывания этих подписок.

Ссылки