ビットマップのキャッシュ保存

注: ほとんどのケースでは、Glide ライブラリを使用してアプリのビットマップを取得、デコード、表示することをおすすめします。Glide を使用すると、Android におけるビットマップなどの画像の操作に関連するこうした複雑なタスクの処理を簡素化できます。 Glide の使用方法とダウンロード方法については、GitHub の Glide リポジトリをご覧ください。

単一のビットマップをユーザー インターフェース(UI)に読み込むことは簡単ですが、多数の画像を同時に読み込む必要がある場合は事態がより複雑になります。ListViewGridViewViewPager のコンポーネントを使用する場合など、多くのケースで画面上の画像の総数(画面上にすぐにスクロールされる可能性がある画像を含みます)については、基本的に無制はありません。

子ビューが画面外へ移動したときにリサイクルすることで、このようなコンポーネントによるメモリ使用量を抑えることができます。また、ガベージ コレクタは、長期的参照は保持されないと仮定して、読み込み済みのビットマップを解放します。これについてはまったく問題ありませんが、UI をスムーズかつ高速に読み込み続けるには、画像が画面上に戻ってくるたびにその処理を行うことは避ける必要があります。こうした状況では往々にして、メモリ キャッシュとディスク キャッシュが役立ちます。これらのキャッシュを活用することで、処理済みの画像の再読み込みを各コンポーネントですばやく行うことができます。

このレッスンでは、ビットマップのメモリ キャッシュとディスク キャッシュを使用して、複数のビットマップを読み込む際の UI の応答性を改善し、スムーズに表示できるようにする方法について説明します。

メモリ キャッシュを使用する

メモリ キャッシュを使用すると、ビットマップへの高速アクセスが可能になりますが、貴重なアプリのメモリが消費されてしまいます。LruCache クラス(サポート ライブラリで API レベル 4 に戻って使用することも可能)は、ビットマップのキャッシュ保存タスクに特に適しており、最近参照したオブジェクトを強力な LinkedHashMap 参照クラス内で保持し、キャッシュが指定のサイズを超える前に、最も長い間使用されていないメンバーを削除することができます。

注: 以前の一般的なメモリ キャッシュの実装は SoftReference または WeakReference ビットマップ キャッシュでしたが、これらの実装は現在は非推奨になっています。Android 2.3(API レベル 9)以降では、ガベージ コレクタがソフト参照または弱い参照の収集を積極的に行うため、キャッシュ保存の効果がかなり薄くなっています。また、Android 3.0(API レベル 11)より前のバージョンでは、ビットマップのバッキング データがネイティブ メモリに格納されていました。ネイティブ メモリは予測可能な方法で解放されないため、アプリのメモリの上限をすぐに超えてクラッシュする可能性があります。

LruCache に適したサイズを選択するには、以下をはじめとするさまざまな要素を考慮する必要があります。

  • 他のアクティビティまたはアプリ(あるいはその両方)がどの程度メモリを使用するか。
  • 画面に同時に表示される画像の数。画面に表示するための準備をする必要がある画像の数。
  • デバイスの画面のサイズと密度。メモリ内に同じ数の画像を保持する場合、Galaxy Nexus などの xhdpi(超高密度画面)デバイスの方が Nexus S(hdpi)などのデバイスに比べて必要なキャッシュが多くなります。
  • 各ビットマップの寸法と設定、およびメモリ消費量。
  • 画像へのアクセス頻度。一部の画像へのアクセス頻度が他の画像より高いかどうか。 高い場合は、特定のアイテムをメモリ内に常に保持するか、ビットマップの各グループ用に複数の LruCache オブジェクトを用意することをおすすめします。
  • 品質と数量のバランスを取ることができるか。場合によっては、低品質のビットマップを多数保存し、高品質のバージョンを別のバックグラウンド タスクで読み込む方が便利なこともあります。

すべてのアプリに適した特定のサイズや数式はないため、デベロッパーが使用状況を分析して、適切なソリューションを用意する必要があります。サイズが小さすぎるキャッシュはメリットがなく、余分なオーバーヘッドを発生させます。また、サイズが大きすぎるキャッシュは java.lang.OutOfMemory 例外を発生させるうえ、他のアプリで使用できるメモリがほとんど残りません。

