MVVM Flickr Android app with common dev use cases — Part 3

Yoav Gray
5 min readApr 24, 2020

--

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 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:

Images are in perfect squares with the same horizontal and vertical padding

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 the core-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!

That’s it for Part 3. In part 4 I’m going to add infinite scroll, so stay tuned! If I missed anything important, please leave a comment. Got questions? Follow me on Medium at Yoav Gray or find me on Twitter, and if you learned something new, please share the 👏.

--

--

Yoav Gray

Android Eng @Uber | Previously @Autolist (Acquired by @CarGurus)