LRUCacheでメモリを管理する

アプリケーションでは様々なデータを扱う。
最近の一般的なモバイルアプリはネットワークを介してデータをやりとりする場合が多いが、全てのデータを逐次的に取りにいく仕組みだと、データ取得の間ユーザに待ち状態が発生してしまう。 一回のやりとりで取得するデータ量が少ない場合は、多くの場合これで問題が発生することは少ないが、アプリ内で大量のデータを使用したり、サイズの大きいデータを扱う場合には注意が必要である。
この問題を解決する方法の一つとして、データをメモリ内に保持し、ネットワークを介さずにデータを表示する方法である。 このこと自体をキャッシュすると言ったり、メモリに保持するデータをキャッシュと指すこともある。
このキャッシュをどのように管理するかという議論は遥か昔からなされており、色々なアルゴリズムが存在する。
Androidでは、この問題に対する一つの解決策としてLRUCacheというものを既に提供している。 今回は様々あるキャッシュ方法の中で、このLRUCacheについて書いく。

LRUCacheについて

LRUCacheは、Least Recently Usedの略。
どういった仕組みかを簡単に説明すると、最近アクセスしたものほど優先度を高く保持するアルゴリズムである。

f:id:ronnn:20190416233224p:plain

このような画像データがメモリ上にある場合、LRUCacheのアルゴリズムを適用すると、右にあるものほど最近使用した画像データであることが分かる。
この時、右から3番目の画像にアクセスするとどうなるか。

f:id:ronnn:20190416224633p:plain

そう、3番目の画像が今度は1番右にくるという訳である。

LRUCacheは初めに最大保持サイズを決めるので、端末全体のメモリを圧迫することもない。
最大保持サイズが決まっているということは、新しいデータがメモリに追加したい時、既にメモリが一杯であれば、すでに保持されているデータのどれかを削除しなければいけない。 では、どのデータを削除するかというと、単純明快で、LRUCacheで保持されているデータの中で、古いデータほど再利用される可能性が低いため、それのいくつかが削除される訳である。 ここで注意しておきたいのは、削除されるデータは必ずしも一つではないということである。

LRUCacheについて簡単に理解できたので、Androidではどのようにして使用するか見ていく。

AndroidでのLRUCacheの実装

Androidでは、 android.util.LruCacheandroidx.collection.LruCache が用意されている。
それぞれのドキュメントを読むと、 androidx.collection.LruCacheAPI level 12未満向けのようである。 今回は android.util.LruCache を実際に使ってみようと思う。

ローカルに画像のURLを持たせ、そのURLにアクセスして画像を取得し、その画像を RecyclerView に表示するという、表示する簡単なアプリを作ってみた。

作成した RecyclerViewAdapter は次の通り。

class LruCacheRecyclerAdapter(maxCacheSize: Int) : RecyclerView.Adapter<LruCacheRecyclerAdapter.ViewHolder>() {

    private val shuffledImageUrlList: MutableList<String> = mutableListOf()

    // 画像URLをkeyとした、BitmapのLruCache
    private val cache: LruCache<String, Bitmap> = lruCache(maxCacheSize / 8, { _, value -> value.byteCount })

    init {
        val imageUrlList: List<String> = listOf(
            // 画像URL
        )

        for (index in 0 until imageUrlList.size * 100) {
            if (index % imageUrlList.size == 0) {
                // imageUrlListの中身をシャッフルして、shuffledImageUrlListに突っ込む
                shuffledImageUrlList.addAll(imageUrlList.shuffled())
            }
        }
    }

