2012年8月28日火曜日

Android Bitmap 読み込みのコードを github に公開しました。

大きい画像を効果的に読み込む
バックグラウンドで Bitmap を処理する
Bitmap をキャッシュする

をまとめて、DiskLruCache として Displaying Bitmaps Efficiently のサンプル BitmapFun.zip の DiskLruCache.java をベースにしたコードを github に公開しました。Google I/O 2012 のコードも参考にしています。

github - yanzm/ImageLoadLib -

特徴は API Level 4 から使えるようにしてあることです(BitmapFun や Google I/O 2012 のコードはそうなっていません)。

こんな感じで使います。

private ImageFetcher mImageFetcher; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mImageFetcher = Utils.getImageFetcher(getActivity()); } private void loadImage(ImageView iv, String imageUrl) { mImageFetcher.loadImage(imageUrl, iv, R.drawable.loading); }


BitmapFun の DiskLruCache は Google I/O 2012 や Androdi 4.0 のソースコードに含まれている DiskLruCache よりかなり単純化されています。 なので読めばわかると思いますが、少しだけ解説します。

内部で行っている処理は

1. キャッシュディレクトリの決定(外部ストレージが使えるなら、外部ストレージを、そうでないなら内部ストレージを使う) public static File getDiskCacheDir(Context context, String uniqueName) { final String cachePath = Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED || !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() : context.getCacheDir().getPath(); return new File(cachePath + File.separator + uniqueName); } @TargetApi(Build.VERSION_CODES.GINGERBREAD) private static boolean isExternalStorageRemovable() { if (Utils.hasGingerbread()) { return Environment.isExternalStorageRemovable(); } return true; } @TargetApi(Build.VERSION_CODES.FROYO) private static File getExternalCacheDir(Context context) { if (Utils.hasFroyo()) { File cacheDir = context.getExternalCacheDir(); if (cacheDir != null) { return cacheDir; } } // Froyo 以前は自前でディレクトリを作成する final String cacheDir = "/Android/data/" + context.getPackageName() + "/cache/"; return new File(Environment.getExternalStorageDirectory().getPath() + cacheDir); }

2. キャッシュディレクトリに作成(なければ)とサイズチェック public static DiskLruCache openCache(Context context, File cacheDir, long maxByteSize) { if (!cacheDir.exists()) { cacheDir.mkdir(); } if (cacheDir.isDirectory() && cacheDir.canWrite() && getUsableSpace(cacheDir) > maxByteSize) { return new DiskLruCache(cacheDir, maxByteSize); } return null; } @TargetApi(Build.VERSION_CODES.GINGERBREAD) private static long getUsableSpace(File path) { if (Utils.hasGingerbread()) { return path.getUsableSpace(); } final StatFs stats = new StatFs(path.getPath()); return (long) stats.getBlockSize() * (long) stats.getAvailableBlocks(); } private DiskLruCache(File cacheDir, long maxByteSize) { mCacheDir = cacheDir; maxCacheByteSize = maxByteSize; }

3. ディスクキャッシュに Bitmap を追加(ファイルとして保存) private final Map<String, String> mLinkedHashMap = Collections.synchronizedMap(new LinkedHashMap<String, String>( INITIAL_CAPACITY, LOAD_FACTOR, true)); public void put(String key, Bitmap data) { synchronized (mLinkedHashMap) { if (mLinkedHashMap.get(key) == null) { try { final String file = createFilePath(mCacheDir, key); if (writeBitmapToFile(data, file)) { put(key, file); flushCache(); } } catch (final FileNotFoundException e) { Log.e(TAG, "Error in put: " + e.getMessage()); } catch (final IOException e) { Log.e(TAG, "Error in put: " + e.getMessage()); } } } } public static String createFilePath(File cacheDir, String key) { return cacheDir.getAbsolutePath() + File.separator + CACHE_FILENAME_PREFIX + key; } private boolean writeBitmapToFile(Bitmap bitmap, String file) throws IOException, FileNotFoundException { OutputStream out = null; try { out = new BufferedOutputStream(new FileOutputStream(file), IO_BUFFER_SIZE); return bitmap.compress(mCompressFormat, mCompressQuality, out); } finally { if (out != null) { out.close(); } } }

4. ハッシュマップに追加し、最大キャッシュサイズを超えていたら、超えなくなるまで最後に参照したファイルから順番に削除 private void put(String key, String file) { mLinkedHashMap.put(key, file); cacheSize = mLinkedHashMap.size(); cacheByteSize += new File(file).length(); } private void flushCache() { Entry>String, String> eldestEntry; File eldestFile; long eldestFileSize; int count = 0; while (count < MAX_REMOVALS && (cacheSize > maxCacheItemSize || cacheByteSize > maxCacheByteSize)) { eldestEntry = mLinkedHashMap.entrySet().iterator().next(); eldestFile = new File(eldestEntry.getValue()); eldestFileSize = eldestFile.length(); mLinkedHashMap.remove(eldestEntry.getKey()); eldestFile.delete(); cacheSize = mLinkedHashMap.size(); cacheByteSize -= eldestFileSize; count++; if (BuildConfig.DEBUG) { Log.d(TAG, "flushCache - Removed cache file, " + eldestFile + ", " + eldestFileSize); } } }

5. ディスクキャッシュから Bitmap を取得 public Bitmap get(String key) { synchronized (mLinkedHashMap) { final String file = mLinkedHashMap.get(key); if (file != null) { if (BuildConfig.DEBUG) { Log.d(TAG, "Disk cache hit"); } return BitmapFactory.decodeFile(file); } else { final String existingFile = createFilePath(mCacheDir, key); if (new File(existingFile).exists()) { put(key, existingFile); if (BuildConfig.DEBUG) { Log.d(TAG, "Disk cache hit (existing file)"); } return BitmapFactory.decodeFile(existingFile); } } return null; } }


Android 4.0 のソースコードの DiskLruCache の解説はまぁ、そのうち、、、、



2012年8月27日月曜日

Android WebView で https のサイトにアクセスして白画面になるのを避ける方法

追記:
指摘が多かったので書いておきます。勝手にSSLの証明書検証をスキップするアプリはセキュリティホールのあるアプリとして扱われるので注意してください。
標準のブラウザのようにダイアログを出したり、アプリでアクセスを許可しているドメインかどうかチェックしたりするべきでしょう。
あと、この方法を格別オススメしているわけではありません。あくまで対処法です。あしからず。



WebView で https で提供されている URL のサイトにアクセスしようとしたとき、証明書がオレオレ証明書だったり、Android にデフォルトで入っている信頼済証明書機関(trusted certificate authorities)に入っていない場合 SSL のエラーが発生し、処理がキャンセルされ white screen / empty screen (つまり真っ白画面)になります。

例えば、いくつかの CA root certificates は Gingerbread 以前には入っていません。
Issue 10807: Root Certificates missing from Android root store

Android 2.2 以降の標準ブラウザでこの URL にアクセスしようとすると、次のような確認ダイアログがでます。



ここで Continue ボタンをクリックすると、エラーを無視してそのまま読み込みます。

デフォルトで WebView にセットされている WebViewClient では、 onReceivedSslError() の中で処理をキャンセルしているため、white screen になります。
つまり、先のダイアログでキャンセルを押した場合の動作がデフォルトになっているということです。

http://tools.oesf.biz/android-2.3_r1.0/xref/frameworks/base/core/java/android/webkit/WebViewClient.java#180 180 public void onReceivedSslError(WebView view, SslErrorHandler handler, 181 SslError error) { 182 handler.cancel(); 183 }

常に Continue を押したときの動作にしたい場合は、この onReceivedSslError() をオーバーライドして handler.proceed() を呼ぶようにします。こうすれば、エラーを無視してそのままページを読み込むようになります。

class CustomWebViewClient extends WebViewClient { public CustomWebViewClient() { super(); } @Override public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { handler.proceed(); } } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mWebview = new WebView(this); mWebview.setWebViewClient(new CustomWebViewClient()); mWebview.setWebChromeClient(new CustomWebChromeClient()); setContentView(mWebview); String url = "https://www.library.city.kawasaki.jp/idcheck.html" mWebview.loadUrl(url); }

