There was a time when the concept of "instant search" wasn't a thing in either apps or on websites. Instead, you had to type your search query and press "Search" in order to see the result. Then some smart people managed to create backend APIs that could handle a huge amount of requests and do it very quickly, so instant search became a thing.

Example of Instant Search in an Android app
Example of Instant Search in an Android app

The concept is fairly simple and something we often take for granted these days. When a user types a query into a search box, the app will perform a search for every letter typed and update the search result. No need to press "Search" every time you change the query!

Since users usually type several letters in a row (also known as "words"), it is not necessary to make a new network request until the user stops typing. We don't really know when the user will stop typing, so we need to come up with some kind of threshold that will work as a signal to make a request to the backend Search API.

There are many ways to solve this, and in this post I will explain the method I recently learned when I decided to implement it using Kotlin Coroutines. When I've implemented this type of feature before it always turned out to be rather complicated and hard to test. With Kotlin Coroutines, it turned out to be extremely easy to write (once I grasped how things worked) as well as writing test for.


Update (2019-12-19): I discovered that my initial error handling had a bug as I misunderstood how catch() works. This is now fixed.

Let’s start at the UI. We have simple XML layout with an EditText and a RecyclerView.

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/searchResult"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/searchText"/>

    <EditText
        android:id="@+id/searchText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

In our Activity we add a listener for when the text in searchText has changed.

searchText.doAfterTextChanged { 
    // Pass text to ViewModel for searching...
}

Our ViewModel now exposes a LiveData with the latest search result, and we’ll use that to update the content of the RecyclerView.

viewModel.searchResult.observe(this) { searchAdapter.submitList(it) }

This is all we need to do on the UI parts, the next thing is to implement the instant search logic in our ViewModel.

Instant Search with a flow

The coroutine component we will use to pass text into our instant search logic is a BroadcastChannel<String> with the capacity set to Channel.CONFLATED (This is better than ConflatedBroadcastChannel since we never need to access the current value at any point). Using a channel here lets us create a bridge between the UI and the Flow logic. The behaviour of a channel can be compared to a blocking queue in the coroutines world (note that it is still a suspending function) and lets us consider all strings coming from the UI.

The channel can be interacted with using either regular functions or suspending functions. In this example, we will use the suspending send() function which will ensure that the call will be cancelled when the lifecycle for the activity reach destroy.

// In our ViewModel
private val queryChannel = BroadcastChannel<String>(Channel.CONFLATED)

// In our Activity
binding.searchText.doAfterTextChanged {
    lifecycleScope.launch { 
        viewModel.queryChannel.send(it.toString())
    }
}

Next, we will consume the events from the channel as a Flow and apply the necessary operators to make sure we don't send a query to our Search API too frequently.

val searchResult = queryChannel
    .asFlow()
    .debounce(500)
    .mapLatest {
        return searchRepository.performSearch(it)
    }

The call to asFlow() converts our channel into a Flow, and the operator debounce() is what ensures we can throttle the calls to our Search API and only send a query after a 500 milliseconds have passed without a new string being emitted.

Finally, the mapLatest() operator will perform the actual call to the Search API and return the search result.

The great thing with this is that when a new event is emitted, anything below that hasn’t been collected yet will be cancelled.

In order to get a better understanding of how this works, you can wrap the code in mapLatest() in a try/catch statement which catches CancellationException and prints something to logcat. Just make sure you rethrow the CancellationException or the coroutine won’t be shut down properly.

mapLatest { query ->
    try {
        searchRepository.performSearch(it) 
    } catch (e: CancellationException) {
        Log.d(TAG, “More text received - search operation cancelled!”)
        throw e
    }
}

When you keep typing text you’ll see that the log printout about cancellation is only printed occasionally. This is because the flow will be waiting at the debounce() call most of the time. However, once mapLatest() is called a new coroutine is started that can also be cancelled. Basically, cancellation in mapLatest() depends on your timing when typing in text.

Error handling

Adding error handling to our instant search can be done using a generic try/catch statement in mapLatest(). The important thing here is to catch the exceptions that comes from the Search API, but rethrow everything else. By creating a sealed class (SearchResult) and have two sub-classes representing valid results and errors, we get a really nice error handling in our code.

sealed class SearchResult
class ValidResult(val matches: List<String>) : SearchResult()
class Error(e: Exception) : SearchResult()

mapLatest { query ->
    try {
        val result = searchRepository.performSearch(it)
        ValidResult(result)
    } catch (e: Throwable) {
        if (e is CancellationException) {
            throw e
        } else {
            ErrorResult(e)
        }
    }
}

The important detail here is to rethrow CancellationException, or we will interfere with the cancellation of the coroutine in mapLatest().

The use of a sealed class as shown above makes it easier to handle errors in the UI and still keep the flow of events active (since errors thrown from a Flow operator will terminate the flow).

You can also use the catch() operator to deal with any unexpected exceptions. This will let you wrap those exceptions as we did above, but the upstream flow will still be terminated and you won't receive any additional events.

class TermianlError(e: Exception) : SearchResult()

.mapLatest { query -> ... }
.catch { e: Throwable -> emit(TerminalError(e)) }

Convert to LiveData

