2012年8月23日木曜日

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

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

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

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

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


■ AsyncTask を使う

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

  1. class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> {  
  2.     ImageView mImageView;  
  3.     int mWidth;  
  4.     int mHeight;  
  5.     String mFilePath;  
  6.   
  7.     public BitmapWorkerTask(ImageView imageView) {  
  8.         mImageView = imageView;  
  9.         mWidth = imageView.getWidth();  
  10.         mHeight = imageView.getHeight();  
  11.     }  
  12.   
  13.     // バックグラウンドで画像をデコード  
  14.     @Override  
  15.     protected Bitmap doInBackground(String... params) {  
  16.         mFilePath = params[0];  
  17.         return decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight);  
  18.     }  
  19.   
  20.     // ImageView に Bitmap をセット  
  21.     @Override  
  22.     protected void onPostExecute(Bitmap bitmap) {  
  23.         if (bitmap != null) {  
  24.             final ImageView imageView = mImageView;  
  25.             imageView.setImageBitmap(bitmap);  
  26.         }  
  27.     }  
  28. }  


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

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

  1. class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> {  
  2.     private final WeakReference<ImageView> mImageViewReference;  
  3.     int mWidth;  
  4.     int mHeight;  
  5.     String mFilePath;  
  6.   
  7.     public BitmapWorkerTask(ImageView imageView) {  
  8.         mImageViewReference = new WeakReference<ImageView>(imageView);  
  9.         mWidth = imageView.getWidth();  
  10.         mHeight = imageView.getHeight();  
  11.     }  
  12.   
  13.     // バックグラウンドで画像をデコード  
  14.     @Override  
  15.     protected Bitmap doInBackground(String... params) {  
  16.         mFilePath = params[0];  
  17.         return decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight);  
  18.     }  
  19.   
  20.     // ImageView に Bitmap をセット  
  21.     @Override  
  22.     protected void onPostExecute(Bitmap bitmap) {  
  23.         if (mImageViewReference != null && bitmap != null) {  
  24.             final ImageView imageView = mImageViewReference.get();  
  25.             if (imageView != null) {  
  26.                 imageView.setImageBitmap(bitmap);  
  27.             }  
  28.         }  
  29.     }  
  30. }  


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


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

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

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


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


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

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



■ 平行処理をあつかう

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

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

  1. static class AsyncDrawable extends BitmapDrawable {  
  2.     private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;  
  3.   
  4.     public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) {  
  5.         super(res, bitmap);  
  6.         bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(bitmapWorkerTask);  
  7.     }  
  8.   
  9.     public BitmapWorkerTask getBitmapWorkerTask() {  
  10.         return bitmapWorkerTaskReference.get();  
  11.     }  
  12. }  


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

  1. public void loadBitmap(Context context, String filePath, ImageView imageView, Bitmap loadingBitmap) {  
  2.     final BitmapWorkerTask task = new BitmapWorkerTask(imageView);  
  3.   
  4.     final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), loadingBitmap, task);  
  5.     imageView.setImageDrawable(asyncDrawable);  
  6.   
  7.     task.execute(filePath);  
  8. }  


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

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

  1. private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {  
  2.     if (imageView != null) {  
  3.         final Drawable drawable = imageView.getDrawable();  
  4.         if (drawable instanceof AsyncDrawable) {  
  5.             final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;  
  6.             return asyncDrawable.getBitmapWorkerTask();  
  7.         }  
  8.     }  
  9.     return null;  
  10. }  


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

  1. class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> {  
  2.     ...  
  3.   
  4.     @Override  
  5.     protected void onPostExecute(Bitmap bitmap) {  
  6.         if (mImageViewReference != null && bitmap != null) {  
  7.             final ImageView imageView = mImageViewReference.get();  
  8.             if (imageView != null) {  
  9.                 // ImageView からタスクを取り出す  
  10.                 final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);  
  11.                 if (this == bitmapWorkerTask && imageView != null) {  
  12.                     // 同じタスクなら ImageView に Bitmap をセット  
  13.                     imageView.setImageBitmap(bitmap);  
  14.                 }  
  15.             }  
  16.         }  
  17.     }  
  18. }  