以下に、ビットマップ用の LruCache の設定例を示します。

Kotlin

private lateinit var memoryCache: LruCache<String, Bitmap>

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // Get max available VM memory, exceeding this amount will throw an
    // OutOfMemory exception. Stored in kilobytes as LruCache takes an
    // int in its constructor.
    val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()

    // Use 1/8th of the available memory for this memory cache.
    val cacheSize = maxMemory / 8

    memoryCache = object : LruCache<String, Bitmap>(cacheSize) {

        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            // The cache size will be measured in kilobytes rather than
            // number of items.
            return bitmap.byteCount / 1024
        }
    }
    ...
}

Java

private LruCache<String, Bitmap> memoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Get max available VM memory, exceeding this amount will throw an
    // OutOfMemory exception. Stored in kilobytes as LruCache takes an
    // int in its constructor.
    final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

    // Use 1/8th of the available memory for this memory cache.
    final int cacheSize = maxMemory / 8;

    memoryCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            // The cache size will be measured in kilobytes rather than
            // number of items.
            return bitmap.getByteCount() / 1024;
        }
    };
    ...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap);
    }
}

public Bitmap getBitmapFromMemCache(String key) {
    return memoryCache.get(key);
}

注: この例では、アプリのメモリの 8 分の 1 がキャッシュ用に割り当てられます。通常のデバイスや hdpi デバイスでは、最小の約 4 MB(32/8)が割り当てられます。解像度が 800x480 のデバイスで全画面表示の GridView に画像を表示すると、約 1.5 MB(800*480*4 バイト)を使用するため、最小の約 2.5 ページ分の画像がメモリにキャッシュ保存されます。

ビットマップを ImageView に読み込む場合、最初に LruCache が確認されます。エントリが見つかると、そのエントリを使用してすぐに ImageView が更新されます。エントリが見つからない場合は、画像を処理するためのバックグラウンド スレッドが生成されます。

Kotlin

fun loadBitmap(resId: Int, imageView: ImageView) {
    val imageKey: String = resId.toString()

    val bitmap: Bitmap? = getBitmapFromMemCache(imageKey)?.also {
        mImageView.setImageBitmap(it)
    } ?: run {
        mImageView.setImageResource(R.drawable.image_placeholder)
        val task = BitmapWorkerTask()
        task.execute(resId)
        null
    }
}

Java

public void loadBitmap(int resId, ImageView imageView) {
    final String imageKey = String.valueOf(resId);

    final Bitmap bitmap = getBitmapFromMemCache(imageKey);
    if (bitmap != null) {
        mImageView.setImageBitmap(bitmap);
    } else {
        mImageView.setImageResource(R.drawable.image_placeholder);
        BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
        task.execute(resId);
    }
}

エントリをメモリ キャッシュに追加するには、BitmapWorkerTask も更新する必要があります。

Kotlin

private inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
    ...
    // Decode image in background.
    override fun doInBackground(vararg params: Int?): Bitmap? {
        return params[0]?.let { imageId ->
            decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
                addBitmapToMemoryCache(imageId.toString(), bitmap)
            }
        }
    }
    ...
}

Java

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final Bitmap bitmap = decodeSampledBitmapFromResource(
                getResources(), params[0], 100, 100));
        addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
        return bitmap;
    }
    ...
}

ディスク キャッシュを使用する

メモリ キャッシュは最近表示したビットマップへのアクセスを高速化するのに便利ですが、このキャッシュに保存された画像が常に利用可能であるとは限りません。GridView などのコンポーネントにサイズの大きなデータセットが格納されると、メモリ キャッシュがすぐにいっぱいになってしまいます。アプリは通話などの別のタスクによって中断されることがあり、バックグラウンドで実行されているときに強制終了され、メモリ キャッシュが破棄される可能性もあります。ユーザーがアプリを再開した後に、アプリで各画像の処理を再度行う必要があります。

このような場合にディスク キャッシュを使用すると、処理済みのビットマップを永続化することで、メモリ キャッシュ内の画像を使用できなくなった場合の読み込み時間を短縮することができます。もちろん、ディスクからの画像の取得はメモリからの読み込みより時間がかかります。また、ディスクの読み取り時間は予測できないため、ディスクからの画像の取得はバックグラウンド スレッドで行う必要があります。

