DEV Community

Cover image for Displaying images in Android app: maintainable, testable, painless. Part II
Eugene Zubkov
Eugene Zubkov

Posted on

Displaying images in Android app: maintainable, testable, painless. Part II

Previously we published the first part 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 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

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:

@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)
    }
}
Enter fullscreen mode Exit fullscreen mode

The class may look like this:

@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
Enter fullscreen mode Exit fullscreen mode

To display images, we use Glide. Naturally, all our transformations correspond to this library.

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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

//Second
add(GlideScaleTransformation(width = 100))
add(RotationTransformer(degrees = 90))
add(CircleCrop())
Enter fullscreen mode Exit fullscreen mode

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 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 will fail. So it is mandatory to add a verification.

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

Glide.with(context)
    .load(url)
    .apply(options)
    .into(imageView)
Enter fullscreen mode Exit fullscreen mode

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

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)
Enter fullscreen mode Exit fullscreen mode

Also, we add a method to the interface TransformableImagegetGlideTransformsArray. The interface and the extension method applyImageTransformations are marked as internal. It helps to avoid the leak of abstraction — Glide is not a part of the public interface. It’s important if we want to change Glide to any other library.

The code looks like this:

Glide.with(context)
    .load(image.url)
    .apply(image.getGlideRequestOptions(resources))
    .into(imageView)
Enter fullscreen mode Exit fullscreen mode

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:

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

data class Model(
    override val listId: String,
    val resourceId: Int,
    val url: String? = null,
    val doubleLettersImage: String? = null
) : ListItem
Enter fullscreen mode Exit fullscreen mode

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

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

The transaction list will look like this:

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")),
)
Enter fullscreen mode Exit fullscreen mode

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

Hence, the model looks like this:

@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
Enter fullscreen mode Exit fullscreen mode

To adjust the background we use ImageTransformations.

Now let’s proceed to bitmap generation. For example, we can use TextDrawable, where the image is built with Canvas. Then we handle the bitmap and set it in ImageView.

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);
}
Enter fullscreen mode Exit fullscreen mode

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

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

Image description

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

Creating customized transformation

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

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
}
Enter fullscreen mode Exit fullscreen mode

If we use Glide, the basic class is BitmapTransformation: 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.

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)
}
Enter fullscreen mode Exit fullscreen mode
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()))
}
Enter fullscreen mode Exit fullscreen mode

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

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:

data class Transaction(
    val id: String,
    val amount: Money,
    val date: DateTime,
    val type: TransactionType
)
Enter fullscreen mode Exit fullscreen mode

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.

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()
}
Enter fullscreen mode Exit fullscreen mode

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.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

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.

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
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

We expect that UrlImage with a transformation will be created.

Case 3 — Purchase in a shop with the userpic.

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
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

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

Case 4 — Purchase in a shop without a userpic.

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
    )
)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)