(原文ではなぜが imageView の null チェックがなくなってますが必要です)

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

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

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

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

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


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







最終的な全体のコードも載せておきます。
  1. public void loadBitmap(Context context, String filePath, ImageView imageView, Bitmap loadingBitmap) {  
  2.     // 同じタスクが走っていないか、同じ ImageView で古いタスクが走っていないかチェック  
  3.     if (cancelPotentialWork(filePath, imageView)) {  
  4.         final BitmapWorkerTask task = new BitmapWorkerTask(imageView);  
  5.         final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), loadingBitmap, task);  
  6.         imageView.setImageDrawable(asyncDrawable);  
  7.         task.execute(filePath);  
  8.     }  
  9. }  
  10.   
  11. public static boolean cancelPotentialWork(String filePath, ImageView imageView) {  
  12.     final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);  
  13.   
  14.     if (bitmapWorkerTask != null) {  
  15.         final String bitmapData = bitmapWorkerTask.mFilePath;  
  16.         if (!bitmapData.equals(filePath)) {  
  17.             // 以前のタスクをキャンセル  
  18.             bitmapWorkerTask.cancel(true);  
  19.         } else {  
  20.             // 同じタスクがすでに走っているので、このタスクは実行しない  
  21.             return false;  
  22.         }  
  23.     }  
  24.     // この ImageView に関連する新しいタスクを実行する  
  25.     return true;  
  26. }  
  27.   
  28. private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {  
  29.     if (imageView != null) {  
  30.         final Drawable drawable = imageView.getDrawable();  
  31.         if (drawable instanceof AsyncDrawable) {  
  32.             final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;  
  33.             return asyncDrawable.getBitmapWorkerTask();  
  34.         }  
  35.     }  
  36.     return null;  
  37. }  
  38.   
  39. static class AsyncDrawable extends BitmapDrawable {  
  40.     private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;  
  41.   
  42.     public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) {  
  43.         super(res, bitmap);  
  44.         bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(bitmapWorkerTask);  
  45.     }  
  46.   
  47.     public BitmapWorkerTask getBitmapWorkerTask() {  
  48.         return bitmapWorkerTaskReference.get();  
  49.     }  
  50. }  
  51.   
  52. class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> {  
  53.     private final WeakReference<ImageView> mImageViewReference;  
  54.     int mWidth;  
  55.     int mHeight;  
  56.     String mFilePath;  
  57.   
  58.     public BitmapWorkerTask(ImageView imageView) {  
  59.         mImageViewReference = new WeakReference<ImageView>(imageView);  
  60.         mWidth = imageView.getWidth();  
  61.         mHeight = imageView.getHeight();  
  62.     }  
  63.   
  64.     // バックグラウンドで画像をデコード  
  65.     @Override  
  66.     protected Bitmap doInBackground(String... params) {  
  67.         mFilePath = params[0];  
  68.         return decodeSampledBitmapFromFile(mFilePath, mWidth, mHeight);  
  69.     }  
  70.   
  71.     @Override  
  72.     protected void onPostExecute(Bitmap bitmap) {  
  73.         // キャンセルされていたらなにもしない  
  74.         if (isCancelled()) {  
  75.             bitmap = null;  
  76.         }  
  77.   
  78.         if (mImageViewReference != null && bitmap != null) {  
  79.             final ImageView imageView = mImageViewReference.get();  
  80.             if (imageView != null) {  
  81.                 // ImageView からタスクを取り出す  
  82.                 final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);  
  83.                 if (this == bitmapWorkerTask && imageView != null) {  
  84.                     // 同じタスクなら ImageView に Bitmap をセット  
  85.                     imageView.setImageBitmap(bitmap);  
  86.                 }  
  87.             }  
  88.         }  
  89.     }  
  90. }  

0 件のコメント:

コメントを投稿