LoginSignup
35
30

More than 5 years have passed since last update.

[Android] RxJavaでリアルタイム検索を制御する

Last updated at Posted at 2017-09-26

はじめに

現在、GoogleやTwitterなど、多くのWebサービスやスマートフォンアプリでリアルタイム検索がサポートされています。この記事では、Androidアプリでリアルタイム検索を実装するときに問題となることをRxJavaを利用することでスマートに解決することを目指します。

リアルタイム検索実装時の問題点

素朴に考えると、TextWatcher#onTextChangedでユーザーの入力文字列を検索処理に渡して結果を表示するという方法でリアルタイム検索を実装できます。しかし、その方法には以下のような問題があります。

onTextChangedが同じ文字列で2回呼ばれる問題

MainActivity.kt
// 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に渡すTextWatcheronTextChangedメソッドでユーザーの入力文字列を取得することができます。実際に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で解決

MainActivity.kt
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で解決

MainActivity.kt
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で解決する方法がよりスマートなので、そちらをおすすめします。

最後に検索した結果だけを受け取れるようにすることで、問題の解決を目指します。

MainActivity.kt
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は新しいアイテムが流れてきたら、以前に流れていたアイテムに関する処理を打ち切ります。これにより、検索処理に時間がかかっている場合に新たな検索処理が走ると前の検索処理をキャンセルすることができます。

MainAcitivity.kt
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のdistinctUntilChangeddebounceswitchMapといったオペレーターを使うことで、より短く処理を書くことができると思います。
以上です。

35
30
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
30