注: 画像ギャラリー アプリなど画像へのアクセス頻度が高い場合は、ContentProvider の方がキャッシュ保存した画像の格納場所として適している可能性があります。

このクラスのサンプルコードでは、Android ソースから取得した DiskLruCache の実装を使用しています。 次のサンプルコードは、既存のメモリ キャッシュに加えてディスク キャッシュを追加するように更新されています。

Kotlin

private const val DISK_CACHE_SIZE = 1024 * 1024 * 10 // 10MB
private const val DISK_CACHE_SUBDIR = "thumbnails"
...
private var diskLruCache: DiskLruCache? = null
private val diskCacheLock = ReentrantLock()
private val diskCacheLockCondition: Condition = diskCacheLock.newCondition()
private var diskCacheStarting = true

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    val cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR)
    InitDiskCacheTask().execute(cacheDir)
    ...
}

internal inner class InitDiskCacheTask : AsyncTask<File, Void, Void>() {
    override fun doInBackground(vararg params: File): Void? {
        diskCacheLock.withLock {
            val cacheDir = params[0]
            diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE)
            diskCacheStarting = false // Finished initialization
            diskCacheLockCondition.signalAll() // Wake any waiting threads
        }
        return null
    }
}

internal inner class  BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
    ...

    // Decode image in background.
    override fun doInBackground(vararg params: Int?): Bitmap? {
        val imageKey = params[0].toString()

        // Check disk cache in background thread
        return getBitmapFromDiskCache(imageKey) ?:
                // Not found in disk cache
                decodeSampledBitmapFromResource(resources, params[0], 100, 100)
                        ?.also {
                            // Add final bitmap to caches
                            addBitmapToCache(imageKey, it)
                        }
    }
}

fun addBitmapToCache(key: String, bitmap: Bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap)
    }

    // Also add to disk cache
    synchronized(diskCacheLock) {
        diskLruCache?.apply {
            if (!containsKey(key)) {
                put(key, bitmap)
            }
        }
    }
}

fun getBitmapFromDiskCache(key: String): Bitmap? =
        diskCacheLock.withLock {
            // Wait while disk cache is started from background thread
            while (diskCacheStarting) {
                try {
                    diskCacheLockCondition.await()
                } catch (e: InterruptedException) {
                }

            }
            return diskLruCache?.get(key)
        }

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
fun getDiskCacheDir(context: Context, uniqueName: String): File {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    val cachePath =
            if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
                    || !isExternalStorageRemovable()) {
                context.externalCacheDir.path
            } else {
                context.cacheDir.path
            }

    return File(cachePath + File.separator + uniqueName)
}

Java

private DiskLruCache diskLruCache;
private final Object diskCacheLock = new Object();
private boolean diskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
private static final String DISK_CACHE_SUBDIR = "thumbnails";

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
    new InitDiskCacheTask().execute(cacheDir);
    ...
}

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
    @Override
    protected Void doInBackground(File... params) {
        synchronized (diskCacheLock) {
            File cacheDir = params[0];
            diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            diskCacheStarting = false; // Finished initialization
            diskCacheLock.notifyAll(); // Wake any waiting threads
        }
        return null;
    }
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final String imageKey = String.valueOf(params[0]);

        // Check disk cache in background thread
        Bitmap bitmap = getBitmapFromDiskCache(imageKey);

        if (bitmap == null) { // Not found in disk cache
            // Process as normal
            final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources(), params[0], 100, 100));
        }

        // Add final bitmap to caches
        addBitmapToCache(imageKey, bitmap);

        return bitmap;
    }
    ...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap);
    }

    // Also add to disk cache
    synchronized (diskCacheLock) {
        if (diskLruCache != null && diskLruCache.get(key) == null) {
            diskLruCache.put(key, bitmap);
        }
    }
}

