はじめに
現在、GoogleやTwitterなど、多くのWebサービスやスマートフォンアプリでリアルタイム検索がサポートされています。この記事では、Androidアプリでリアルタイム検索を実装するときに問題となることをRxJavaを利用することでスマートに解決することを目指します。
リアルタイム検索実装時の問題点
素朴に考えると、TextWatcher#onTextChanged
でユーザーの入力文字列を検索処理に渡して結果を表示するという方法でリアルタイム検索を実装できます。しかし、その方法には以下のような問題があります。
onTextChangedが同じ文字列で2回呼ばれる問題
// onCreateの中です。
editText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
Log.d(TAG, "query: ${s}")
// ここで検索処理を実行
}
})
EditText#addTextChangedListener
に渡すTextWatcher
のonTextChanged
メソッドでユーザーの入力文字列を取得することができます。実際にEditTextに「a」と打ってみると
query: a
というログが出ます。しかし、1文字だと問題なかったのですが、端末(Nexus 5X Android7.1.1 のエミュレータで確認)によっては「abcd」と打つと
query: a
query: ab
query: ab
query: abc
query: abcd
query: abcd
というログが出ました。つまり、「ab」と「adcd」については文字列の変化がないはずなのに2回呼ばれてしまい、余計な検索処理が発生してしまいます。調べてみたところ、おそらくキーボードの予測変換が関係しているのではないかと思われます。
https://stackoverflow.com/a/19298614
ユーザーの入力速度>検索速度の問題
入力文字列の変化があるたびに毎回検索処理を走らせればリアルタイム検索となります。しかし、ユーザーの入力速度が検索処理速度を上回っている場合は、無駄な検索処理が多くなります。たとえば、「aaaa」とユーザーが素早く入力した場合は、「a」「aa」「aaa」「aaaa」の全てで検索をするのではなく、最後の「aaaa」だけで検索を実行すれば十分です。
検索時間にばらつきがある問題
APIを利用してネットワーク通信で検索処理を実行する場合、検索文字列やネットワーク状況によって結果が返ってくるまでの時間がばらつく可能性があります。結果が返ってくる時間にばらつきがあると、結果が返ってくる順番が入れ替わってしまう可能性があります。たとえば、「a」「ab」の順で検索処理を実行したときに、「a」の検索処理に時間がかかり、「ab」「a」の順で検索結果が返ってきてしまうと、ユーザーに表示される検索結果が「a」のものになってしまいます。
RxJavaを入れて制御する
onTextChangedが同じ文字列で2回呼ばれる問題:distinctUntilChangedで解決
val queryPublisher = PublishSubject.create<String>()
editText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
queryPublisher.onNext(s.toString())
}
})
queryPublisher.distinctUntilChanged().subscribe({
Log.d(TAG, "query: ${it}")
// ここで検索処理を実行
})
まず、検索文字列を流すPublishSubject<String>
を定義します。そして、onTextChanged
メソッド内で文字列を発行します。実際に検索文字列を受けるのは最後のsubscribe
メソッドに渡すConsumer#accept
(ラムダ式で省略されています)です。
ここでポイントとなるのが、subscribe
の前にあるdistinctUntilChanged
メソッドです。このメソッドを入れることで、同じ文字列が連続で来た場合は値を流さないようにできます。
参考:http://reactivex.io/documentation/operators/distinct.html
実際に「abcd」と入力した時のログは以下のようになります。
query: a
query: ab
query: abc
query: abcd
ユーザーの入力速度>検索速度の問題:debounceで解決
queryPublisher.distinctUntilChanged()
.debounce(500, TimeUnit.MILLISECONDS)
.subscribe({
Log.d(TAG, "query: ${it}")
})
ユーザーが素早く入力した時の最後の値だけ流すにはObservable#debounce
を使います。
参考:http://reactivex.io/documentation/operators/debounce.html
上記の書き方だと、ユーザーが文字を入力してから500ms経過したら値が流れるようになっています。実際にアプリで「a」を連打で入力して「aaaa」となった時点で手を止めると以下のようなログになります。
query: aaaa
また、Observable#throttleLast
でも似たようなことが実現できます。この場合は、ユーザーが続けている途中でも一定間隔で値を流すようになります。
参考:http://reactivex.io/documentation/operators/sample.html
検索時間にばらつきがある問題:Disposableで解決
次節のswitchMap
で解決する方法がよりスマートなので、そちらをおすすめします。
最後に検索した結果だけを受け取れるようにすることで、問題の解決を目指します。
private var searchDisposable: Disposable? = null
override fun onCreate(savedInstanceState: Bundle?) {
// 〜省略〜
queryPublisher.distinctUntilChanged()
.debounce(500, TimeUnit.MILLISECONDS)
.subscribe({
Log.d(TAG, "query: ${it}")
searchDisposable?.dispose()
searchDisposable = search(query = it)
.subscribe({
// 結果の表示
})
})
}
/**
* 検索用メソッド
*/
fun search(query: String?): Observable<ArrayList<String>> {
// 仮実装。本来は検索結果のリストを返す。
return Observable.empty<ArrayList<String>>()
}
検索処理をObservable<ArrayList<String>>
で受け取れるとした場合、Observable#subscribe
の戻り値であるDisposable
を保持しておき、新たな検索処理を走らせる前にすでに保持しているDisposable
のキャンセルをdispose
メソッドによって実施します。これによって、最後に検索した結果のみ表示することができます。
[追記] 検索時間にばらつきがある問題:switchMapで解決
switchMap
を使うと良いというコメントをいただいたので、調べてみました。
http://reactivex.io/documentation/operators/flatmap.html より
RxJava also implements the switchMap operator. It behaves much like flatMap, except that whenever a new item is emitted by the source Observable, it will unsubscribe to and stop mirroring the Observable that was generated from the previously-emitted item, and begin only mirroring the current one.
switchMap
は新しいアイテムが流れてきたら、以前に流れていたアイテムに関する処理を打ち切ります。これにより、検索処理に時間がかかっている場合に新たな検索処理が走ると前の検索処理をキャンセルすることができます。
override fun onCreate(savedInstanceState: Bundle?) {
// 〜省略〜
queryPublisher.distinctUntilChanged()
.debounce(500, TimeUnit.MILLISECONDS)
.switchMap { search(it) }
.subscribe({
// 検索結果が流れてくる
Log.d(TAG, "result: ${it}")
})
}
/**
* 検索用メソッド
*/
fun search(query: String?): Observable<ArrayList<String>> {
// 仮実装。本来は検索結果のリストを返す。
return Observable.empty<ArrayList<String>>()
}
こちらの方が前節よりも簡潔に書けていますね。
おわりに
この記事では、Androidでリアルタイム検索を実装する時の問題点をRxJavaで解決する方法を紹介しました。今回の問題点はRxJavaを使わずに解決することも可能ですが、RxJavaのdistinctUntilChanged
やdebounce
、switchMap
といったオペレーターを使うことで、より短く処理を書くことができると思います。
以上です。