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

Реализация мгновенного поиска в 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 (я часто ссылаюсь на этот сайт при построении пайплайнов).

Существуют также дополнительные библиотеки для Android и Kotlin. Но я расскажу об этом в другой раз.

Также читайте о работе с API: «Используем Retrofit 2 в Android-приложении»

Перевод статьи «Implementing Search-on-type in Android with RxJava»

ПОХОЖИЕ ПУБЛИКАЦИИ

763
22/11/2018

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

Добавить комментарий