Debounce Pencarian Kata Kunci dengan RxJava 2

Chandra
Karena Kita Vidio
Published in
4 min readSep 20, 2018

Dalam aplikasi mobile, sering kali kita temukan fitur menampilkan hasil pencarian ketika pengguna sedang mengetik kata kunci. Penerapan paling sederhana fitur ini adalah mengirimkan request pencarian setiap kali pengguna mengubah kata kunci.

Misalkan pengguna mencari “motogp”, maka:

  1. Pengguna mengetik “m”
  2. Aplikasi request pencarian “m”
  3. Pengguna mengetik “mo”
  4. Aplikasi request pencarian “mo”
  5. Pengguna mengetik “mot”
  6. Aplikasi request pencarian “mot”

…dan seterusnya.

Namun, dapat dibayangkan aplikasi akan mengirim banyak request yang tidak dibutuhkan karena pencarian sebelum “motogp” sebenarnya tidak dibutuhkan oleh pengguna. Server pun dirugikan karena harus memproses request yang tidak perlu.

Oleh karena itu, seringkali pengembang aplikasi akan menunda request pencarian selama beberapa waktu , dengan asumsi kata kunci muncul belakangan lebih mendekati atau persis sama dengan kata kunci yang diinginkan pengguna. Hal ini dapat dirumuskan:

waktu_aksi_input_terakhir + x < waktu_sekarang

di mana nilai x ini adalah nilai yang ditentukan oleh pengembang aplikasi
(Umumnya berkisar 200ms-300ms).

Teknik di atas biasa diterapkan dengan debounce dan mudah ditemukan lewat mesin pencari. Dengan debounce, langkah-langkah di atas berubah seperti ini:

  1. Pengguna mengetik “m”
  2. Aplikasi menunggu input-an terbaru sampai x
  3. Pengguna mengetik “mo”
  4. Aplikasi menunggu input-an terbaru sampai x
  5. …dst
  6. Pengguna mengetik “motogp”
  7. Aplikasi menunggu input-an terbaru sampai x.
  8. Tidak ada input-an baru sampai dengan x, aplikasi request pencarian “motogp”

(Contoh di atas adalah penyederhaan dari kasus nyata. Seringkali pengguna berhenti sebentar di tengah-tengah mengetik dan itu lumrah terjadi.)

Implementasi dengan RxJava 2

RxJava 2 memiliki operator debounce yang sesuai dengan apa yang dibutuhkan.
Implementasi paling sederhana dalam bahasa pemograman kotlin seperti ini:

val keywords = // observable untuk kata kunci yang tengah diketikkeywords
.debounce(250, TimeUnit.MILLISECOND) // tunggu 250ms sebelum melakukan pencarian ke server
.switchMap {
// melakukan request pencarian ke server
}
.subscribe({
// tampilkan hasil pencarian
}, {
// tampilkan halaman kesalahan terjadi
})

Namun, implementasi ini memiliki kekurangan ketika request pencarian ke server mengalami error, maka pencarian berikutnya tidak akan terjadi akibat subscriber akan berhenti subscribe ketika error terjadi.

Kita dapat memperbaikinya dengan subscribe kembali ketika error terjadi dengan retry operator.

val keywords = // observable untuk kata kunci yang tengah diketikkeywords.debounce(250, TimeUnit.MILLISECOND)
.switchMap {
// melakukan request pencarian ke server
// beberapa kali error dapat terjadi baik dari response server
// atau network error
}
.retry() // subscribe kembali ketika error
.subscribe({
// tampilkan hasil pencarian
}, {
// tampilkan halaman request error
})

Dengan versi terbaru, setelah error terjadi, pencarian masih akan berlangsung. Akan tetapi, beberapa kali percobaan memperlihatkan hal yang tidak diharapkan, yaitu setelah pengguna mengetikkan kata kunci dengan sempurna, tidak tampak pencarian dengan kata kunci tersebut. Misalkan pengguna selesai mengetik “motogp”, tapi permintaan pencarian terakhir adalah “motog”.

Keanehan ini selalu terjadi setelah error terlihat sebelumnya. Oleh karena itu, muncul kecurigaan ketika error, maka beberapa kata kunci yang dihasilkan bersamaan dengan error tidak terproses. Untuk menguji hipotesis ini, maka dibuatlah contoh kode ini:


