Dynamically Loading RecyclerView Images in Android

Updated 10/5/2019 for HTTP-RPC 6.6

As in iOS, Android applications often display thumbnail images alongside other text-based content such as contact names or product descriptions in recycler views. However, the images are not usually delivered with the initial request, but are instead retrieved separately afterward. They are typically downloaded in the background as needed to avoid blocking the UI thread, which would temporarily render the application unresponsive.

For example, consider this sample REST service, which returns a list of simulated photo data:

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "https://via.placeholder.com/600/92c952",
    "thumbnailUrl": "https://via.placeholder.com/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "https://via.placeholder.com/600/771796",
    "thumbnailUrl": "https://via.placeholder.com/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "https://via.placeholder.com/600/24f355",
    "thumbnailUrl": "https://via.placeholder.com/150/24f355"
  }, ...
]

Each record contains a photo ID, album ID, and title, as well as URLs for both thumbnail and full-size images; for example:

Example Application

A basic user interface for displaying service results in a recycler view is shown below:

The markup for the main activity simply declares an instance of RecyclerView that will occupy the entire screen:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.v7.widget.RecyclerView android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"/>
</LinearLayout>

Item markup is shown below:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp">
    <ImageView android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView android:id="@+id/textView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:layout_marginLeft="8dp"/>
</LinearLayout>

Photo Class

A class representing the photo data might be defined as follows:

class Photo (map: Map<String, Any>) {
    val id = map["id"] as Int
    val title = map["title"] as String
    val thumbnailUrl = URL(map["thumbnailUrl"] as String)
}

The constructor extracts property values from the map data provided by the service. This data will be used later to retrieve the thumbnails.

View Holder and Adapter Classes

View holder and adapter classes are used to produce and configure individual photo item views. Item data is stored in a list of Photo instances, and previously loaded thumbnail images are stored in a map that associates Bitmap instances with photo IDs:

class MainActivity : AppCompatActivity() {
    // Photo view holder
    inner class PhotoViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        ...
    }

    // Photo adapter
    inner class PhotoAdapter : RecyclerView.Adapter<PhotoViewHolder>() {
        ...
    }

    // Photo list
    var photos: List<Photo>? = null

    // Thumbnail cache
    val photoThumbnails = HashMap<Int, Bitmap>()

    ...
}

Main Activity

When the activity is created, the main view is loaded and the recycler view configured:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity_main)

    recyclerView.layoutManager = LinearLayoutManager(this)
    recyclerView.adapter = PhotoAdapter()
}

The photo list is loaded the first time the activity resumes. An instance of WebServiceProxy (provided by the open-source HTTP-RPC project) is used to retrieve the data. If the call succeeds, the response data is transformed into a list of Photo objects, and the recycler view is refreshed. Otherwise, an error message is logged:

override fun onResume() {
    super.onResume()

    // Load photo data
    if (photos == null) {
        doInBackground({
            val webServiceProxy = WebServiceProxy("GET", URL("https://jsonplaceholder.typicode.com/photos"))

            val photos = webServiceProxy.invoke { inputStream, _, _ -> ObjectMapper().readValue(inputStream, List::class.java) }

            photos.map { @Suppress("UNCHECKED_CAST") Photo(it as Map<String, Any>) }
        }) { activity, result ->
            result.onSuccess { value ->
                photos = value

                activity?.recyclerView?.adapter?.notifyDataSetChanged()
            }.onFailure { exception ->
                println(exception.message)
            }
        }
    }
}

Background Task Execution

The photo list is loaded asynchronously using doInBackground(), an extension method added to the Activity class. It provides a lambda-based wrapper around AsyncTask that allows the result handler to safely dereference the activity without leaking memory. Because the activity type is a generic, the callback can access the members of the activity without a cast:

fun <A: Activity, R> A.doInBackground(task: () -> R, resultHandler: (activity: A?, result: Result<R>) -> Unit) {
    BackgroundTask(this, task, resultHandler).execute()
}

class BackgroundTask<A: Activity, R>(activity: A,
    private val task: () -> R,
    private val resultHandler: (activity: A?, result: Result<R>) -> Unit
) : AsyncTask<Unit, Unit, R?>() {
    private val activityReference = WeakReference<A>(activity)

    private var value: R? = null
    private var exception: Exception? = null

    override fun doInBackground(vararg params: Unit?): R? {
        try {
            value = task()
        } catch (exception: Exception) {
            this.exception = exception
        }

        return value
    }

    override fun onPostExecute(value: R?) {
        resultHandler(activityReference.get(), if (exception == null) {
            Result.success(value!!)
        } else {
            Result.failure(exception!!)
        })
    }
}

Item Content

Item content is managed by the view holder and adapter classes. The Photo instance is retrieved from the list and used to configure the item view:

// Photo view holder
inner class PhotoViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    val imageView: ImageView = view.findViewById(R.id.imageView)
    val textView: TextView = view.findViewById(R.id.textView)
}

// Photo adapter
inner class PhotoAdapter : RecyclerView.Adapter<PhotoViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoViewHolder {
        return PhotoViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_photo, parent, false))
    }

    override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) {
        val photo = photos!![position]

        // Attempt to load image from cache
        val thumbnail = photoThumbnails[photo.id]

        holder.imageView.setImageBitmap(thumbnail)

        if (thumbnail == null) {
            ...
        }

        holder.textView.text = photo.title
    }

    override fun getItemCount(): Int {
        return photos?.size ?: 0
    }
}

If the thumbnail image is already available in the cache, it is used to populate the item’s image view. Otherwise, it is asynchronously loaded from the server and added to the cache as shown below:

if (thumbnail == null) {
    // Request image
    doInBackground({
        val webServiceProxy = WebServiceProxy("GET", photo.thumbnailUrl)

        webServiceProxy.invoke { inputStream, _, _ -> BitmapFactory.decodeStream(inputStream) }
    }) { activity, result ->
        // Add image to cache and update view holder, if visible
        result.onSuccess { value ->
            photoThumbnails[photo.id] = value

            val viewHolder = activity?.recyclerView?.findViewHolderForAdapterPosition(position) as? PhotoViewHolder

            viewHolder?.imageView?.setImageBitmap(value)
        }
    }
}

If the item is still visible when the image request returns, its image view is updated immediately. Otherwise, it will be updated the next time the item is shown.

More Information

Complete source code for this example can be found here. For more information, see the HTTP-RPC README.