Реализация мгновенного поиска в Android с помощью RxJava


Я работаю над новым приложением, которое, как это обычно и происходит, связывается с бэкенд-сервисом для получения данных через API. В этом примере я буду разрабатывать функцию поиска, одной из особенностей которого будет мгновенный поиск прямо во время ввода текста.


Мгновенный поиск


Ничего сложного, подумаете вы. Нужно просто разместить компонент поиска на странице (скорее всего, в тулбаре), подключить обработчик событий onTextChange и выполнить поиск. Итак, вот что я сделал:


override fun onCreateOptionsMenu(menu: Menu?): Boolean {
    menuInflater.inflate(R.menu.menu_main, menu)
    val searchView = menu?.findItem(R.id.action_search)?.actionView as SearchView

    // Set up the query listener that executes the search
    searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
        override fun onQueryTextSubmit(query: String?): Boolean {
            Log.d(TAG, "onQueryTextSubmit: $query")
            return false
        }

        override fun onQueryTextChange(newText: String?): Boolean {
            Log.d(TAG, "onQueryTextChange: $newText")
            return false
        }
    })

    return super.onCreateOptionsMenu(menu)
}

Но вот в чём проблема. Поскольку мне необходимо реализовать поиск прямо во время ввода, то всякий раз, когда срабатывает обработчик события onQueryTextChange(), я обращаюсь к API для получения первого набора результатов. Логи выглядят следующим образом:


D/MainActivity: onQueryTextChange: T
D/MainActivity: onQueryTextChange: TE
D/MainActivity: onQueryTextChange: TES
D/MainActivity: onQueryTextChange: TEST
D/MainActivity: onQueryTextSubmit: TEST

Несмотря на то, что я просто печатаю свой запрос, происходит пять вызовов API, каждый из которых выполняет поиск. Например, в облаке вам необходимо платить за каждое обращение к API. Таким образом, когда я ввожу свой запрос, мне нужна небольшая задержка перед его отправлением, чтобы в итоге состоялось только одно обращение к API.


Теперь, предположим, я хочу найти что-нибудь ещё. Я удаляю TEST и ввожу другие символы:


D/MainActivity: onQueryTextChange: TES
D/MainActivity: onQueryTextChange: TE
D/MainActivity: onQueryTextChange: T
D/MainActivity: onQueryTextChange: 
D/MainActivity: onQueryTextChange: S
D/MainActivity: onQueryTextChange: SO
D/MainActivity: onQueryTextChange: SOM
D/MainActivity: onQueryTextChange: SOME
D/MainActivity: onQueryTextChange: SOMET
D/MainActivity: onQueryTextChange: SOMETH
D/MainActivity: onQueryTextChange: SOMETHI
D/MainActivity: onQueryTextChange: SOMETHIN
D/MainActivity: onQueryTextChange: SOMETHING
D/MainActivity: onQueryTextChange: SOMETHING 
D/MainActivity: onQueryTextChange: SOMETHING E
D/MainActivity: onQueryTextChange: SOMETHING EL
D/MainActivity: onQueryTextChange: SOMETHING ELS
D/MainActivity: onQueryTextChange: SOMETHING ELSE
D/MainActivity: onQueryTextChange: SOMETHING ELSE
D/MainActivity: onQueryTextSubmit: SOMETHING ELSE

Происходит 20 вызовов API! Небольшая задержка сократит количество этих вызовов. Я также хочу избавиться от дубликатов, чтобы обрезанный текст не приводил к повторным запросам. Ещё я, вероятно, захочу отфильтровать некоторые элементы. Например, нужна ли возможность поиска без введённых символов или поиска по одному символу?


Реактивное программирование


Есть несколько вариантов дальнейших действий, но прямо сейчас я хочу сосредоточиться на технике, которая широко известна как реактивное программирование и библиотека RxJava. Когда я впервые столкнулся с реактивным программированием, то увидел следующее описание:


ReactiveX — это API, который работает с асинхронными структурами и манипулирует потоками данных или событиями, используя сочетания паттернов Observer и Iterator, а также особенности функционального программирования.

Это определение не полностью объясняет сущность и сильные стороны ReactiveX. А если и объясняет, то только тем, кто уже знаком с принципами работы этого фреймворка. Я также видел такие диаграммы:


Диаграмма оператора задержки


Диаграмма объясняет роль оператора, но не позволяет полностью разобраться в сути. Итак, давайте посмотрим, смогу ли я понятнее объяснить данную диаграмму на простом примере.


Давайте сначала подготовим наш проект. Вам понадобится новая библиотека в файле build.gradle вашего приложения:


implementation "io.reactivex.rxjava2:rxjava:2.1.14"

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


Теперь давайте рассмотрим новое решение. Используя старый метод, я обращался к API при вводе каждого нового символа. C помощью нового способа я собираюсь создать Observable:


override fun onCreateOptionsMenu(menu: Menu?): Boolean {
    menuInflater.inflate(R.menu.menu_main, menu)
    val searchView = menu?.findItem(R.id.action_search)?.actionView as SearchView

    // Set up the query listener that executes the search
    Observable.create(ObservableOnSubscribe<String> { subscriber ->
        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
            override fun onQueryTextChange(newText: String?): Boolean {
                subscriber.onNext(newText!!)
                return false
            }
            override fun onQueryTextSubmit(query: String?): Boolean {
                subscriber.onNext(query!!)
                return false
            }
        })
    })
    .subscribe { text ->
        Log.d(TAG, "subscriber: $text")
    }

    return super.onCreateOptionsMenu(menu)
}

Этот код выполняет абсолютно то же самое, что и старый код. Логи выглядят следующим образом:


D/MainActivity: subscriber: T
D/MainActivity: subscriber: TE
D/MainActivity: subscriber: TES
D/MainActivity: subscriber: TEST
D/MainActivity: subscriber: TEST

Тем не менее, ключевое отличие использования нового приёма заключается в наличии реактивного потока — Observable. Обработчик текста (или обработчик запроса в данном случае) отправляет элементы в поток, используя метод onNext(). А у Observable есть подписчики, которые и обрабатывают эти элементы.


Мы можем создать цепочку методов перед подпиской, чтобы уменьшить список строк для обработки. Начнём с того, что отправленный текст всегда будет находиться в нижнем регистре и в нём не будет пробелов в начале и конце строки:


Observable.create(ObservableOnSubscribe<String> { ... })
.map { text -> text.toLowerCase().trim() }
.subscribe { text -> Log.d(TAG, "subscriber: $text" }

Я сократил методы, чтобы показать наиболее значимую часть. Теперь те же логи выглядит следующим образом:


D/MainActivity: subscriber: t
D/MainActivity: subscriber: te
D/MainActivity: subscriber: tes
D/MainActivity: subscriber: test
D/MainActivity: subscriber: test

Теперь давайте применим задержку на 250мс, ожидая большего количества контента:


Observable.create(ObservableOnSubscribe<String> { ... })
.map { text -> text.toLowerCase().trim() }
.debounce(250, TimeUnit.MILLISECONDS)
.subscribe { text -> Log.d(TAG, "subscriber: $text" }

И, наконец, удалим дубликаты потока, чтобы только первый уникальный запрос был обработан. Последующие идентичные запросы будут игнорироваться:


Observable.create(ObservableOnSubscribe<String> { ... })
.map { text -> text.toLowerCase().trim() }
.debounce(100, TimeUnit.MILLISECONDS)
.distinct()
.subscribe { text -> Log.d(TAG, "subscriber: $text" }

Прим. перев. В данном случае разумнее использовать оператор distinctUntilChanged(), потому что иначе в случае повторного поиска по какой-либо строке запрос просто проигнорируется. А при реализации такого поиска разумно обращать внимание только на последний успешный запрос и игнорировать новый в случае его идентичности с предыдущим.

В конце отфильтруем пустые запросы:


Observable.create(ObservableOnSubscribe<String> { ... })
.map { text -> text.toLowerCase().trim() }
.debounce(100, TimeUnit.MILLISECONDS)
.distinct()
.filter { text -> text.isNotBlank() }
.subscribe { text -> Log.d(TAG, "subscriber: $text" }

На этом этапе вы заметите, что выводится только одно (или, может быть, два) сообщение в логах, что говорит о меньшем количестве вызовов API. При этом приложение будет продолжать адекватно работать. Более того, случаи, когда вы вводите что-либо, но затем удаляете и вводите снова, также приведут к меньшему количеству обращений к API.


Существует ещё много различных операторов, которые вы можете добавить в этот пайплайн в зависимости от ваших целей. Я считаю, что они весьма полезны для работы с полями ввода, которые взаимодействуют с API. Полный код выглядит следующим образом:


// Set up the query listener that executes the search
Observable.create(ObservableOnSubscribe<String> { subscriber ->
    searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
        override fun onQueryTextChange(newText: String?): Boolean {
            subscriber.onNext(newText!!)
            return false
        }

        override fun onQueryTextSubmit(query: String?): Boolean {
            subscriber.onNext(query!!)
            return false
        }
    })
})
.map { text -> text.toLowerCase().trim() }
.debounce(250, TimeUnit.MILLISECONDS)
.distinct()
.filter { text -> text.isNotBlank() }
.subscribe { text ->
    Log.d(TAG, "subscriber: $text")
}

Теперь я могу заменить сообщение логов обращением ко ViewModel, чтобы инициировать вызов API. Однако это уже тема для другой статьи.


Заключение


С помощью этой простой техники обёртывания текстовых элементов в Observable и использования RxJava вы можете сократить количество вызовов API, которые необходимы для выполнения операций с сервером, а также улучшить отзывчивость вашего приложения. В этой статье мы затронули лишь малую часть целого мира RxJava, поэтому я оставляю вам ссылки для дополнительного чтения на эту тему:


  • Grokking RxJava от Дэна Лью (это сайт, который помог меня двигаться в правильном направлении).
  • Сайт ReactiveX (я часто ссылаюсь на этот сайт при построении пайплайнов).

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


  1. time2rfc
    29.11.2018 16:26

    Спасибо за статью, давно не видел примеров на RxJava, интересно было узнать есть ли сильные различия на андроиде и десктопе.
    Подскажите, человеку который который никогда не писал для андроидов, а callback hell в примерах кода — это особенность мобильной разработки?


  1. BigDflz
    29.11.2018 17:05

    Devcolibri а теперь попробуй набрать что-нибудь такое из середины текста и не сначала слова. Поиск по одному слову — это ерунда и даже если по нескольким словам подряд — это трата времени


  1. ConstOrVar
    29.11.2018 17:08

    Спасибо за статью. На мой взгляд, один из важных момент при работе с Rx — помнить про подписки и не забывать отписываться от них. В приведенных примерах, как-то про это забыли. У Вас в данном случае создается бесконечный Observable, подписка на который нигде не сохраняется, но от которого нужно отписаться в onDestroy(). Хотелось бы, чтобы пользователи, которые будут читать статью, видели «картину в целом» и следовали хорошим практикам.


  1. yno7
    30.11.2018 13:59

    Devcolibri думаю в данном примере правильнее будет использовать заместо оператора distinct() более простую версию distinctUntilChanged(). В случае distinct() мы не сможем два раза искать по одной и той же строке, что как по мне вообще не логично для данного примера. Т.е сначала мы ввели имя Петя и получили какой-то результат, потом ввели имя Даша и снова получили результат, b вдруг мы решили снова искать по имени Петя, но результат уже не получили.


    1. Devcolibri Автор
      30.11.2018 15:22

      Да, верно подмечено. Спасибо за замечание, добавил примечание переводчика в статью.


  1. garastard
    30.11.2018 13:59

    Devcolibri не надо так пользоваться kotlin:

    val searchView = menu?.findItem(R.id.action_search)?.actionView as SearchView
               // Set up the query listener that executes the search
        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {


    Плохо когда nullable принуждают к notNullable, а потом без проверки вешают на него листенер.
    Это то же самое, что и!!! — это грех за такое Бреслав накажет.
    Свалитесь вы как-нить с NPE(и черт с вами). Не думаю, что стоит обучать «ТАКОМУ» подходу…

    А теперь короночка, Devcolibri, которая ставит под вопрос функциональность вашего поиска — обратите внимание на оператор ".distinct()".
    Приведу в пример такой кейс: держите вы в голове поисковой запрос «Null safety in Kotlin». Вводите его… постепенно… с новыми символами вам предлагаются варианты. Это хорошо… набрав данный запрос до конца и увидев соответствующие подсказки от поиска. А потом у ужасом осознаете, что вы не знаете, что такое «null safety» в принципе! и решаете почитать про это… вы начинаете стирать ваш поисковой запрос… и… и… и .distinct() ломает вашу жизнь.