Displaying images in Android app: maintainable, testable, painless. Part I
Tags: android, kotlin
Published: 2023-06-13 13:42:29

If your app shows dozens of thousands of images, these images would most likely come from various sources: downloaded from the web, loaded locally, generated, etc.

Writing additional code for displaying every type is time-consuming, inefficient, not scalable and potentially causing a lot of pain and bugs.

Every time you add a new screen you will get swamped copypasting your essential code and swamped again fixing the bugs you created while copypasting.

What you really need instead of suffering this agony is a slick and elegant system to centralize the whole displaying process and fit the maintainable, testable and painless criteria.

Here is how we did it in Revolut's Android app.

The article is divided into two parts:

* Part I - improving the legacy approach and building delegates,
* [Part II - making transformations, testing and summing-up the results.](https://dev.to/ezubkov/displaying-images-in-android-app-maintainable-testable-painless-part-ii-43m6)

---

## Revolut app
There are several types of image use in Revolut app:
* Transaction lists with various icons,
* Cards lists,
* Lottie animations,
* GIFs.

Let's take a look at the transactions list. It may contain dozens of cell types, however, we'll pick five of them as an example.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hpokua88zomp0obllcn1.jpg)

Each image has its own source or can be generated.

## The legacy approach
Following the legacy approach to displaying the list you would start with creating the adapter:

```kotlin
class TransactionsAdapter : RecyclerView.Adapter<TransactionsAdapter.ViewHolder>() {
    private var items = mutableListOf<Transaction>()
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.view_transaction, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHandler, position: Int) = Unit

    override fun getItemCount() = items.size

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val imageView: ImageView = itemView.findViewById(R.id.image)
    }
}
```

That’s how a standard adapter for RecyclerView looks like. Binding would be the next step here:

```kotlin
override fun onBindViewHolder(holder: ViewHandler, position: Int) {
    val transaction = items[position]
    when {
        transaction.isContactWithAvatar() -> {
            //avatar loading and displaying
        }
        !transaction.isContactWithAvatar() -> {
            //avatar displaying
        }
        transaction.isMerchantWithAvatar() -> {
            //avatar loading and displaying
        }
        !transaction.isMerchantWithAvatar() -> {
            //loading an image from resources 
        }
    }
}
```

We get a long list of conditions because every type of transaction has its own display logic in the adapter. It can be even more complicated if we use a separate ViewType for each type (it is also led by the adapter contract):

```kotlin
override fun getItemViewType(position: Int): Int {
    val transaction = items[position]
    return when {
        transaction.isContactWithAvatar() -> VIEW_TYPE_CONTACT_WITH_AVATAR
        !transaction.isContactWithAvatar() -> VIEW_TYPE_CONTACT_WITHOUT_AVATAR
        transaction.isMerchantWithAvatar() -> VIEW_TYPE_MERCHANT_WITH_AVATAR
        !transaction.isMerchantWithAvatar() -> VIEW_TYPE_MERCHANT_WITHOUT_AVATAR
        else -> VIEW_TYPE_UNKNOWN
    }
}
```

As we know, there might be dozens of transaction types, and we can’t use this approach to build the adapter.

## Improving the adapter

There are two basic approaches to the adapter’s extension — ViewType and delegates.

The ViewType approach can be used when the app is simple and contains only one list or a couple of screens. This is not our case because such an adapter can’t be reused. If we continue to extend the adapter and to add new ViewTypes, the adapter will constantly grow. Moreover, we’ll have to build new adapters for each screen in the app.

