2012年8月23日木曜日

Android Bitmap をキャッシュする

Caching Bitmaps
に補足をつけて解説しています。

前回のバックグラウンドで Bitmap を処理するで、最後に(でもキャッシュは、、、?)と書きました。

そう!キャッシュ!キャッシュ大事。

前回までの段階でもまだ ListView で使うには問題が残っています。
既にタスクが走り終わって ImageView に画像がセットされている行をいったんスクロールアウトし、再度スクロールして画面に表示すると、またタスクが走ってしまいます。

スクロールするたびに読み込み状態になるのはユーザーとしてはうれしくないですよね。

そこでキャッシュを使って、一旦読み込んだ画像が再度必要になったときに利用できるようにします。
キャッシュとしてはメモリキャッシュとディスクキャッシュを利用することができます。


■ メモリキャッシュ

メモリキャッシュの利点は読み込みが速いこと、欠点はメモリを消費することです。

Android 3.1 からメモリキャッシュ用の LruCache というクラスが追加されました(Support Library にもバックポートされています)。LruCache は Bitmap をキャッシュするのに適しています。LinkedHashMap を使って最近参照されたオブジェクトを保持していて、設定されたサイズよりもキャッシュが大きくなるときには一番最後に参照されたオブジェクトを解放します。

-----------
以前は、SoftReferenceWeakReference を使って Bitmap をキャッシュする実装がよくありましたが、この方法はオススメしません。Android 2.3 (API Level 9) からガーベージコレクターの振る舞いが変わって、より積極的に soft/weak references を回収するようになり、あまり効果的ではなくなったからです。加えて、Android 3.0 (API Level 11) 以前では、Bitmap のデータはネイティブメモリに保存され、予測可能な方法で解放されず、潜在的にメモリ制限を超えてクラッシュする可能性があります。

(たぶん、ガーベージコレクターの振る舞いが変わって、頻繁に回収されるのであまり意味ない → WeakReference、ネイティブメモリがうまく解放されずクラッシュ(この辺り?「Bitmap を SoftReference で管理すべきではない - ろじかるんるんものがたり - 」) → SoftReference だと思う)
-----------

LruCache のサイズを決める基準はいろいろあるのですが、

・アプリに割り当てられているヒープサイズ
・一度に読み込む画像数
・画面のサイズとピクセル密度
・画像のサイズとタイプ(ARGB_8888とか)
・どのくらい頻繁に画像がアクセスされるか
・量と質(画像の解像度)ならどちらをとるか

あたりです。
全てのアプリに共通の最適解などないので、いろいろ試してみるのがいいです。 小さすぎると overhead が大きくなるし、大きすぎると OutOfMemory になります。

例えば、アプリに割り当てられているヒープサイズを基準にすると次のようになります。

注意: ActivityManager の getMemoryClass() は API Level 5 からです。 1.6(API Level 4)から Support Package の LruCache を使う場合は Lazy Loading でクラスを分けるようにしましょう!

