Android Native - How to serve asynchronous data to ListAdapter

dimitrilc 1 Tallied Votes 318 Views Share

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:

  1. How to serve asynchronous data to a ListAdapter.

Tools Required

  1. Android Studio. The version used in this tutorial is Android Studio Chipmunk 2021.2.1 Patch 1.

Prerequisite Knowledge

  1. Intermediate Android.
  2. *RecyclerView.Adapter.
  3. Kotlin coroutines.
  4. Retrofit.
  5. Moshi

Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. 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'
  3. 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"/>
  4. 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>
  5. 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>
  6. 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?
     )
  7. 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
     )
  8. 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
                        }
                    }
                }
            }
        }
     }
  9. 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
                }
            }
        }
     }
  10. 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.

Dog_App.gif

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.

  1. 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
        }
     }
  2. 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)
        }
     }
  3. 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)
            }
        }
     }
  4. Callbacks can be hard to read, so you can reference the diagram below for an overview of what is going on.

Untitled_Diagram_drawio.png

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
}

Dog_App_Flickering.gif

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.

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.