Androidアプリの実装において、必要な時に必要なだけAPIにアクセスしていては通信量がかさんでしまいますし、応答速度などパフォーマンス上の問題も発生します。さらにはサーバーに不必要な負荷を掛けてしまい、維持費の高騰や、高負荷時の安定性などに影響が出てしまいます。
小規模なアプリではあまり気にしなくてもよいところですが、規模が大きくなるにつれて、通信量を如何に削減するか、という問題に向き合う必要が出てきます。
ここでは、Androidアプリで実装するAPIキャッシュについて考えてみようと思います。
多くの場合、通信ライブラリにキャッシュの仕組みが組み込まれているので、そちらを利用すれば十分かもしれません。またそれ以外にも、ViewModelでStateFlowに必要なデータを保持している、などの形でキャッシングは行われているので、別途キャッシュの仕組みを作る必要のない場合も多くあると思います。
ここではそれ以外にAPI単位でのキャッシュが必要な場合の実装方法について考えてみます。
Mapを使ったキャッシュ
一番シンプルなキャッシュは、Mapを使った方法です。
private val cache: MutableMap<String, Any?> =
Collections.synchronizedMap(mutableMapOf())
suspend fun foo(bar: String): Result<String> {
val key = "foo:$bar"
val cached = cache[key]
if (cached != null) {
return Result.success(cached as String)
}
val result = fooActual(bar)
result.getOrNull()?.let {
cache[key] = it
}
return result
}
APIの種別の引数の情報を一つのStringに詰め込んでそれをキャッシュのkeyとして利用しています。こうしておけば、一度コールした後はキャッシュした値が返るようになり、実際のAPIアクセスは1回だけに制限することができます。
ただ、これで要求を満たせる状況はあまり無いでしょう。
容量の制限をしていないため、使い方によっては延々とキャッシュが貯まっていってしまう場合もありますし、多くの場合は、キャッシュに有効期限を設定したいはずです。
LruCacheを使った容量制限付きキャッシュ
まずは容量制限を考えてみましょう。Androidの場合、LruCacheがJetpackに用意されていますのでこれを利用するのが簡単です。LruCacheのLruはLeast Recently Usedの頭文字で、容量がいっぱいになった時に、最後に使った(利用が最も古い)データを破棄する構造のキャッシュです。
Androidのクラスが使えない環境でも、LruCacheは比較的容易に実装できます。
LruCacheの実装は小さいのでざっと見てみましょう。内部ではLinkedHashMapをaccessOrder=trueで使っています。LinkedHashMapは通常、最後に追加したものが末尾にくるように内部順序を持っていますが、accessOrder=trueにすると、getアクセスしたデータを内部順序の末尾に移動させるようになります。そのため、容量がいっぱいになった時、容量に収まるまで先頭から順に削除する処理を入れるだけでLruCacheを実装できます。
LruCacheを使って容量制限を行う場合、以下のような実装になるかと思います。
private val cache: LruCache<String, Any> = lruCache(maxSize = 128)
suspend fun foo(): Result<String> {
val key = "foo"
val cached = cache[key]
if (cached != null) {
return Result.success(cached as String)
}
val result = fooActual()
result.getOrNull()?.let {
cache.put(key, it)
}
return result
}
オブジェクトの消費メモリサイズを計算するのは難しいので、割り切って個数でのみ制限をかけています。上記の場合、キャッシュエントリーが128個を超えると、一番古いデータから削除されるようになります。
何らかの形でデータごとのサイズを計算できる場合は、以下のようにsizeOfでサイズを返すラムダを提供することで、それに基づいてサイズ計算を行うこともできます。
private val cache: LruCache<String, Any> = lruCache(
maxSize = 128,
sizeOf = { key, value ->
// TODO
}
)
寿命指定付きキャッシュ
続いては、キャッシュに寿命を設定できるようにしてみましょう。
APIから取得して、1分間はキャッシュを使うが、それを超えたら、キャッシュを捨てて、APIから再取得する。のよな用途ですね。ここまで実現できるとかなり実用的な動作になってきます。
寿命を設けるのですから、キャッシュには作成時刻とその寿命情報を持たせる必要がありますので、以下のようなデータクラスを用意しましょう。
internal data class CacheEntry<T>(
val value: T?,
val lifetime: Duration,
val createTime: Long = System.currentTimeMillis(),
) {
val expiry: Long = createTime + lifetime.inWholeMilliseconds
fun isExpired(
lifetime: Duration = this.lifetime,
): Boolean {
val age = System.currentTimeMillis() - createTime
return age < 0 || lifetime.inWholeMilliseconds <= age
}
}
特にユーザー端末で動作するソフトウェアでは、端末時刻の変更が行われる可能性も考慮する必要があります。作成時刻+キャッシュの寿命の終了時刻だけで判定していると、大幅に端末時刻が変更された場合に、ずっと残り続けてしまう問題が発生します。
LruCacheに上記のデータクラスでラップしたデータを格納するようにして、寿命を指定したput/getは以下のように実装します。
getにもlifetimeを指定できるようにしているので、比較的新しい情報ならAPIより優先して利用し、API失敗時には多少古い場合も一定期間内なら採用するみたいな利用ができます。
private val cache: LruCache<String, CacheEntry<*>> = LruCache(maxCacheSize)
suspend fun <T : Any> put(
key: String,
value: T,
lifetime: Duration,
) {
mutex.withLock {
cache.put(key, CacheEntry(value, lifetime))
}
}
suspend fun <T : Any> get(
key: String,
lifetime: Duration?,
): T? {
mutex.withLock {
@Suppress("UNCHECKED_CAST")
val target: CacheEntry<T> = cache.get(key) as? CacheEntry<T> ?: return null
return if (target.isExpired()) {
cache.remove(key)
null
} else if (lifetime != null && target.isExpired(lifetime)) {
null
} else {
target.value
}
}
}
寿命が切れたキャッシュを削除するGC機能付きキャッシュ
寿命が設定できるようになったら概ねそれで十分ではありますが、さらに言えば、寿命が切れてもう使わないデータに関しては、できるだけ速くキャッシュから削除して、メモリ消費を抑えたいところです。
寿命が切れたキャッシュを削除するGC機能を考えてみます。
最短の寿命が来るまで待機しておき、寿命が来たエントリーを削除、をキャッシュが空になるまで繰り返すループを組んでおきます。新しい要素が追加され場合、最短寿命が変化するかもしれないので、channelで通知を受けとってループを回すようにしておきます。
private suspend fun startGarbageCollectionIfNeed() {
if (gcJob != null) {
channel.send(Unit)
return
}
gcJob = scope.launch {
try {
garbageCollectionLoop()
} finally {
gcJob = null
}
}
}
private suspend fun garbageCollectionLoop() {
while (cache.size() != 0) {
val duration = mutex.withLock { calculateWaitDuration(currentTimeMillis()) }
withTimeoutOrNull(duration) { channel.receive() }
mutex.withLock {
cache.snapshot().forEach {
if (it.value.isExpired()) {
cache.remove(it.key)
}
}
}
}
}
private fun calculateWaitDuration(
currentTime: Long,
): Long {
val mostRecentExpireTime = cache.snapshot().values
.minOfOrNull { if (it.createTime > currentTime) currentTime else it.expiry }
?: return MIN_GC_INTERVAL
return maxOf(MIN_GC_INTERVAL, mostRecentExpireTime - currentTime)
}
全体を走査しているので、キャッシュのエントリーが多くなると重くなるし、節約できるリソースより多くのリソースを使ってしまっては元も子もないので、一定容量を超えたときにだけ実行するでも十分かもしれないです。