The final thing is to convert the search results coming from our flow to a LiveData. This is done using the extension function asLiveData(). Putting all of this together results in the following expression.

val searchResult = queryChannel
    .asFlow()
    .debounce(500)
    .mapLatest {
        try {
            val result = searchRepository.performSearch(it)
            ValidResult(result)
        } catch (e: Throwable) {
            if (e is CancellationException) {
                throw e
            } else {
                ErrorResult(e)
            }
        }
    }
    .catch { emit(Error(it)) }
    .asLiveData()

Now we got everything we need for our instant search. This implementation should be fairly easy to follow, even if you’re not familiar with the details of the coroutine APIs used here.

Testing

Something that I often find missing when reading articles and tutorials on a new API is how we can test our code that uses it (including most of my own posts). To avoid this, let’s have a look at how we can test our implementation. For a more thorough look at how to write tests for Coroutines, I recommend “Testing with Coroutines” by Sean McQuillan. This is an excellent presentation that everyone working with Coroutines should watch.

We will write a test for our SearchViewModel that implements most of the code above. The first part looks like this;

class SearchViewModelTest {
    @get:Rule
    var rule: TestRule = InstantTaskExecutorRule()

    val mainDispatcher = TestCoroutineDispatcher()

    @Before
    fun setUp() {
        Dispatchers.setMain(mainDispatcher)
    }

    @After
    fun teardown() {
        Dispatchers.resetMain()
    }

    @Test
    fun testInstantSearch() = mainDispatcher.runBlockingTest {
        // Test goes here!
    }
}

We first need to add a test rule to our class that will make sure any architecture component can run on a test executor (since there is no Android main available in the unit test). Next, we create a TestCoroutineDispatcher that will be used for executing the coroutines in the test. We also have the @Before and @After functions for setting the Main CoroutineDispatcher to our TestCoroutineDispatcher.

The test itself goes into the testInstantSearch() function. What we’re going to test here is the debouncing of the input. We want to make sure that we will only perform a call to the SearchAPI after 500 ms have passed and no new query input has been emitted. In this test we’re not interested in the actual search results, so we will ignore that in this test.

Note that the test function itself points to a call to mainDispatcher.runBlockingTest(). This is a convenience function that exposes functions that lets us control a virtual clock inside the coroutine, thus letting us skip ahead and trigger the debounce() operator in the test.

The test starts by setting up the fake Search API, create our SearchViewModel instance and define the actual and expected results (used later in the assertions).

val fakeApi = FakeApi()
val actualQueries = mutableListOf<String>()
val expectedQueries = listOf("aa", "bbb", "ccc", "ddd actual query")

val subject = SearchViewModel(
    fakeApi,
    mainDispatcher
)

The FakeApi will simply collect all the queries that we send in so we can verify them later. Note that we always return an empty list as a result since we’re not testing the search logic here.

class FakeApi: SearchApi {
    val actualQueries = mutableListOf<String>()

    override suspend fun performSearch(query: String): List<String> {
        actualQueries.add(query)
        return listOf()
    }
}

Next, we need to start collecting the flow of the search results, or our chain of operators won’t be running.

val collectParent = launch {
    subject.internalSearchResult.launchIn(this)

    subject.queryChannel.asFlow().mapLatest { query ->
        actualQueries.add(query)
    }.launchIn(this)
}

This is a separate coroutine running inside the test. The call to internalSearchResult.launchIn() starts the collecting and triggers the debounce() operator. The call to queryChannel.asFlow().mapLatest() will collect all strings that are sent to our channel, and will be used to ensure that we actually send queries and they are not getting lost before the debounce() operator kicks in.

Once the collecting of queries have started, we can start passing search queries to channel.

for (query in expectedQueries) {
    subject.queryChannel.send(query)
    advanceTimeBy(35)
}

advanceTimeBy(500)

Here we simply pass all the query strings into the channel using send(), and then advance the clock by 35 milliseconds to make sure that the debounce() operator catches them. Once all queries have been sent, we advance the clock with another 500 ms to trigger the first release from debounce().

The final step is to verify that our API gets called only once with the final search query and that all search queries actually got sent to the channel.

collectParent.cancel()

assert(fakeApi.actualQueries == listOf("ddd actual query")) { "Only saw one search" }
assert(actualQueries == expectedQueries) { "all queries were sent, then debounced" }

First, we cancel the collecting job or our test coroutine won’t complete. Then we check that our API only received the final query string and that the actual queries sent matches all the items in expectedQueries.

This completes a first test of our Instant Search logic in the SearchViewModel. You might want to expand on this with additional test and ensure that you get the correct response depending on the length of the query, empty results, etc.

Conclusions

Instant search makes the user experience in a search feature much better, but it can be complicated to implement if you’ve not done this before. Hopefully, this post will help you the next time you need to do this.

I’ve published a simple example application on GitHub which does Instant Search in a large set of english words. Feel free to use this code for your own project.

Many thanks to Manuel Vivo for reviewing this post and giving some great suggestions for improving the implementation, Sean McQuillan for helping me with the code sample and especially getting the unit test right, and Adam Powell for leading me in the right direction of how to use Flow.

Photo by adrian on Unsplash