上記の対応は 2.2 以降に対してだけです。

2.2 より前のデバイスでは、標準のブラウザで読み込もうとしても次のようにエラー画面になって読み込めません。



残念ながら onReceivedSslError() は API Level 2.2 からなので 2.1 より以前では Override できません。
代わりに onReceivedError() が呼ばれますが、ここでは読み込みをそのまま進ませるということができません。
対処方法として http://damianflannery.wordpress.com/2010/09/28/android-webview-with-https-loadurl-shows-blankempty-page/ とかあるんですけど、4.0.1 のコードでやってもうまくいきませんでした。コメントみても解決してる人とだめな人がいて、あまりいい方法ではないようです。
どうしたものか。。。



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 のなかみは、、、?)




Android バックグラウンドで Bitmap を処理する

Processing Bitmaps Off the UI Thread
の内容に補足を付けて解説してます。

前回のエントリーで大きい画像を効果的に読む込む方法を解説しましたが、デコードするデータがディスクやネットワークにある場合、BitmapFactory の decode* メソッドは UI スレッドで行ってはいけません(というかメモリ上以外のデータを読み込む場合は全部だめ)。

これらの処理はディスクやネットワークのスピード、画像のサイズ、CPUのパワーなどさまざまな要因で完了までの時間が変わり、いつ完了するのかわかりません。 もし画像のデコード処理で UI スレッドをブロックしてしまうと、最悪 ANR が発生します。