The delegates approach seems to be easier: we don’t need separate adapters for every screen. Four years ago Hannes Dorfmann [described the approach](http://hannesdorfmann.com/android/adapter-delegates/), and today you may easily find a library for its implementation. We’ll use [Dorfmann’s library](https://github.com/sockeqwe/AdapterDelegates).

Take a look at the simple delegate that displays ProgressBar:

```kotlin
class LoadingDelegate :
    AbsListItemAdapterDelegate<LoadingDelegate.Model, ListItem, LoadingDelegate.ViewHandler>() {
    override fun onCreateViewHolder(parent: ViewGroup): ViewHandler =
        ViewHandler(LayoutInflater.from(parent.context).inflate(R.layout.view_loading, parent, false))

    override fun isForViewType(item: ListItem, items: MutableList<ListItem>, position: Int): Boolean = item is Model

    override fun onBindViewHolder(item: Model, holder: ViewHandler, payloads: MutableList<Any>) = Unit

    data class Model(override val listId: String) : ListItem

    class ViewHandler(itemView: View) : RecyclerView.ViewHolder(itemView)
}

interface ListItem {
    val listId: String

    fun calculatePayload(oldItem: ListItem): Any? = null
}
```

We create a _ViewHolder_ in the delegate as if we did it in standard adapters. After that, we go for binding. The main difference is that each delegate has its own model which will be used to display the cell type needed. Also, each model implements interface _ListItem_ with the **listId** field and **calculatePayloads** method.

Let’s create an adapter that can display delegates:

```kotlin
class DiffAdapter(
    delegates: List<AdapterDelegate<List<ListItem>>>
) : AsyncListDifferDelegationAdapter<ListItem>(ListDiffCallback()) {
    init {
        delegates.forEach { delegate -> delegatesManager.addDelegate(delegate) }
    }

    private class ListDiffCallback<T : ListItem> : DiffUtil.ItemCallback<T>() {
        override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = 
            oldItem.listId == newItem.listId

        override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = 
            oldItem.equals(newItem)

        override fun getChangePayload(oldItem: T, newItem: T): Any? = 
            newItem.calculatePayload(oldItem)
    }
}
```

As you may have noticed, we can easily use the _ListItem_ interface within the _ListDiffCallback_ class, so that _DiffUtil_ doesn’t refresh unchanged cells and doesn’t launch extra animations. Besides, as we use _data class_ for models, equals are available out of the box. All we need to do with _DiffUtil_ is to create the right model of the delegate.

The adapter for every screen is being created by declaring a list of delegates that the screen must support:

```kotlin
private val adapter by lazy {
    DiffAdapter(
        listOf(
            EmptyDelegate(),
            ErrorDelegate(),
            LoadingDelegate(),
            LoadMoreDelegate(),
            CardDelegate()
        )
    )
}
```

## Displaying images

We remove the logic of loading and displaying images from the adapter, and we ease **onBindViewHolder**. Basically, we need to create two things — an image model and the delegate which will be able to load and display the image. Here is the example of a model where we load the image from sources:

```kotlin
interface Image : Parcelable

@Parcelize
data class ResourceImage(
    @DrawableRes val drawableRes: Int,
    @ColorRes val colorRes: Int? = null
) : Image
```

First, we build the interface Image. Then we describe a set of parameters for _ResourceImage_, that can be used to set the image. Particularly — image resource id and colors, if we want to paint it over.

After that, we move to delegate and create its interface. You can see why we need the interface Image.

```kotlin
interface ImageDisplayDelegate {

    fun suitsFor(image: Image): Boolean

    fun displayTo(image: Image, to: ImageView)

}
```

Each delegate must be able to:
* understand if it can display the image or not,
* display the image in ImageView.

Loading images from resources will look like this:

```kotlin
class ResourceImagesDisplayDelegate : ImageDisplayDelegate {

    override fun suitsFor(image: Image) = image is ResourceImage

    override fun displayTo(image: Image, to: ImageView) {
        Glide.with(to.context).clear(to)
        with(image as ResourceImage) {
            val drawable = ContextCompat.getDrawable(to.context, drawableRes)
            colorRes?.let { drawable?.setTint(ContextCompat.getColor(to.context, it)) }
            to.setImageDrawable(drawable)
        }
    }

}
```

You can see that:
* Method **suitsFor** verifies that image is _ResourceImage_,
* We set **image** in _ImageView_ within the **displayTo** method, and if **colorRes** is not null, we set tint.

It’s the simplest of all possible delegates.

## Combining delegates

It’s about time to combine all supported delegates in one place and to cut down the interface to the method **displayTo**.

```kotlin
class ImagesDisplayeDelegates : ImageDisplayer {
    protected val delegates = listOf(
        ResourceImagesDisplayDelegate(),
        UriImageDisplayDelegate(),
        LottieImageDelegate(),
        CountryImageLoader(),
        CurrencyImageDisplayDelegate(),
        BitmapImageDelegate(),
        GifResourseImageDisplayDelegate(),
        CardImagesDisplayDelegate(),
        GrayedOutImageDecoratorDisplayDelegate()
    )

    override fun displayTo(image: Image?, to: ImageView) {
        if (image != null) {
            //begin
            delegates.first { delegate -> delegate.suitsFor(image) }
                .displayTo(image, to)
            //end
        } else {
            to.setImageDrawable(null)
        }
    }
}
```

Check the highlighter line. Using the **first** method we find the first suitable delegate to display the image. If it’s not found, there will be a crash. And it’s not an error in the architecture: we intentionally use the fail-fast approach to get rid of unobvious behavior. Otherwise, if the image is not displayed, it’s hard to tell what’s causing the issue.

---

Stay tuned for the second part of the article to be released next week.