class DebounceTest {
@Test
fun incorrect_implementation_debounce_click() {
val inputs = listOf<InputCase>(
InputCase(“a”, true),
InputCase(“b”, false)
)
val fakeUserInput = PublishSubject.create<InputCase>()
val observeInput : Observable<InputCase> = fakeUserInput
.debounce(200, TimeUnit.MILLISECONDS)
.switchMap {
if (it.shouldError) {
Observable.just(1)
.delay(50, TimeUnit.MILLISECONDS)
.flatMap { Observable.error<InputCase> (RuntimeException(“Faking error”)) }
} else {
Observable.just(it)
.delay(50, TimeUnit.MILLISECONDS)
}
}
.log()
.retry()
observeInput.subscribe(System.out::println) fakeUserInput.onNext(inputs[0])
// wait before emit new input.
// 250 ms is total time of debounce time (200ms) + fake error delay time (50ms)
TimeUnit.MILLISECONDS.sleep(250)
fakeUserInput.onNext(inputs[1])
// because debounce use computation scheduler, sleep test thread to wait it
TimeUnit.SECONDS.sleep(1)
}
private data class InputCase(
// fake user input
val input: String,
// flag for pretending error happened, i.e: request fails
val shouldError: Boolean
)
// log helper to show whatever happens with observable
private inline fun <reified T> Observable<T>.log() : Observable<T> {
return this
.doOnSubscribe { System.out.println(“onSubsribe $it at time: ${System.currentTimeMillis()}”) }
.doOnNext { System.out.println(“next : $it at time: ${System.currentTimeMillis()}”) }
.doOnError { System.out.println(“error : $it at time: ${System.currentTimeMillis()}”) }
.doOnComplete { System.out.println(“complete at time: ${System.currentTimeMillis()}”) }
.doOnDispose { System.out.println(“onDispose at time: ${System.currentTimeMillis()}”) }
}
}

Output

onSubsribe 0 at time: 1536684717184
error : java.lang.RuntimeException: Faking error at time: 1536684717454
onSubsribe 0 at time: 1536684717454
onDispose at time: 1536684717454

Output yang diharapkan

InputCase(input=b, shouldError=false)

Akan tetapi, implementasi di atas tidak menghasilkan output tersebut. Bila dilihat, onDispose dipanggil pada waktu yang sama dengan onSubscribe kedua (akibat retry operator). Muncul hipotesis ketika dispose terjadi, di waktu yang sama pengguna selesai mengetik kata kunci, sehingga kata kunci tersebut tidak pernah diterima oleh subscriber. Untuk itu, kita perlu menghilangkan error dengan menulis ulang seperti ini:

@Test
fun correct_implementation_debounce_click() {
val inputs = listOf<InputCase>(
InputCase(“a”, true),
InputCase(“b”, false)
)
val fakeUserInput = PublishSubject.create<InputCase>()
val observeInput : Observable<InputCase> = fakeUserInput
.debounce(200, TimeUnit.MILLISECONDS)
.switchMap {
if (it.shouldError) {
Observable.just(1)
.delay(50, TimeUnit.MILLISECONDS)
.flatMap { Observable.error<InputCase> (RuntimeException(“Faking error”)) }
} else {
Observable.just(it)
.delay(50, TimeUnit.MILLISECONDS)
}
.onErrorResumeNext { _: Throwable ->
// replace error with replacement usually default value
Observable.just(InputCase("error replacement", false))
}
}
.log()
observeInput.subscribe(System.out::println) fakeUserInput.onNext(inputs[0])
TimeUnit.MILLISECONDS.sleep(250)
fakeUserInput.onNext(inputs[1])
TimeUnit.SECONDS.sleep(1)
}

Output


onSubsribe 0 at time: 1536685689006
next : InputCase(input=error replacement, shouldError=false) at time: 1536685689262
InputCase(input=error replacement, shouldError=false)
next : InputCase(input=b, shouldError=false) at time: 1536685689520
InputCase(input=b, shouldError=false)

Dapat dilihat output yang diharapkan muncul.

Dengan implementasi yang tepat, RxJava 2 akan memudahkan kita dalam menerapkan debounce. Dan bila ada kesalahan yang terjadi, maka akan sangat membantu bila kita dapat mencoba membuat model sederhana untuk menguji hipotesis.

Kode-kode di atas hanyalah ilustrasi dan tidak ditujukan sebagai rujukan.

Beberapa sumber yang dipakai dalam penulisan:
1. Survei reaksi klik manusia

2. Ilustrasi interaktif debounce di RxMarbles

3. RxJava 2 Debounce operator dapat ditemukan di sini

4. Test Scheduler untuk menggantikan thread sleep

5. RxJava 2 adalah salah satu implementasi Reactive programming dalam bahasa java

--

--