そこで、AsyncTask を使ってバックグランドで Bitmap を読み込むようにします。


■ AsyncTask を使う

特に何も考えないで作ると、きっとこんな感じになると思います。

class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { ImageView mImageView; int mWidth; int mHeight; String mFilePath; public BitmapWorkerTask(ImageView imageView) { mImageView = imageView; mWidth = imageView.getWidth(); mHeight = imageView.getHeight(); } // バックグラウンドで画像をデコード @Override protected Bitmap doInBackground(String... params) { mFilePath = params[0]; return decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight); } // ImageView に Bitmap をセット @Override protected void onPostExecute(Bitmap bitmap) { if (bitmap != null) { final ImageView imageView = mImageView; imageView.setImageBitmap(bitmap); } } }

この方法の問題は、AsyncTask のフィールドとして直接 ImageView のオブジェクトを持っていることです。 Activity からこの AsyncTask を起動したとして、Activity を終了しても(cancel() などを明示的に行わないなら)この AsyncTask は走り続けます。その際、この AsyncTask が ImageView への参照をもっているので、ImageView が GC の対象になりません。

これを防ぐために、次のように WeakReference を使って間接的に ImageView への参照を持つようにします。

class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { private final WeakReference<ImageView> mImageViewReference; int mWidth; int mHeight; String mFilePath; public BitmapWorkerTask(ImageView imageView) { mImageViewReference = new WeakReference<ImageView>(imageView); mWidth = imageView.getWidth(); mHeight = imageView.getHeight(); } // バックグラウンドで画像をデコード @Override protected Bitmap doInBackground(String... params) { mFilePath = params[0]; return decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight); } // ImageView に Bitmap をセット @Override protected void onPostExecute(Bitmap bitmap) { if (mImageViewReference != null && bitmap != null) { final ImageView imageView = mImageViewReference.get(); if (imageView != null) { imageView.setImageBitmap(bitmap); } } } }

WeakReference で ImageView への参照を持つようにすると、 AsyncTask は ImageView が GC されるのを妨げないようになります。
こうなると、onPostExecute() の時点で ImageView が存在していないことがあります。例えばバックキーで Activity が終了したり、画面回転などでコンフィグレーションが変わった場合などです。そこで ImageView の null チェックを行うようにします。


------------------------------
「AsyncTask は Activity の onPause() で cancel() すればいいじゃない」
というあなた。甘い、甘過ぎです。開発者としての視点ではそれでいいかもしれませんが、ユーザーのことを考えたら、必ずそうするのがいいとは限らないことがわかるはずです。

例えば、ユーザーがあるアプリAでネット上の画像を表示しようとして読み込み中になりました。
そこで、読み込みが終わるまでちょっと別のアプリ(例えばブラウザの記事を読んだり、twitter のタイムラインを見たり)に移動して、そろそろ読み込みが終わったかなーというころにアプリAに戻ってきました。 こういう使い方ってよくしますよね。
もし別のアプリに移動した時点(つまり、onPause() になったとき)で AsyncTask を cancel してしまったら、ユーザーが戻ってきたときに読み込みが完了できてないことになります。
これはユーザーとして嫌ですよね。

せめて onPause() で isFinishing() 判定くらいはしないとダメでしょう。


いやー、twicca よくできてるわ。
------------------------------


AsyncTask の実行はいつも通りです。

public void loadBitmap(String filePath, ImageView imageView) { BitmapWorkerTask task = new BitmapWorkerTask(imageView); task.execute(filePath); }


■ 平行処理をあつかう

上記の方法は ListView や GridView で使うにはまだ問題が残っています。
ListView や GridView ではご存知の通り、スクロール時に子ビューを再利用しています。1行のビューを取得する getView() の中で上記の AsyncTask を走らせた場合、タスクが完了するまえにビューが再利用されてしまうと、別の行に別の画像が表示されるという、大変残念なことになってしまいます。

そこで、ImageView にセットされる Drawable にタスクへの参照を持たせておいて、タスクが完了したときに同じかどうかチェックするようにします。
Drawable として BitmapDrawable を使うようにすれば、タスクが完了するまでの間 BitmapDrawable の画像が ImageView に表示されるので、読み込み中画像を表示したい場合などに便利です。

static class AsyncDrawable extends BitmapDrawable { private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference; public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) { super(res, bitmap); bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(bitmapWorkerTask); } public BitmapWorkerTask getBitmapWorkerTask() { return bitmapWorkerTaskReference.get(); } }

タスクを走らせる前この BitmapDrawable を作成し、ImageView にセットしておきます。

public void loadBitmap(Context context, String filePath, ImageView imageView, Bitmap loadingBitmap) { final BitmapWorkerTask task = new BitmapWorkerTask(imageView); final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), loadingBitmap, task); imageView.setImageDrawable(asyncDrawable); task.execute(filePath); }