public Bitmap getBitmapFromDiskCache(String key) {
    synchronized (diskCacheLock) {
        // Wait while disk cache is started from background thread
        while (diskCacheStarting) {
            try {
                diskCacheLock.wait();
            } catch (InterruptedException e) {}
        }
        if (diskLruCache != null) {
            return diskLruCache.get(key);
        }
    }
    return null;
}

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    final String cachePath =
            Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                    !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context.getCacheDir().getPath();

    return new File(cachePath + File.separator + uniqueName);
}

注: ディスク キャッシュの初期化でもディスクの操作が必要なため、メインスレッドでは行わないでください。ただしこれは、初期化の前にキャッシュにアクセスする可能性があることを意味します。上記の実装では、この問題に対処するためにロック オブジェクトを使用しています。これにより、キャッシュが初期化されるまで、アプリはディスク キャッシュからの読み取りを行えなくなります。

メモリ キャッシュの確認が UI スレッドで行われるのに対し、ディスク キャッシュの確認はバックグラウンド スレッドで行われます。ディスクの操作が UI スレッドで行われることはありません。画像の処理が完了すると、最終的なビットマップがメモリ キャッシュとディスク キャッシュの両方に追加され、後で使用できるようになります。

構成の変更に対処する

実行時に構成の変更(画面の向きの変更など)が行われると、Android は実行中のアクティビティを破棄し、新しい構成で再開します(この動作について詳しくは、実行時の変更への対処をご覧ください)。 構成の変更が行われてもスムーズかつ高速なユーザー エクスペリエンスを提供できるよう、すべての画像の処理をやり直さずに済むようにする必要があります。

幸い、メモリ キャッシュを使用するで作成したビットマップのメモリ キャッシュがあります。このキャッシュは、setRetainInstance(true) を呼び出すことによって保持される Fragment を使用して、新しいアクティビティ インスタンスに渡すことができます。アクティビティが再作成されると、保持されている Fragment が再アタッチされ、既存のキャッシュ オブジェクトにアクセスできるようになります。これにより、画像をすばやく取得して、ImageView オブジェクトに再設定することができます。

Fragment を使用すると、構成の変更後も LruCache オブジェクトを保持することができます。以下にその例を示します。

Kotlin

private const val TAG = "RetainFragment"
...
private lateinit var mMemoryCache: LruCache<String, Bitmap>

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    val retainFragment = RetainFragment.findOrCreateRetainFragment(supportFragmentManager)
    mMemoryCache = retainFragment.retainedCache ?: run {
        LruCache<String, Bitmap>(cacheSize).also { memoryCache ->
            ... // Initialize cache here as usual
            retainFragment.retainedCache = memoryCache
        }
    }
    ...
}

class RetainFragment : Fragment() {
    var retainedCache: LruCache<String, Bitmap>? = null

    companion object {
        fun findOrCreateRetainFragment(fm: FragmentManager): RetainFragment {
            return (fm.findFragmentByTag(TAG) as? RetainFragment) ?: run {
                RetainFragment().also {
                    fm.beginTransaction().add(it, TAG).commit()
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true
    }
}

Java

private LruCache<String, Bitmap> memoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    RetainFragment retainFragment =
            RetainFragment.findOrCreateRetainFragment(getFragmentManager());
    memoryCache = retainFragment.retainedCache;
    if (memoryCache == null) {
        memoryCache = new LruCache<String, Bitmap>(cacheSize) {
            ... // Initialize cache here as usual
        }
        retainFragment.retainedCache = memoryCache;
    }
    ...
}

class RetainFragment extends Fragment {
    private static final String TAG = "RetainFragment";
    public LruCache<String, Bitmap> retainedCache;

    public RetainFragment() {}

    public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) {
        RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG);
        if (fragment == null) {
            fragment = new RetainFragment();
            fm.beginTransaction().add(fragment, TAG).commit();
        }
        return fragment;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setRetainInstance(true);
    }
}

このコードをテストするには、Fragment が保持されている状態と保持されていない状態の両方でデバイスを回転してみてください。キャッシュを保持している場合、画像がメモリからほぼ瞬時にアクティビティを設定するため、遅延にはほとんどあるいはまったく気付かないでしょう。メモリ キャッシュにない画像がディスク キャッシュにある場合もありますが、ディスク キャッシュにもない場合は通常どおり処理されます。