@Override protected void onCreate(Bundle savedInstanceState) { final int memClass = ((ActivityManager)getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass(); // Use 1/8th of the available memory for this memory cache. final int cacheSize = 1024 * 1024 * memClass / 8; mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { // The cache size will be measured in bytes rather than number // of items. return bitmap.getByteCount(); } }; ImageProcessor processor = new ImageProcessor(this, mMemoryCache); }

public ImageProcessor(Context context, LruCache<String, Bitmap> memoryCache) { // Memory Cache mMemoryCache = memoryCache; } private LruCache<String, Bitmap> mMemoryCache; public void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } } public Bitmap getBitmapFromMemCache(String key) { return mMemoryCache.get(key); }

画像をバックグラウンドで読み込む AsyncTask を走らせる前に、キャッシュをチェックして、もしキャッシュに画像があればそれを使ってタスクは走らせません。

public void loadBitmap(Context context, String filePath, ImageView imageView, Bitmap loadingBitmap) { // キャッシュにあるかチェック final Bitmap bitmap = getBitmapFromMemCache(filePath); if (bitmap != null) { imageView.setImageBitmap(bitmap); } else { // 同じタスクが走っていないか、同じ ImageView で古いタスクが走っていないかチェック if (ImageProcessor.cancelPotentialWork(filePath, imageView)) { final BitmapWorkerTask task = new BitmapWorkerTask(imageView); final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), loadingBitmap, task); imageView.setImageDrawable(asyncDrawable); task.execute(filePath); } } }

AsyncTask で画像の読み込みが終わったときにキャッシュに追加するのも忘れずにいれておきます。

class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { ... // バックグラウンドで画像をデコード @Override protected Bitmap doInBackground(String... params) { mFilePath = params[0]; final Bitmap bitmap = decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight); if (bitmap != null) { addBitmapToMemoryCache(mFilePath, bitmap); } return bitmap; } ... }


■ DiskCache を使う

メモリキャッシュの欠点は、GridView などコンポーネントの数が多い場合すぐにいっぱいになってしまうこと、電話などでアプリが割り込まれ、バックグラウンドにいる間にメモリキャッシュが破棄された場合に再度読み込みをしないといけないことです。

ディスクキャッシュを使えば、メモリキャッシュの欠点を補えます。読み込んだ画像を永続化しておくことで、メモリキャッシュよりは読み込みに時間がかかりますが、ネットワークなどから再取得する回数を減らすことができます。

ディスクキャッシュは読み込みに時間がかかるのでバックグラウンド行います。

ギャラリーアプリのように頻繁にアクセスされる場合、ContentProvider がより適切なキャッシュの保存先です。

サンプルの BitmapFun に含まれる DiskLruCache はシンプルな実装ですが、もっと堅牢でオススメなのが Android 4.0 のソースコードに含まれる DiskLruCache です。ただし、このクラスを以前のバージョンの Android で利用するなら、かなり単純化する必要があります。

サンプルの BitmapFun の DiskLruCache はこんな感じで使います。

public ImageProcessor(Context context, LruCache<String, Bitmap> memoryCache) { // Memory Cache mMemoryCache = memoryCache; // Disk Cache File cacheDir = getCacheDir(context, DISK_CACHE_SUBDIR); mDiskCache = DiskLruCache.openCache(context, cacheDir, DISK_CACHE_SIZE); } /** * Disk Cache */ private DiskLruCache mDiskCache; private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB private static final String DISK_CACHE_SUBDIR = "thumbnails"; public static File getCacheDir(Context context, String uniqueName) { // 外部ストレージが使える場合はそっちのディレクトリを、そうでない場合は内部のディレクトリを使う final String cachePath = Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED || !Environment.isExternalStorageRemovable() ? context.getExternalCacheDir().getPath() : context .getCacheDir().getPath(); return new File(cachePath + File.separator + uniqueName); } public void addBitmapToCache(String key, Bitmap bitmap) { // Add to memory cache as before if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } // Also add to disk cache if (!mDiskCache.containsKey(key)) { mDiskCache.put(key, bitmap); } } public Bitmap getBitmapFromDiskCache(String key) { return mDiskCache.get(key); } class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { ... // バックグラウンドで画像をデコード @Override protected Bitmap doInBackground(String... params) { mFilePath = params[0]; // ディスクキャッシュにあるかチェック Bitmap bitmap = getBitmapFromDiskCache(mFilePath); if (bitmap == null) { bitmap = decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight); } if (bitmap != null) { addBitmapToCache(mFilePath, bitmap); } return bitmap; } ... }


■ コンフィグレーションの変化に対処する

画面回転などコンフィグレーションが起こると Activity が再生成されてしまいます。このときメモリキャッシュも一緒に破棄されてしまってはこまるため、メモリキャッシュの保持先を Fragment にします。setRetainInstance(true) がセットされた Fragment はコンフィグレーションの変化時にも再生成されないため、これを利用します。

public class RetainFragment extends Fragment { private static final String TAG = "RetainFragment"; private Object mObject; public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) { RetainFragment mRetainFragment = (RetainFragment) fm.findFragmentByTag(TAG); if (mRetainFragment == null) { mRetainFragment = new RetainFragment(); fm.beginTransaction().add(mRetainFragment, TAG).commit(); } return mRetainFragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setRetainInstance(true); } public void setObject(Object object) { mObject = object; } public Object getObject() { return mObject; } }

@Override protected void onCreate(Bundle savedInstanceState) { RetainFragment mRetainFragment = RetainFragment.findOrCreateRetainFragment(getFragmentManager()); LruCache<String, Bitmap> memoryCache = (LruCache<String, Bitmap>) mRetainFragment.getObject(); if (memoryCache == null) { // Memory Cache final int memClass = ((ActivityManager) getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass(); // Use 1/8th of the available memory for this memory cache. final int cacheSize = 1024 * 1024 * memClass / 8; memoryCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { // The cache size will be measured in bytes rather than number // of items. return bitmap.getByteCount(); } }; mRetainFragment.setObject(memoryCache); } ImageProcessor processor = new ImageProcessor(this, memoryCache); }

これでキャッシュも含めて画像の非同期読み込みができました!

(DiskLruCache のなかみは、、、?)




1 件のコメント:

  1. Environment.getExternalStorageState()の結果を '==' で比較しているのはなぜですか?同じインスタンスが返ってくるのですかね。

    返信削除