読み込みが終わったら、タスクが WeakReference として参照を持っている ImageView から Drawable をとりだし、その Drawable からタスクを取り出し、そのタスクがこのタスクと同じかどうかをチェックします。同じなら再利用されてないということです。

まず、ImageView からタスクを取り出すメソッドを用意しておきます。

private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { if (imageView != null) { final Drawable drawable = imageView.getDrawable(); if (drawable instanceof AsyncDrawable) { final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; return asyncDrawable.getBitmapWorkerTask(); } } return null; }

onPostExecute() では上記のメソッドを呼び出して ImageView からタスクを取り出し、現在のタスクと同じか比較して同じなら ImageView に Bitmap をセットします。

class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { ... @Override protected void onPostExecute(Bitmap bitmap) { if (mImageViewReference != null && bitmap != null) { final ImageView imageView = mImageViewReference.get(); if (imageView != null) { // ImageView からタスクを取り出す final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); if (this == bitmapWorkerTask && imageView != null) { // 同じタスクなら ImageView に Bitmap をセット imageView.setImageBitmap(bitmap); } } } } } (原文ではなぜが imageView の null チェックがなくなってますが必要です)

これで別の行に別の画像が表示されるという問題はなくなりました。

しかし他の問題が残っています。タスクが開始されたビューが一旦画面外にスクロールアウトされて、また画面にスクロールで戻ってきたとき、同じタスクが重複して走ってしまいます。
また、ビューが再利用された場合、以前のタスクはもはや必要ないのでキャンセルしたほうがいいでしょう。

そこで、タスクを走らせる前に ImageView にすでにタスクがセットされていないかチェックし、タスクがセットされている場合は、そのタスクが読み込んでいる画像の識別子(例えば、ファイルパス、URL、リソースIDなど)とこれから読もうとしている画像の識別子を比較します。

同じ識別子なら、すでに走っているのとまったく同じタスクを走らせようとしてるので、タスクを走らせないようにします。
違う識別子なら、ImageView が再利用されたということなので、以前のタスクはキャンセルし、新しいタスクを走らせます。

public static boolean cancelPotentialWork(String filePath, ImageView imageView) { final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); if (bitmapWorkerTask != null) { final String bitmapData = bitmapWorkerTask.mFilePath; if (!bitmapData.equals(filePath)) { // 以前のタスクをキャンセル bitmapWorkerTask.cancel(true); } else { // 同じタスクがすでに走っているので、このタスクは実行しない return false; } } // この ImageView に関連する新しいタスクを実行する return true; } public void loadBitmap(Context context, String filePath, ImageView imageView, Bitmap loadingBitmap) { // 同じタスクが走っていないか、同じ ImageView で古いタスクが走っていないかチェック if (cancelPotentialWork(filePath, imageView)) { final BitmapWorkerTask task = new BitmapWorkerTask(imageView); final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), loadingBitmap, task); imageView.setImageDrawable(asyncDrawable); task.execute(filePath); } } 最後に、onPostExecute() にこのタスクがキャンセルされていないかチェックする部分を追加します。 class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { ... @Override protected void onPostExecute(Bitmap bitmap) { // キャンセルされていたらなにもしない if (isCancelled()) { bitmap = null; } if (mImageViewReference != null && bitmap != null) { final ImageView imageView = mImageViewReference.get(); if (imageView != null) { // ImageView からタスクを取り出す final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); if (this == bitmapWorkerTask && imageView != null) { // 同じタスクなら ImageView に Bitmap をセット imageView.setImageBitmap(bitmap); } } } } }

これで、ListView でも重複の心配なく、非同期読み込みできます!
わーい。
(でもキャッシュは、、、?)







最終的な全体のコードも載せておきます。 public void loadBitmap(Context context, String filePath, ImageView imageView, Bitmap loadingBitmap) { // 同じタスクが走っていないか、同じ ImageView で古いタスクが走っていないかチェック if (cancelPotentialWork(filePath, imageView)) { final BitmapWorkerTask task = new BitmapWorkerTask(imageView); final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), loadingBitmap, task); imageView.setImageDrawable(asyncDrawable); task.execute(filePath); } } public static boolean cancelPotentialWork(String filePath, ImageView imageView) { final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); if (bitmapWorkerTask != null) { final String bitmapData = bitmapWorkerTask.mFilePath; if (!bitmapData.equals(filePath)) { // 以前のタスクをキャンセル bitmapWorkerTask.cancel(true); } else { // 同じタスクがすでに走っているので、このタスクは実行しない return false; } } // この ImageView に関連する新しいタスクを実行する return true; } private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { if (imageView != null) { final Drawable drawable = imageView.getDrawable(); if (drawable instanceof AsyncDrawable) { final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; return asyncDrawable.getBitmapWorkerTask(); } } return null; } static class AsyncDrawable extends BitmapDrawable { private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference; public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) { super(res, bitmap); bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(bitmapWorkerTask); } public BitmapWorkerTask getBitmapWorkerTask() { return bitmapWorkerTaskReference.get(); } } class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> { private final WeakReference<ImageView> mImageViewReference; int mWidth; int mHeight; String mFilePath; public BitmapWorkerTask(ImageView imageView) { mImageViewReference = new WeakReference<ImageView>(imageView); mWidth = imageView.getWidth(); mHeight = imageView.getHeight(); } // バックグラウンドで画像をデコード @Override protected Bitmap doInBackground(String... params) { mFilePath = params[0]; return decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight); } @Override protected void onPostExecute(Bitmap bitmap) { // キャンセルされていたらなにもしない if (isCancelled()) { bitmap = null; } if (mImageViewReference != null && bitmap != null) { final ImageView imageView = mImageViewReference.get(); if (imageView != null) { // ImageView からタスクを取り出す final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); if (this == bitmapWorkerTask && imageView != null) { // 同じタスクなら ImageView に Bitmap をセット imageView.setImageBitmap(bitmap); } } } } }

Android 大きい画像を効果的に読み込む

Loading Large Bitmaps Efficiently
の内容なのですが、補足も入れてメモっておきます。

端的にいうと、

実際に表示するサイズより大きい Bitmap を読みこむのはメモリの無駄
(拡大させるとかなら話は別だけど)

・高解像度のカメラで取られた写真は往々にしてディスプレイのピクセルサイズより大きい
・サムネイルとして使うのに元のサイズのまま読み込むのはばかげてる
・大きいサイズの Bitmap をメモリに展開したら OutOfMemoryException になる


ステップとしては3つ

1. メモリに Bitmap 展開せずに、サイズや MimeType だけを取得する
2. 1. の情報をもとにサブサンプルにサイズを決める
3. 2. で決めたサブサンプルで Bitmap をメモリに読み込む


1. メモリに Bitmap 展開せずに、サイズや MimeType だけを取得する

BitmapFactory のデコードメソッド(decodeFile(), decodeResources(), decodeByteArray() など)は引数として BitmapFactory.Options を取るようになっているものがあります。

この BitmapFactory.Options の inJustDecodeBounds プロパティに true をセットしておくと、Bitmap がメモリに展開されません。ただし、BitmapFactory.Options の outHeight, outWidth, outMimeType プロパティには読み込んだ画像の情報がセットされます。

BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(filePath, options); int imageHeight = options.outHeight; int imageWidth = options.outWidth; String imageType = options.outMimeType;


2. 1. の情報をもとにサブサンプルにサイズを決める

画像のサイズがわかったので、縮小して読み込むのかそのまま読み込むのかを決めます。

・そのまま読み込んだ場合に使われるメモリサイズ
・読み込む画像に割り当てたいメモリサイズ(アプリの他の要因などから決まる)
・読み込んだ画像をセットする ImageView の大きさ
・デバイスの画面サイズとピクセル密度

このあたりの要因から決めます。
よっぽど ImageView が大きくなければ、だいたいは ImageView の大きさで決めます。

デコーダーに画像をサブサンプルさせる(縮小して読み込む、低解像度で読み込む)には、BitmapFactory.Options の inSampleSize パラメータに 1 より大きい整数値を指定します。
(原文ではなぜか true をセットと書いてありますが間違いです)

inSampleSize に 1 より大きい整数値を指定すると、縦横それぞれが約 1 / inSampleSize になって読み込まれます。
例えば

元 : 2048 x 1536
|
inSampleSize = 4

Bitmap : 512 x 384

縦横がそれぞれ 1 / inSampleSize になるので、サイズは 1 / inSampleSize^2 になります。


表示するサイズから inSampleSize を求めるにはこうします。

public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { // 画像の元サイズ final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { if (width > height) { inSampleSize = Math.round((float)height / (float)reqHeight); } else { inSampleSize = Math.round((float)width / (float)reqWidth); } } return inSampleSize; }

Math.round() だと四捨五入なので、切り上げられる場合、表示されるサイズよりも得られる Bitmap のサイズが小さくなります。それが嫌な場合は Math.floor() を使って切り下げるようにします。

inSampleSize には 2 のべき乗を指定したほうが効果的に速くデコードできますが、得られた Bitmap をメモリやディスクにキャッシュするなら、一番近いサイズになるように inSampeSize を決めたほうがいいです。

inSampleSize は 2 のべき乗にして大雑把に縮小し、そのあと Matrix を使って目的のサイズに近い Bitmap を取得する方法もあります。
createBitmap | Android Developers
Matrix | Android Developers



3. 2. で決めたサブサンプルで Bitmap をメモリに読み込む

BitmapFactory.Options の inFustDecodeBounds を false に戻して、2. で決めた値を inSampleSize にセットしてデコードします。

public static Bitmap decodeSampledBitmapFromFile(String filePath, int reqWidth, int reqHeight) { // inJustDecodeBounds=true で画像のサイズをチェック final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(filePath, options); // inSampleSize を計算 options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // inSampleSize をセットしてデコード options.inJustDecodeBounds = false; return BitmapFactory.decodeFile(filePath, options); }

例えば、 <resources> <dimen name="image_size">48dip</dimen> </resources> <ImageView android:id="@+id/image" android:layout_width="@dimen/image_size" android:layout_height="@dimen/image_size" />

という ImageView なら、

・View の getWidth(), getHeight() からサイズを取得
・ImageView に対して measure() を呼んで getMeasuredWidth(), getMeasuredHeight() からサイズを取得
・Resources の getDimensionPixelSize() を使って image_size の大きさを取得

という方法があります。
ImageView の大きさが固定値なら、最後の方法がいいと思います。
大きさが可変なら、最初か2番目の方法を使います。

Resources res = getResources(); int size = res.getDimensionPixelSize(R.dimen.image_size); mImageView.setImageBitmap( decodeSampledBitmapFromFile(filePath, size, size));



2012年8月21日火曜日

Support Package の LruCache を使うときの注意点

Android 3.1 (API Level 12) からキャッシュ用の便利クラスである LruCache が追加されました。

API Level 12 の方にはサンプルコードが載っています。

int cacheSize = 4 * 1024 * 1024; // 4MiB LruCache bitmapCache = new LruCache(cacheSize) { protected int sizeOf(String key, Bitmap value) { return value.getByteCount(); } }

この LruCache は Support Package にもバックポートされています。

ここで注意点なのですが、上記のコードをこっちで使う場合は

int cacheSize = 4 * 1024 * 1024; // 4MiB LruCache bitmapCache = new LruCache(cacheSize) { protected int sizeOf(String key, Bitmap value) { return bitmap.getRowBytes() * bitmap.getHeight(); } }

のようにします。

実は Bitmap の getByteCount() メソッドも API Level 12 からなんです。

このメソッドの中身は次のように getRowBytes() と getHeight() の積です。

http://tools.oesf.biz/android-4.0.1_r1.0/xref/frameworks/base/graphics/java/android/graphics/Bitmap.java#getByteCount 828 public final int getByteCount() { 829 // int result permits bitmaps up to 46,340 x 46,340 830 return getRowBytes() * getHeight(); 831 }

わざわざ新しく API に入れるほどでもなかったのでは。。。