注: ほとんどのケースでは、Glide ライブラリを使用してアプリのビットマップを取得、デコード、表示することをおすすめします。Glide を使用すると、Android におけるビットマップなどの画像の操作に関連するこうした複雑なタスクの処理を簡素化できます。 Glide の使用方法とダウンロード方法については、GitHub の Glide リポジトリをご覧ください。
単一のビットマップをユーザー インターフェース(UI)に読み込むことは簡単ですが、多数の画像を同時に読み込む必要がある場合は事態がより複雑になります。ListView
、GridView
、ViewPager
のコンポーネントを使用する場合など、多くのケースで画面上の画像の総数(画面上にすぐにスクロールされる可能性がある画像を含みます)については、基本的に無制はありません。
子ビューが画面外へ移動したときにリサイクルすることで、このようなコンポーネントによるメモリ使用量を抑えることができます。また、ガベージ コレクタは、長期的参照は保持されないと仮定して、読み込み済みのビットマップを解放します。これについてはまったく問題ありませんが、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
が保持されている状態と保持されていない状態の両方でデバイスを回転してみてください。キャッシュを保持している場合、画像がメモリからほぼ瞬時にアクティビティを設定するため、遅延にはほとんどあるいはまったく気付かないでしょう。メモリ キャッシュにない画像がディスク キャッシュにある場合もありますが、ディスク キャッシュにもない場合は通常どおり処理されます。