LRUCacheでメモリを管理する
アプリケーションでは様々なデータを扱う。
最近の一般的なモバイルアプリはネットワークを介してデータをやりとりする場合が多いが、全てのデータを逐次的に取りにいく仕組みだと、データ取得の間ユーザに待ち状態が発生してしまう。
一回のやりとりで取得するデータ量が少ない場合は、多くの場合これで問題が発生することは少ないが、アプリ内で大量のデータを使用したり、サイズの大きいデータを扱う場合には注意が必要である。
この問題を解決する方法の一つとして、データをメモリ内に保持し、ネットワークを介さずにデータを表示する方法である。
このこと自体をキャッシュすると言ったり、メモリに保持するデータをキャッシュと指すこともある。
このキャッシュをどのように管理するかという議論は遥か昔からなされており、色々なアルゴリズムが存在する。
Androidでは、この問題に対する一つの解決策としてLRUCacheというものを既に提供している。
今回は様々あるキャッシュ方法の中で、このLRUCacheについて書いく。
LRUCacheについて
LRUCacheは、Least Recently Usedの略。
どういった仕組みかを簡単に説明すると、最近アクセスしたものほど優先度を高く保持するアルゴリズムである。
このような画像データがメモリ上にある場合、LRUCacheのアルゴリズムを適用すると、右にあるものほど最近使用した画像データであることが分かる。
この時、右から3番目の画像にアクセスするとどうなるか。
そう、3番目の画像が今度は1番右にくるという訳である。
LRUCacheは初めに最大保持サイズを決めるので、端末全体のメモリを圧迫することもない。
最大保持サイズが決まっているということは、新しいデータがメモリに追加したい時、既にメモリが一杯であれば、すでに保持されているデータのどれかを削除しなければいけない。
では、どのデータを削除するかというと、単純明快で、LRUCacheで保持されているデータの中で、古いデータほど再利用される可能性が低いため、それのいくつかが削除される訳である。
ここで注意しておきたいのは、削除されるデータは必ずしも一つではないということである。
LRUCacheについて簡単に理解できたので、Androidではどのようにして使用するか見ていく。
AndroidでのLRUCacheの実装
Androidでは、 android.util.LruCache
や androidx.collection.LruCache
が用意されている。
それぞれのドキュメントを読むと、 androidx.collection.LruCache
はAPI level 12未満向けのようである。
今回は android.util.LruCache
を実際に使ってみようと思う。
ローカルに画像のURLを持たせ、そのURLにアクセスして画像を取得し、その画像を RecyclerView
に表示するという、表示する簡単なアプリを作ってみた。
作成した RecyclerView
の Adapter
は次の通り。
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 })
ktxに lruCache
という拡張関数が用意されており、自分で 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未使用 |
---|---|
見比べると違いが一目瞭然だと思うが、LruCacheを使用した方はスムーズに画像を表示できているが、使用していない方は、画像が全然表示されず、スクロールも重い。
LruCacheを使用している方は、キャッシュに画像がない場合のみデータをダウンロードしてくるのに対し、使用していない方は、 RecyclerView#Adapter#onBind
が呼ばれるたびに画像をダウンロードしてきているため、当然の結果である。
アプリを快適に動作させるには、キャッシュをうまく活用し、データを素早く表示することが大切だなあと思った。
おまけ
LruCacheの LruCache#put
と LruCache#get
の処理は内部で synchronized
を用いて処理が行われているため、スレッドセーフである。
LruCache#get
してから何か処理をして LruCache#put
したい時に、 LruCache
のアトミック性を保持しておきたい場合には、その処理全体を synchronized
で処理した方がいいかもしれない。