Android Design Pattern - Build a SOLID Image Loader

Develop a image loader guided by design patterns

Guowei Lv

6 minute read

SOLID Principles and Design Patterns play an important role in Android development.

Lets take a look at how to design and implement an image loader step by step. This example comes from the book Android Source Code Design Patterns - Analysis and Practice. I also rewrote all the code from Java to Kotlin and made some bug fixes.

Step 1: Make it work

Requirement: Our first version of image loader will just use in-memory cache to cache images loaded from the Internet, we will ignore cache validation for now.

I prefer to write working code first before diving too deep into advanced design patterns. So let’s put everything together.

package com.example.lv.imageloader

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.Looper
import android.util.LruCache
import android.widget.ImageView
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

object ImageLoader {
    private var imageCache: LruCache<String, Bitmap>
    private var executorService: ExecutorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
    private val uiHandler: Handler = Handler(Looper.getMainLooper())

    init {
        val maxMemory: Long = Runtime.getRuntime().maxMemory() / 1024
        val cacheSize: Int = (maxMemory / 4).toInt()

        imageCache = object : LruCache<String, Bitmap>(cacheSize) {
            override fun sizeOf(key: String?, bitmap: Bitmap?): Int {
                return (bitmap?.rowBytes ?: 0) * (bitmap?.height ?: 0) / 1024
            }
        }
    }

    fun displayImage(url: String, imageView: ImageView) {
        val cached = imageCache.get(url)
        if (cached != null) {
            updateImageView(imageView, cached)
            return
        }

        imageView.tag = url
        executorService.submit {
            val bitmap: Bitmap? = downloadImage(url)
            if (bitmap != null) {
                if (imageView.tag == url) {
                    updateImageView(imageView, bitmap)
                }
                imageCache.put(url, bitmap)
            }
        }
    }

    private fun updateImageView(imageView: ImageView, bitmap: Bitmap) {
        uiHandler.post {
            imageView.setImageBitmap(bitmap)
        }
    }

    private fun downloadImage(url: String): Bitmap? {
        var bitmap: Bitmap? = null
        try {
            val url = URL(url)
            val conn: HttpURLConnection = url.openConnection() as HttpURLConnection
            bitmap = BitmapFactory.decodeStream(conn.inputStream)
            conn.disconnect()
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return bitmap
    }
}

The ImageLoader class only has one public method, displayImage. It is very easy to use and understand though it has some potential problems. This class is clearly doing several things: download images, display images, image caching and overall logic that manipulates the previous tasks.

Based on the Simple Responsibility Principle, this is not good. But at this moment, we still have too little information to do the refactoring confidently.

Step 2: Double Caches

Requirement: Add a disk cache so that when loading images, first use the in-memory cache, if not found then go the disk cache, if still not found then go the Internet.

Now we have the new requirement at hand, we have a better idea what to do next.

First, let’s create a interface for all kinds of caches.

interface ImageCache {
    fun put(url: String, bitmap: Bitmap)
    fun get(url: String): Bitmap?
    fun clear()
}

Notice that we also add a method to clear the cache.

Now we can implement a DiskCache that cache downloaded images into the cache directory on Android.

class DiskCache(context: Context) : ImageCache {
    private var cache: DiskLruCache = DiskLruCache.open(context.cacheDir, 1, 1, 10 * 1024 * 1024)

    override fun get(url: String): Bitmap? {
        val key = url.md5()
        val snapshot: Snapshot? = cache.get(key)
        return if (snapshot != null) {
            val inputStream: InputStream = snapshot.getInputStream(0)
            val buffIn = BufferedInputStream(inputStream, 8 * 1024)
            BitmapFactory.decodeStream(buffIn)
        } else {
            null
        }
    }

    override fun put(url: String, bitmap: Bitmap) {
        val key: String = url.md5()
        var editor: DiskLruCache.Editor? = null
        try {
            editor = cache.edit(key)
            if (editor == null) {
                return
            }
            if (writeBitmapToFile(bitmap, editor)) {
                cache.flush()
                editor.commit()
            } else {
                editor.abort()
            }
        } catch (e: IOException) {
            try {
                editor?.abort()
            } catch (ignored: IOException) {
            }
        }
    }

    override fun clear() {
        cache.delete()
    }

