Caching made simple on Android

Developing for the next billion users isn’t just about making your app as small as possible but also requires us to (re-)think our network connectivity.

43% of the world have access to 4G — The Inclusive Internet Index: Bridging digital divides

With only 43% of the world having access to a 4G signal it’s easy to see why Google is pushing developers to optimise networking with their Connectivity for billions guidance.

An offline-first architecture initially tries to fetch data from local storage and, failing that, requests the data from the network. After being retrieved from the network, the data is cached locally for future retrieval.

The guidelines talk about deduplicating network requests with few instructions on how to achieve this other than a brief mention to use SQLite, SharedPreferences and DiskLruCache.

So, how do we go about achieving a caching mechanism where we can easily store data in memory and on disk whilst loading a cache miss from the network.

There was an excellent talk, Composable Caching in Swift by Brandon Kase, at iOSCon 2017 that covered this very subject. In this article I’m going to show how we can implement this on Android, allowing us to perform caching while following good coding principles such as the single responsibility principle.

At its simplest, a cache is nothing more than a key-value store with a getter and setter. Using Kotlin’s coroutines, we can load the data in the background and return it sometime in the future; Deferred is very similar to Java’s Future.

interface Cache<Key : Any, Value : Any> {
fun get(key: Key): Deferred<Value?>
fun set(key: Key, value: Value): Deferred<Unit>
}

A simplified example for caching in SharedPreferences might look like the following:

class SharedPrefsCache(private val sharedPreferences:
SharedPreferences) : Cache<String, String> {
override fun get(key: String): Deferred<String?> {
return async(CommonPool) {
sharedPreferences
.getString(key, null)
}
}

override fun set(key: String, value: String): Deferred<Unit> {
return async(CommonPool) {
sharedPreferences
.edit().let {
it.putString(key, value)
it.apply()
}
}
}
}

The real power with caches comes when we combine two caches but still keep the same interface. We want to try and fetch data from the first cache and, failing that, request the data from the second cache.

In code this might look like the following:

fun compose(b: Cache<Key, Value>): Cache<Key, Value> {
return object : Cache<Key, Value> {
override fun get(key: Key): Deferred<Value?> {
return async(CommonPool) {
this
@Cache.get(key).await() ?: let {
b.get(key).await()?.apply {
this
@Cache.set(key, this).await()
}
}
}
}

override fun set(key: Key, value: Value): Deferred<Unit> {
return async(CommonPool) {
listOf(this@Cache.set(key, value),
b.set(key, value)).forEach { it.await() }
}
}
}
}

We can now create a memory cache who’s single responsibility is to store data in memory, and a disk cache who’s single responsibility is to store data on disk and combine these with one line into a new cache type.

val composedCache = memoryCache.compose(diskCache)

Networking can also be modelled as a cache, for instance, given an id for a news article (the key) you retrieve that article in get and no-op for set. The above example becomes:

val cache = memoryCache.compose(diskCache).compose(networkCache)

Of course, in reality, the data type required in each layer doesn’t necessarily match. Indeed even our back-end services often return data that makes no sense for our UIs. What we typically do is use mappers to convert from one data type to another. As with composing, we can apply the same principle of returning a new cache type to include these mappers as part of our caches.

In code this might look like the following:

fun <MappedValue : Any> mapValues(transform: (Value) -> MappedValue, inverseTransform: (MappedValue) -> Value):
Cache<Key, MappedValue> {
return object : Cache<Key, MappedValue> {
override fun get(key: Key): Deferred<MappedValue?> {
return async(CommonPool) {
this
@Cache.cache.get(key).await()?.run(transform)
}
}

override fun set(key: Key, value: MappedValue): Deferred<Unit> {
return async(CommonPool) {
this
@Cache.set(key, inverseTransform(value)).await()
}
}
}
}

A common example for mapping values would be to serialize data to string. Kotlin’s serialization makes this trivial to implement by adding the @Serializable annotation to your data class:

fun transform(value: Value): String {
return JSON.stringify(Value::class::serializer, value)
}
fun inverseTransform(mappedValue: String): Value {
return JSON.parse(Value::class::serializer, mappedValue)
}

Building a secure app means we should be protecting ours and the users’ data when it is stored on disk. Encryption transform and inverseTransform functions can be provided to turn what is usually something very complex into a one-liner.

Keys can be similarly mapped with fun <MappedKey : Any> mapKeys(transform: (MappedKey) -> Key): Cache<MappedKey, Value>:

Network requests, just as with saving data to disk, can take time. Coroutines ensure these tasks are already run on a background thread, however if two requests come in for the same data at the same time then currently both will hit the network. If there is a request to our cache in flight then we want to return it.

fun reuseInflight(): Cache<Key, Value> {
return object : Cache<Key, Value> {

val map = mutableMapOf<Key, Deferred<Value?>>()

override fun get(key: Key): Deferred<Value?> {
return map.get(key) ?: this@Cache.get(key).apply {
map
.set(key, this)

async(CommonPool) {
// free up map when job is completed regardless of success or failure
join()
map.remove(key)
}
}
}


}
}

Now your network cache has some smarts and you’ve not polluted your network code:

val smartCache = networkCache.reuseInflight()

This can also be applied at any caching level, whether it be an implementation of a network cache or a composed cache.

Hopefully this has given you an idea of how simple it can be to implement a powerful cache in Android. To make your life even simpler this has been released as an open-source library on GitHub. Please check it out.

I also presented on this at droidcon London 2017.

Caching made simple #buildbetterapps