    override fun getItemCount(): Int = shuffledImageUrlList.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = DataBindingUtil.inflate<ItemMainRecyclerBinding>(
            LayoutInflater.from(parent.context),
            R.layout.item_main_recycler,
            parent,
            false
        )
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.onBind(shuffledImageUrlList[position])
    }

    inner class ViewHolder(private val binding: ItemMainRecyclerBinding) : RecyclerView.ViewHolder(binding.root) {

        fun onBind(imageUrl: String) {
            val bitmap = cache.get(imageUrl)
            if (bitmap != null) {
                // キャッシュに画像データがあれば、それを取ってきて表示する
                binding.artworkImageView.setImageBitmap(bitmap)
            } else {
                Glide.with(binding.root.context)
                    .asBitmap()
                    .load(imageUrl)
                    .skipMemoryCache(true) // Glideによってメモリキャッシュされないようにする
                    .diskCacheStrategy(DiskCacheStrategy.NONE) // GlideはデフォルトでDiskCacheするので、しないようにする
                    .listener(object : RequestListener<Bitmap> {
                        override fun onLoadFailed(
                            e: GlideException?,
                            model: Any?,
                            target: Target<Bitmap>?,
                            isFirstResource: Boolean
                        ): Boolean {
                            return false
                        }

                        override fun onResourceReady(
                            resource: Bitmap?,
                            model: Any?,
                            target: Target<Bitmap>?,
                            dataSource: DataSource?,
                            isFirstResource: Boolean
                        ): Boolean {
                            resource ?: return false

                            // 画像をダウンロードしたら、キャッシュに追加する
                            cache.put(imageUrl, resource)
                            return false
                        }
                    })
                    .into(binding.artworkImageView)
            }
        }
    }
}

ポイント1: LruCacheのサイズを決める

LruCacheは、初めに保持するサイズを決めると先で説明したが、その基準となるのが、 LruCacheRecyclerAdapter のconstractorに渡している maxCacheSize である。
これは LruCacheRecyclerAdapterインスタンス化している Activity で次のように取得している。

val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val maxCacheSize = activityManager.memoryClass * 1024 * 1024

ActivityManager#getMemoryClass は、デバイスがそのアプリに対して使用できるメモリサイズ量を返してくれる。返ってくる値の単位はメガバイトである。 developer.android.com

この値を8で割った値を今回はLruCacheのメモリサイズに指定している。

ポイント2: LruCacheをインスタンス化する

LruCacheはMapのように、keyでvalueを管理している。
今回はkeyを画像のURL、valueをBitmapとしてLruCacheを作成した。

private val cache: LruCache<String, Bitmap> =
        lruCache(maxCacheSize / 8, { _, value -> value.byteCount })

ktxlruCache という拡張関数が用意されており、自分で LruCache を継承したクラスを作成しなくていいので便利である。 android.github.io

第一引数には、先で説明したキャッシュの保持サイズを指定する。
第二引数では、一つのBitmapのデータのサイズを返す関数を指定している。 ソースコードを読めばわかるが、これは LruCache#sizeOf をoverrideしている。 もし LruCache を継承したクラスを自作する場合には、この LruCache#sizeOf をoverrideすることを忘れないようにしたい。 デフォルトでは1を返すようになっているようだ。

ポイント3: LruCacheへのデータの保存と取り出し

LruCacheにデータを保存しておくのも、それを取り出すのも非常に簡単である。
データを保存したい場合には LruCache#put でkeyとvalueを渡すだけ。
データを取り出す場合には、 LruCache#get にkeyを渡して、keyに対応したデータが返ってくる。 もしkeyに対応したデータがない場合にはnullが返ってくる。

たったこれだけで、簡単にメモリキャッシュに対応した実装ができてしまう。
せっかくなので、LruCacheを使用したアプリと使用していないアプリの動作を比べてみる。

LruCache使用 LruCache未使用
f:id:ronnn:20190416231359g:plain f:id:ronnn:20190416235513g:plain

見比べると違いが一目瞭然だと思うが、LruCacheを使用した方はスムーズに画像を表示できているが、使用していない方は、画像が全然表示されず、スクロールも重い。
LruCacheを使用している方は、キャッシュに画像がない場合のみデータをダウンロードしてくるのに対し、使用していない方は、 RecyclerView#Adapter#onBind が呼ばれるたびに画像をダウンロードしてきているため、当然の結果である。

アプリを快適に動作させるには、キャッシュをうまく活用し、データを素早く表示することが大切だなあと思った。

おまけ

LruCacheの LruCache#putLruCache#get の処理は内部で synchronized を用いて処理が行われているため、スレッドセーフである。
LruCache#get してから何か処理をして LruCache#put したい時に、 LruCache のアトミック性を保持しておきたい場合には、その処理全体を synchronized で処理した方がいいかもしれない。

参考

youtu.be