    private fun writeBitmapToFile(bitmap: Bitmap, editor: DiskLruCache.Editor): Boolean {
        var out: OutputStream? = null
        try {
            out = BufferedOutputStream(editor.newOutputStream(0), 8 * 1024)
            return bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
        } finally {
            out?.close()
        }
    }
}

We uses the DiskLruCache by Jake Wharton to do the heavy lifting for us internally.

There are some changes that have to be made to the MemoryCache to be compliant with the ImageCache interface.

class MemoryCache : ImageCache {
    private val cache: LruCache<String, Bitmap>
    init {
        val maxMemory: Long = Runtime.getRuntime().maxMemory() / 1024
        val cacheSize: Int = (maxMemory / 4).toInt()

        cache = object : LruCache<String, Bitmap>(cacheSize) {
            override fun sizeOf(key: String?, bitmap: Bitmap?): Int {
                return (bitmap?.rowBytes ?: 0) * (bitmap?.height ?: 0) / 1024
            }
        }
    }

    override fun put(url: String, bitmap: Bitmap) {
        cache.put(url, bitmap)
    }

    override fun get(url: String): Bitmap? {
        return cache.get(url)
    }

    override fun clear() {
        cache.evictAll()
    }
}

Now comes the interesting part: implement a DoubleCache that uses both MemoryCache and DiskCache.

class DoubleCache(context: Context) : ImageCache {

    private val memCache = MemoryCache()
    private val diskCache = DiskCache(context)

    override fun get(url: String): Bitmap? {
        return memCache.get(url) ?: diskCache.get(url)
    }

    override fun put(url: String, bitmap: Bitmap) {
        memCache.put(url, bitmap)
        diskCache.put(url, bitmap)
    }

    override fun clear() {
        memCache.clear()
        diskCache.clear()
    }
}

The new ImageLoader now becomes:

object ImageLoader2 {
    private lateinit var cache: ImageCache
    private var executorService: ExecutorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
    private val uiHandler: Handler = Handler(Looper.getMainLooper())

    fun setCache(cache: ImageCache) {
        this.cache = cache
    }

    fun displayImage(url: String, imageView: ImageView) {
        val cached = cache.get(url)
        if (cached != null) {
            updateImageView(imageView, cached)
            return
        }
        imageView.tag = url
        executorService.submit {
            val bitmap: Bitmap? = downloadImage(url)
            if (bitmap != null) {
                if (imageView.tag == url) {
                    updateImageView(imageView, bitmap)
                }
                cache.put(url, bitmap)
            }
        }
    }

    fun clearCache() {
        this.cache.clear()
    }

    private fun updateImageView(imageView: ImageView, bitmap: Bitmap) {
        uiHandler.post {
            imageView.setImageBitmap(bitmap)
        }
    }

    private fun downloadImage(url: String): Bitmap? {
        var bitmap: Bitmap? = null
        try {
            val url = URL(url)
            val conn: HttpURLConnection = url.openConnection() as HttpURLConnection
            bitmap = BitmapFactory.decodeStream(conn.inputStream)
            conn.disconnect()
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return bitmap
    }
}

I also wrote a demo client code to use our ImageLoader2:

class MainActivity : AppCompatActivity() {
    lateinit var imageView: ImageView
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        imageView = findViewById(R.id.imageView)
        ImageLoader2.setCache(DoubleCache(applicationContext))
    }

    override fun onResume() {
        super.onResume()
        ImageLoader2.displayImage("https://picsum.photos/200/300", imageView)
    }

    override fun onDestroy() {
        super.onDestroy()
        ImageLoader2.clearCache()
    }
}

SOLID Principles

Now let’s look at the SOLID principles and how our new ImageLoader follows it.

Single Responsibility Principle

Now that we have ImageCache that only handle image caches.

Open Close Principle

If now we need to add new types of caches, we just need to write new code (implement another ImageCache) instead of modifying existing ImageLoader class.

Liskov Substitution Principle

All the ImageCache can be interchangeably used in the ImageLoader class.

Interface Segregation Principle

The ImageCache has only 3 relevant interface methods that the ImageLoader needs to know.

Dependency Inversion Principle

The ImageLoader now depends on the abstract ImageCache interface. And the different concrete image cache implementations also only depends on the ImageCache interface. And there is no dependency between the ImageLoader class and all ImageCacheimplementations.

Conclusion

This is by no means a production ready library, but the aim is to how to use SOLID Principles to guide our design.

comments powered by Disqus