This is part 3 of a series that will showcase common use cases that i’ve been curious about how to implement as I don’t get to do most of them on my day to day work.
You can get the end result of this article in this GitHub repository:
The incremental branch is here: https://github.com/yoavgray/flickr-kotlin/tree/part-3
The series will cover small topics and will add incremental value to the same GitHub repo:
- Part 1: Fetch a list of images from Flickr and show them in a grid
- Part 2: Show loaded images in squares using ConstraintLayout
- Part 3: Fetch a list of images from a search while debouncing search requests with Coroutines
- Part 4: Add infinite scroll
Part 3: Fetch a list of images from a search while debouncing search requests with Coroutines
As a reminder, this is what the app looked like at the end of Part 2:
We’d like to add a search box. You could use a SearchView
or an EditText
. For the sake of simplicity, I’ll add an EditText
above.
activity_photos.xml
currently looks like this:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.PhotosActivity"
android:padding="4dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photosRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</FrameLayout>
To add a search box, we’ll have to have a different type of view group. I usually use ConstraintLayout
but this layout is simple enough to use a verticalLinearLayout
(don’t forget to maintain that 4dp top padding the FrameLayout
had):
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.PhotosActivity"
>
<EditText
android:id="@+id/searchBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search for something"
android:layout_marginBottom="4dp"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photosRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</LinearLayout>
So now we have a non functional search box above the grid layout:
Now let’s hook everything up. We’ll need to do a few things here:
- Stop fetching images with the search term “dogs” as default
- Add a text change listener that fetches the current search term every time you enter another character (relax, we will not keep it that way)
Step 1: Set endpoint to fetch dynamic search terms
We need to change our Retrofit endpoint from this:
@GET("?method=flickr.photos.search&format=json&nojsoncallback=1&text=dogs&api_key=$FLICKR_API_KEY")
suspend fun fetchImages(): PhotosSearchResponse
To this:
@GET("?method=flickr.photos.search&format=json&nojsoncallback=1&api_key=$FLICKR_API_KEY")
suspend fun fetchImages(@Query(value = "text") searchTerm: String): PhotosSearchResponse
That way, Retrofit automatically adds the value of searchTerm
as text=value
in the url parameters. Notice that we had to add searchTerm
as an argument, which will eventually trickle down from the activity, to the ViewModel, and then to the endpoint.
The ViewModel method that fetches images, now takes an argument and no longer being executed as a part of the init
block:
init {
viewModelScope.launch {
val searchResponse = WebClient.client.fetchImages()
val photosList = searchResponse.photos.photo.map { photo ->
...
}
mutablePhotosLiveData.postValue(photosList)
}
}
Now changes to this:
fun fetchImages(searchTerm: String) {
if (searchTerm.isBlank()) {
mutablePhotosLiveData.postValue(emptyList())
return
}
viewModelScope.launch {
val searchResponse = WebClient.client.fetchImages(searchTerm)
val photosList = searchResponse.photos.photo.map { photo ->
...
}
mutablePhotosLiveData.postValue(photosList)
}
}
Step 2: add a text change listener for the search box
This is super simple. We just need to add this block of code to the activity:
searchBox.addTextChangedListener { editable ->
photosViewModel.fetchImages(editable.toString())
}
Note: For simplicity sake, I’m using Kotlin synthetic binding, so that’s why you won’t see any
findViewById
explicitly written anywhere. The above method is also an extension function from thecore-ktx
library.
This works. The problem, as I insinuated before, is that for every character you’ll write, it will immediately fetch another search request. That’s not only wasteful, but could very easily lead to weird bugs where old requests might return later than new ones, and you’ll have wrong data shown (that bug could still happen even if we debounce. We’ll see how we can avoid that soon.
Coroutines for the rescue!
A nifty little trick that I like to do is to use a coroutine with a delay, so instead of making the view model request immediately, we’ll change it to this (these kind of issues are usually solved with RxJava throttleWithTimeout
operator or something similar):
// High level definition
private var searchJob: Job? = null// Inside onCreate
searchBox.addTextChangedListener { editable ->
searchJob?.cancel()
searchJob = lifecycleScope.launch {
delay(SOME_DELAY_IN_MS)
photosViewModel.fetchImages(editable.toString())
}
}
What’s happening here? For every character you type, you’ll cancel a potentially active coroutine that’s about to fetch images (after the delay), and then you’ll wait again in case another character is coming, but once you get outside of that delay time window, the view model request will execute, the live data will be updated, and then the UI will be updated consecutively.
This fixes the issue of making too many requests. What’s still missing is how do we make sure that the latest update to the live data is not a stale response from a previous set of characters? We could try to compare the response search term (if that’s even possible) to the current search term and throw away anything that doesn’t correlate. Another thing we could do, is not use LiveData
. We’re already using the lifecycleScope
to launch a coroutine, so we could change fetchImages
method in the view model to be a suspend
function. After that, we’ll stop observing the LiveData, and update the recycler view inside the coroutine. That way, if a new search term is typed, it will cancel the coroutine and we don’t have to worry about async bugs.
fetchImages
inside the ViewModel now looks like this (changes are bolded):
suspend fun fetchImages(searchTerm: String): List<Photo> {
if (searchTerm.isBlank()) {
return emptyList()
}
val searchResponse = WebClient.client.fetchImages(searchTerm)
return searchResponse.photos.photo.map { photo ->
...
}
// We're no longer updating a live data here
}
And in the activity, we no longer observe the live data:
// Inside onCreate
searchBox.addTextChangedListener { editable ->
searchJob?.cancel()
searchJob = lifecycleScope.launch {
delay(SEARCH_DELAY_MS)
val imagesList = photosViewModel.fetchImages(editable.toString())
with(photosAdapter) {
photos.clear()
photos.addAll(imagesList)
notifyDataSetChanged()
}
}
}
// Set up recycler view
photosRecyclerView.adapter = photosAdapter
photosRecyclerView.layoutManager = GridLayoutManager(this, 3)
This is obviously not addressing network connectivity issues or server response issues, but as for the “happy path”, this is it!