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

[Previously we published the first part](https://dev.to/ezubkov/displaying-images-in-android-app-maintainable-testable-painless-part-i-322) of this article about building maintainable and scalable system for displaying dozens of thousands of images in your Android app.

Part I contained the approach description and the delegates building instructions. The second part describes the transformation and testing processes and sums-up the results.

Please check [the first part](https://dev.to/ezubkov/displaying-images-in-android-app-maintainable-testable-painless-part-i-322) before you start reading. Let’s go.

## Making transformations
Why do we need transformations? Let’s pretend that we have a contact’s (or a merchant’s) userpic uploaded from the web. The userpic can have any form and size, whilst in Revolut app, we need to display it round-shaped at 40 х 40 dp.

![How we display avatars in Revolut app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tl63en17gb70rhz4wf96.jpeg)

To set the model and make it behave accordingly let’s take _UrlImage_ class as an example. Every image to be transformed should have relevant settings. We set it by creating _TransformableImage_ interface with transformations property:

```kotlin
@Parcelize
data class UrlImage(
    val url: String,
    @DrawableRes val placeholder: Int? = null,
    @DrawableRes val errorIcon: Int? = null,
    override val transformations: ImageTransformations? = null
) : Image, TransformableImage

class UrlImagesDisplayDelegate() : ImageDisplayDelegate {
    override fun suitsFor(image: Image) = image is UrlImage

    override fun displayTo(image: Image, to: ImageView) {
      if (image !is UrlImage) throw IllegalStateException("UrlImagesDisplayDelegate displays only UrlImages")
      Glide.with(to.context).clear(to)
      
      Glide.with(to.context)
            .load(image.url)
            .apply(
                RequestOptions()
                    .error(image.errorIcon)
                    .placeholder(image.placeholder)
                    .applyImageTransformations(to.context.resources, image)
            )
            .into(to)
    }
}
```

The class may look like this:

```kotlin
@Parcelize
data class ImageTransformations(
    val rotation: Int? = null,
    val circle: Boolean = false,
    val square: Boolean = false,
    val centerCrop: Boolean = false,
    @Dimension(unit = Dimension.DP) val radiusDp: Int? = null,
    @Dimension(unit = Dimension.DP) val widthDp: Int? = null,
    @Dimension(unit = Dimension.DP) val heightDp: Int? = null
) : Parcelable
```

To display images, we use [Glide](https://github.com/bumptech/glide). Naturally, all our transformations correspond to this library.

```kotlin
internal interface TransformableImage {
    val transformations: ImageTransformations?
  
    fun getGlideTransformsArray(resources: Resources): Array<Transformation<Bitmap>> {
        return mutableListOf<Transformation<Bitmap>>().apply {
            val widthDp = transformations?.widthDp
            val heightDp = transformations?.heightDp
            if (widthDp != null && heightDp != null) {
                add(
                    GlideScaleTransformation(
                        newWidth = UiUtils.dpToPx(resources, widthDp.toFloat()).toInt(),
                        newHeight = UiUtils.dpToPx(resources, heightDp.toFloat()).toInt()
                    )
                )
            } else {
                heightDp?.let { height ->
                    add(
                        GlideScaleTransformation
                            .withNewHeight(UiUtils.dpToPx(resources, height.toFloat()).toInt())
                    )
                }
                widthDp?.let { width ->
                    add(
                        GlideScaleTransformation
                            .withNewWidth(UiUtils.dpToPx(resources, width.toFloat()).toInt())
                    )
                }
            }

            transformations?.rotation?.let { rotation ->
                add(RotationTransformer(rotation))
            }
            if (transformations?.centerCrop == true) {
                add(CenterCrop())
            }
            if (transformations?.square == true) {
                add(SquareTransformation())
            }
            transformations?.radiusDp?.let { radius ->
                add(RoundedCorners(UiUtils.dpToPx(resources, radius.toFloat()).toInt()))
            }
            if (transformations?.circle == true) {
                add(CircleCrop())
            }
        }.toTypedArray()
    }
}
```

That’s how we create the transformations array.

To avoid extra work, mark the fields as nullable (this lets you implement only the needed transformations), and pay attention to the transformations’ order.

Imagine that we have a very large image we need to rotate, scale and crop into a circle. Let’s compare two scenarios:

```kotlin
//First
add(RotationTransformer(degrees = 90))
add(CircleCrop())
add(GlideScaleTransformation(width = 100))

//Second
add(GlideScaleTransformation(width = 100))
add(RotationTransformer(degrees = 90))
add(CircleCrop())
```

In the first one we start with image rotation, then crop it into a circle and only after that we scale it. In the second scenario, we start with scaling.

Obviously, the second one seems more reasonable, because rotating and cropping of a small image demand fewer hardware resources.

Earlier we created the array that we’ll pass to [Glide](https://github.com/bumptech/glide) when it displays an image by _URL_. Now we build an object _RequestOptions_ and pass the array to the object. If we pass an empty array, [Glide](https://github.com/bumptech/glide) will fail. So it is mandatory to add a verification.

```kotlin
val options = RequestOptions().apply {
    val transformations = image.getGlideTransformsArray(resources)
    if (transformations.isNotEmpty()) {
        transforms(*transformations)
    }
}

Glide.with(context)
    .load(url)
    .apply(options)
    .into(imageView)
```

We’re going to reuse transformations in different delegates, that’s why we can put them to the extension method **applyImageTransformations**.

```kotlin
internal fun RequestOptions.applyImageTransformations(resources: Resources, image: TransformableImage): RequestOptions = 
    apply {
        val transformations = image.getGlideTransformsArray(resources)
        if (transformations.isNotEmpty()) {
            transforms(*transformations)
        }
    }

fun getGlideRequestOptions(resources: Resources): RequestOptions = 
    RequestOptions().applyImageTransformations(resources, this)
```

Also, we add a method to the interface _TransformableImage_ — **getGlideTransformsArray**. The interface and the extension method **applyImageTransformations** are marked as internal. It helps to avoid the leak of abstraction — [Glide](https://github.com/bumptech/glide) is not a part of the public interface. It’s important if we want to change [Glide](https://github.com/bumptech/glide) to any other library.

The code looks like this:

```kotlin
Glide.with(context)
    .load(image.url)
    .apply(image.getGlideRequestOptions(resources))
    .into(imageView)
```

## Creating a delegate to display transaction
We already know how the adapter works. Let’s create a delegate to display the transaction. The basic version looks like this:

```kotlin
class ImageDelegate : BaseRecyclerViewDelegate<ImageDelegate.Model, ImageDelegate.Holder>(
    viewType = R.layout.delegate_image,
    rule = DelegateRule { _, data -> data is Model }
) {

    override fun onCreateViewHolder(parent: ViewGroup) = Holder(parent.inflate(viewType))

    override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) = Unit

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

    class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val imageView: ImageView = itemView.image
    }
}
```

To simplify the code we skip displaying the text. We can make this delegate display transactions with pictures from web or resources, with contact userpic generated from the initial letters.

We start by modifying the model.

```kotlin
data class Model(
    override val listId: String,
    val resourceId: Int,
    val url: String? = null,
    val doubleLettersImage: String? = null
) : ListItem
```

In each case, we use specific parameters gathered in one place. The image will be displayed like this:

```kotlin
override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) {
    if (data.url != null) {
        //downloading and displayint by url
    } else if (data.doubleLettersImage != null) {
        //bitmap creation from the string and displaying
    } else {
        holder.imageView.setImageResource(data.resourceId)
    }
}
```

This solution has some disadvantages:
* hard to extend;
* the order is important, and it may not be obvious which order to choose;
* the logic is inside the adapter (in the delegate).

Let’s try to use image delegates. In the model, we leave only the image to display instead of all other parameters.

```kotlin
class ImageDelegate(
    //
    private val imageDisplayer: ImageDisplayer
    //
) : BaseRecyclerViewDelegate<ImageDelegate.Model, ImageDelegate.Holder>(
    viewType = R.layout.delegate_image,
    rule = DelegateRule { _, data -> data is Model }
) {

    override fun onCreateViewHolder(parent: ViewGroup) = Holder(parent.inflate(viewType))

    override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) {
        //
        imageDisplayer.displayTo(data.image, holder.imageView)
        //
    }

    data class Model(
        override val listId: String,
        //
        val image: Image
        //
    ) : ListItem

    class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val imageView: ImageView = itemView.image
    }
}
```

The transaction list will look like this:

```kotlin
listOf(
    ImageDelegate.Model(image = ResourceImage(R.drawable.ic_no_avatar)),
    ImageDelegate.Model(image = UrlImage("url to merchant")),
    ImageDelegate.Model(image = DoubleLettersImage("EZ")),
    ImageDelegate.Model(image = UrlImage("url to user avatar")),
)
```

Its behavior becomes more obvious, and also we take the logic out of the adapter.

---

## Creating a delegate for generated images
Let’s take a particular case of creating a delegate that generates an image of two symbols. First of all, we define requirements for this delegate: it must be able to display the letters and adjust the image.

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

Hence, the model looks like this:

```kotlin
@Parcelize
data class DoubleLettersImage(
    val letters: String,
    @ColorRes val textColor: Int = Color.GRAY,
    @ColorRes val backgroundColor: Int = Color.TRANSPARENT,
    val sizeInDp: Int = 40,
    val textSizeInDp: Int = 40,
    override val transformations: ImageTransformations? = null
) : Image, TransformableImage
```

To adjust the background we use _ImageTransformations_.

Now let’s proceed to bitmap generation. For example, we can use [TextDrawable](https://github.com/amulyakhare/TextDrawable), where the image is built with _Canvas_. Then we handle the bitmap and set it in _ImageView_.

```kotlin
private fun generateBitmap(image: DoubleLettersImage, resources: Resources): Bitmap {
    return TextDrawable.builder()
        .beginConfig()
        .textColor(image.textColor)
        .height(dpToPx(resources, image.sizeInDp))
        .width(dpToPx(resources, image.sizeInDp))
        .fontSize(spToPx(resources, image.textSizeInSp))
        .useFont(defaultFont)
        .endConfig()
        .buildBitmap(image.letters, image.backgroundColor);
}
```

As we use the extension, the delegate takes only a couple of lines. Here is how it works.

The first version with basic settings is:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5mhxvtazv68nw3ftpq7q.jpeg)

At the second stage, we crop the image into a circle:

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

At the third stage, we rotate the image. So we can display the userpic icon as we need to follow the design guidelines.

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

## Creating customized transformation
Let’s say, we need to flip the image horizontally. In order to do so, we build a transformation class framework.

```kotlin
class FlipTransformation private constructor(
    private val horizontal: Boolean
) : BitmapTransformation() {
    override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap = TODO()
    
    override fun updateDiskCacheKey(messageDigest: MessageDigest) = Unit
}
```

If we use [Glide](https://github.com/bumptech/glide), the basic class is _BitmapTransformation_: [Glide](https://github.com/bumptech/glide) makes our life easier because it contains _TransformationUtils_ with methods we need. All we have to do is to add the transformation to others.

```kotlin
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
    val exifOrientation = if (horizontal) {
        ExifInterface.ORIENTATION_FLIP_HORIZONTAL
    } else {
        ExifInterface.ORIENTATION_FLIP_VERTICAL
    }
    return TransformationUtils.rotateImageExif(pool, toTransform, exifOrientation)
}
```

```kotlin
if (transformations?.centerCrop == true) {
    add(CenterCrop())
}
if (transformations?.flipHorizontal != null) {
    add(FlipTransformation(transformations?.flipHorizontal))
}
transformations?.radiusDp?.let { radius ->
    add(RoundedCorners(UiUtils.dpToPx(resources, radius.toFloat()).toInt()))
}
```

---

## Testing
One of the main reasons why we use this solution is testability.

Let’s see how the clean architecture may look like, and see how data goes to the UI layer. We use the transaction list as data.


![Clean architecture](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kydrm4l0c4uz9r23nzlh.jpeg)

It looks pretty regular. The database returns the models list, and on the repository level, we map them into models of the domain level. Then it sends them to UI level. Each mapping stage is covered with tests.

This is how the domain transaction’s model looks like:

```kotlin
data class Transaction(
    val id: String,
    val amount: Money,
    val date: DateTime,
    val type: TransactionType
)
```

It contains the transaction’s id, amount and date. How does it understand if it’s a money transfer or a purchase? Where does it take the title or URL? The answer is the _sealed class._

```kotlin
sealed class TransactionType {
    data class Transfer(
        val contactName: String,
        val contactAvatarUrl: String? = null
    ) :  TransactionType()
    
    data class CardPayment(
        val merchantName: String,
        val merchantRating: Double = 0.0,
        val merchantLogoUrl: String? = null
    ) :  TransactionType()
}
```

We see two transaction types — money transfer and purchase. Each of them has a unique set of parameters.

What is the model for the UI layer? Let’s go back to the delegate for the adapter _RecyclerView_.

```kotlin
class ImageDelegate(
    private val imageDisplayer: ImageDisplayer
) : BaseRecyclerViewDelegate<ImageDelegate.Model, ImageDelegate.Holder>(
    viewType = R.layout.delegate_image,
    rule = DelegateRule { _, data -> data is Model }
) {

    override fun onCreateViewHolder(parent: ViewGroup) = Holder(parent.inflate(viewType))

    override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) {
        imageDisplayer.displayTo(data.image, holder.imageView)
    }

    //
    data class Model(
        override val listId: String,
        val image: Image
    ) : ListItem
    //

    class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val imageView: ImageView = itemView.image
    }
}
```

Delegate model can be perfectly used as a UI model.

Here are some cases we can test only with the help of delegates.

Case 1 — Money transfer to a contact without a userpic.

```kotlin
Transaction(
    id = "some_id",
    amount = Money(100, Currency.GBP),
    date = DateTime.parse("some_date"),
    type = TransactionType.Transfer(
        contactName = "Some Name"
    )
)

//should be mapped to:

ImageDelegate.Model(
    listId = "some_id",
    image = DoubleLettersImage(
        letters = "SN",
        transformations = ImageTransformations(
            circle = true
        )
    )
)
```

We expect that if there is no URL for userpic, the model to display the initial letters is created.

Case 2 — Money transfer to a contact with the userpic.

```kotlin
Transaction(
    id = "some_id",
    amount = Money(100, Currency.GBP),
    date = DateTime.parse("some_date"),
    type = TransactionType.Transfer(
        contactName = "Some Name",
        contactAvatarUrl = "some_url"
    )
)

//should be mapped to:

ImageDelegate.Model(
    listId = "some_id",
    image = UrlImage(
        url = "some_url",
        transformations = ImageTransformations(
            circle = true
        )
    )
)
```

We expect that _UrlImage_ with a transformation will be created.

Case 3 — Purchase in a shop with the userpic.

```kotlin
Transaction(
    id = "some_id",
    amount = Money(100, Currency.GBP),
    date = DateTime.parse("some_date"),
    type = TransactionType.CardPayment(
        merchantName = "Netflix",
        merchantLogoUrl = "some_url"
    )
)

//should be mapped to:

ImageDelegate.Model(
    listId = "some_id",
    image = UrlImage(
        url = "some_url",
        transformations = ImageTransformations(
            circle = true
        )
    )
)
```

Same as the second: we expect that _UrlImage_ with a transformation will be created.

Case 4 — Purchase in a shop without a userpic.

```kotlin
Transaction(
    id = "some_id",
    amount = Money(100, Currency.GBP),
    date = DateTime.parse("some_date"),
    type = TransactionType.CardPayment(
        merchantName = "Netflix"
    )
)

//should be mapped to:

ImageDelegate.Model(
    listId = "some_id",
    image = ResourceImage(
        drawableRes = R.drawable.ic_no_avatar
    )
)
```

Here we can make an additional test: every purchase can belong to a separate category, so the icons will differ. We can also check if we map categories in the relevant icons.

## Conclusion
We benefit from using delegates.

First, we remove the logic that shouldn’t be in the adapter: it shouldn’t be responsible for the image source choice, depending on its parameters.

Secondly, we don’t depend on image loading and adjusting the method anymore. We can replace Glide with any other library at any moment.

Thirdly, we can check that image type we need is displayed, in other words, we can test the data displaying.