Introduction ##
In this tutorial, we will learn how to load data asynchronously into a ListAdapter (a subclass of RecyclerView.Adapter).
Goals
At the end of the tutorial, you would have learned:
- How to serve asynchronous data to a ListAdapter.
Tools Required
- Android Studio. The version used in this tutorial is Android Studio Chipmunk 2021.2.1 Patch 1.
Prerequisite Knowledge
- Intermediate Android.
- *RecyclerView.Adapter.
- Kotlin coroutines.
- Retrofit.
- Moshi
Project Setup
To follow along with the tutorial, perform the steps below:
-
Create a new Android project with the default Empty Activity.
-
Add the dependencies below into your module build.gradle file.
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.0-rc01" implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'androidx.activity:activity-ktx:1.4.0' implementation 'io.coil-kt:coil:2.1.0' implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
-
Because we are reaching out to the Dog API (https://dog.ceo/dog-api) in this tutorial, internet permission will be required. Add the permission below to your manifest.
<uses-permission android:name="android.permission.INTERNET"/>
-
Replace the code in activity_main.xml with the code below. We have replaced the default TextView with a RecyclerView.
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView_dog" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:spanCount="2" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
Create a new layout for a ViewHolder called item_view.xml. Replace the code inside item_view.xml with the code below.
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="wrap_content" android:layout_height="wrap_content"> <ImageView android:id="@+id/imageView_breedImage" android:layout_width="50dp" android:layout_height="50dp" android:layout_margin="8dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:srcCompat="@tools:sample/avatars" /> <TextView android:id="@+id/textView_dogBreed" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginHorizontal="8dp" tools:text="Dog Breed" android:textSize="18sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@+id/imageView_breedImage" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
Create a new Kotlin file called DogService.kt. This file will house our Retrofit HTTP service. Copy and paste the code below into this file.
interface DogService { @GET("breeds/list/all") suspend fun getAllBreeds(): BreedsCall? @GET("breed/{breed}/images/random") suspend fun getImageUrlByBreed(@Path("breed") breed: String): ImageUrlCall? companion object { val INSTANCE: DogService = Retrofit.Builder() .baseUrl("https://dog.ceo/api/") .addConverterFactory(MoshiConverterFactory.create()) .build() .create(DogService::class.java) } } data class BreedsCall( val message: Map<String, List<String>>? ) data class ImageUrlCall( val message: String? )
-
Create a new data class called DogUiState using the code below. This is the data that we will service from the ViewModel.
data class DogUiState( val breed: String, val image: Drawable? = null )
-
Create a new class called MainViewModel using the code below. This is our program’s only ViewModel.
class MainViewModel : ViewModel() { private val httpClient = DogService.INSTANCE //Adapter will invoke this val imageUrlLoader: (String)->Unit = { breed -> if (!_imageUrlCache.value.containsKey(breed)){ loadImageUrl(breed) } } //Self will invoke this var imageLoader: ((breed: String, url: String)->Unit)? = null private val _uiState = MutableStateFlow<List<DogUiState>>(listOf()) val uiState = _uiState.asStateFlow() private val _imageUrlCache = MutableStateFlow<Map<String, String?>>(mapOf()) //Fine-grained thread confinement. Performance penalty. private val mutex = Mutex() init { //Gets breeds viewModelScope.launch(Dispatchers.IO) { try { httpClient.getAllBreeds()?.message?.let { breeds -> if (breeds.isNotEmpty()){ val state = breeds.keys .map { DogUiState(breed = it) } _uiState.value = state } } } catch (e: IOException){ e.printStackTrace() } } } private fun loadImageUrl(breed: String) { //Adding the breed key so observers know that there is already // pending async loading operation _imageUrlCache.value = _imageUrlCache.value.plus(breed to null) viewModelScope.launch(Dispatchers.IO){ try { //Loading image URL httpClient.getImageUrlByBreed(breed) ?.message ?.let { mutex.withLock { //Adds url to the URL cache _imageUrlCache.value = _imageUrlCache.value.plus(breed to it) } //Starts loading images imageLoader?.invoke(breed, it) } } catch (e: IOException){ e.printStackTrace() } } } fun updateImage(drawable: Drawable, breed: String){ //Updates UiState with image viewModelScope.launch(Dispatchers.IO) { mutex.withLock { _uiState.value = _uiState.value.map { if (it.breed == breed){ it.copy(image = drawable) } else { it } } } } } }
-
Create a new class called DogAdapter using the code below.
class DogAdapter(private val imageUrlLoader: (String)->Unit) : ListAdapter<DogUiState, DogAdapter.DogViewHolder>(DIFF_UTIL_CALLBACK) { inner class DogViewHolder(view: View) : RecyclerView.ViewHolder(view){ val breed: TextView = view.findViewById(R.id.textView_dogBreed) val image: ImageView = view.findViewById(R.id.imageView_breedImage) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DogViewHolder { val itemView = LayoutInflater .from(parent.context) .inflate(R.layout.item_view, parent, false) return DogViewHolder(itemView) } override fun onBindViewHolder(holder: DogViewHolder, position: Int) { val currentData = currentList[position] holder.breed.text = currentData.breed //If there is no image data, requests ViewModel //to start loading the images if (currentData.image != null){ holder.image.setImageDrawable(currentData.image) } else { imageUrlLoader(currentData.breed) } } override fun onViewRecycled(holder: DogViewHolder) { //If Drawables are not released, ViewHolders will display wrong image //when you are scrolling too fast holder.image.setImageDrawable(null) super.onViewRecycled(holder) } companion object { val DIFF_UTIL_CALLBACK = object : DiffUtil.ItemCallback<DogUiState>() { override fun areItemsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean { //This is called first return oldItem.breed == newItem.breed } override fun areContentsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean { //This is called after return oldItem == newItem } } } }
-
Finally, replace the content of MainActivity.kt with the code below.
class MainActivity : AppCompatActivity() { private val viewModel by viewModels<MainViewModel>() //Re-usable request builder private val imageRequestBuilder = ImageRequest.Builder(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) /* Performing the image loading in Activity code because Coil requires a context. Can also use AndroidViewModel if you want the ViewModel to do the image loading as well. */ val imageLoader: (breed: String, url: String)->Unit = { breed, url -> val request = imageRequestBuilder .data(url) .build() lifecycleScope.launch(Dispatchers.IO){ imageLoader.execute(request).drawable?.also { //Sends image to ViewModel so it can update the UiState viewModel.updateImage(it, breed) } } } //Pass the callback to ViewModel viewModel.imageLoader = imageLoader val recyclerView = findViewById<RecyclerView>(R.id.recyclerView_dog) val dogAdapter = DogAdapter(viewModel.imageUrlLoader).also { recyclerView.adapter = it } lifecycleScope.launch { viewModel.uiState.collect { //Submit list so ListAdapter can calculate the diff dogAdapter.submitList(it) } } } }
Project Overview
We technically already have the fully completed project at this stage. The app will smoothly load both dog breed and a random breed image (in background threads) into the RecyclerView.
For the rest of the tutorial, we will mostly learn how all of this works in the background.
Architecture
Because of how the Dog API works, we have to make at least three HTTP calls to be able to achieve the functionality that we want.
-
First call: get the list of breeds. We only do this once in MainViewModel.
httpClient.getAllBreeds()?.message?.let { breeds -> if (breeds.isNotEmpty()){ val state = breeds.keys .map { DogUiState(breed = it) } _uiState.value = state } }
-
Second call: get a random image URL for a specific breed. We have to do this for every single breed, only as needed (when RecyclerView requests the data). The callback below is passed to the ListAdapter, which it will invoke during
onBindViewHolder()
.//Adapter will invoke this val imageUrlLoader: (String)->Unit = { breed -> if (!_imageUrlCache.value.containsKey(breed)){ loadImageUrl(breed) } }
-
Third call: use Coil to load the image using the image URL.
/* Performing the image loading in Activity code because Coil requires a context. Can also use AndroidViewModel if you want the ViewModel to do the image loading as well. */ val imageLoader: (breed: String, url: String)->Unit = { breed, url -> val request = imageRequestBuilder .data(url) .build() lifecycleScope.launch(Dispatchers.IO){ imageLoader.execute(request).drawable?.also { //Sends image to ViewModel so it can update the UiState viewModel.updateImage(it, breed) } } }
-
Callbacks can be hard to read, so you can reference the diagram below for an overview of what is going on.
DiffUtil Usage in DogAdapter
The goal of DiffUtil in DogAdapter is only to assist the RecyclerView in figuring how your dataset as changed. Because the list of breeds do not change when the app is being used, it is safe to use it as a key to identify whether two items are the same. Improperly implementing this function can cause weird flickering and jumping issues.
override fun areItemsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean {
//This is called first
return oldItem.breed == newItem.breed
}
The animation below depicts how your app will look like if you always return false
from areItemsTheSame()
.
override fun areItemsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean {
//This is called first
//return oldItem.breed == newItem.breed
return false
}
The second function that we have overridden is areContentsTheSame()
. This function is only called if areItemsTheSame()
returns true
. This is where we decide whether a Drawable has been loaded or not.
Race Conditions
When the list was being scrolled too fast, the StateFlows _uiState
and _imageUrlCache
might experience race conditions where the previous value of their state might be outdated, and work from one thread will override the value from the other.
I have experienced this in about 1 in 10 runs, so I have added a quick fix using a Mutex. The Mutex introduces a performance penalty, but the app felt smooth, so I did not feel like more optimization was needed.
Summary
In a real app, you primarily would want to perform IO operations in a Repository or UseCases instead. I have skipped the data layer in this tutorial to keep it simple.
Another approach to loading async data in use cases like this is to use the Paging 3 library. You can check out the tutorial on it here
The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidListAdapterAsyncData.