Android:非同期にダウンロードした画像の表示方法

※この記事はAndroid Advent Calendarの19日のエントリーです。

さて、勢いでAndroid Advent Calendarに参加することにしたら、皆がガチの技術情報ばかりなので、私も技術情報にしようと思いました(ネタが滑りそうで怖くなったため)。

今回の記事は真新しい情報でもなく、非同期にDLした画像の表示方法です。
なぜこれを書くつもりになったかと言いますと、かなり前の記事ですが、Android:Adapter.getViewでAsyncTaskは危険というのを書いてまして、タブレットでAdapter.getViewメソッドで画像をAsyncTaskを使ってダウンロードさせるとAsyncTaskの呼び過ぎでアプリが落ちてました。それの自己解決方法の記事を書いてなかったので、折角だから書いておこうかなと思った次第です。ただし、あくまでも私流です。これが正解ということはありません。いい方法があったら教えていただきたいくらいです。

実装した内容は、以下の通り。

  1. ListView系でダウンロードする画像のURLを事前に全部抽出する
  2. List3つにダウンロードする画像を割り振る
  3. 画像ダウンロード専用のAsyncTaskを3つ起動させて、それぞれDLさせる
  4. ダウンロードした画像はファイルとして保存。SQLiteにURLと画像のファイルパスと有効期限を保存しておく
  5. ImageViewクラスを継承したRemoteImageViewクラスを作成
  6. RemoteImageViewクラスにハンドラを実装し、対象のURLの画像があるかSQLiteに問い合わせる
  7. 画像があったら表示。なかったら1秒後にリトライ
  8. 10回ループして画像がダウンロードできてなかったら、Not found画像を表示
  9. アプリ起動時に画像の有効期限を確認して、古いものは削除する

まず、画像情報を管理するデータベース用のクラスImageCacheDBを作成します。
AsyncTaskで画像をダウンロードし終わったタイミングでデータを登録します。

public class ImageCacheDB extends SQLiteOpenHelper {

    private static final String DB_FILE = "imagecache.db";
    public static final String TBL_CACHE = "ImageCache";
    private static final int DB_VERSION = 1;
    
    public interface CacheColumn {
        public static final String ID = "_id";
        public static final String NAME = "fileName";
        public static final String REGIST_DATE = "registDate";
        public static final String URL = "url";
        public static final String TYPE = "type";
    }
    
    private SQLiteDatabase mDB;
    
    public static ImageCacheDB instance;
    
    private ImageCacheDB(Context context) {
        super(context, DB_FILE, null, DB_VERSION);
    }
    
    public static ImageCacheDB getInstance(Context context) {
        if (instance == null) {
            instance = new ImageCacheDB(context);
        }
        return instance;
    }
    
    synchronized private SQLiteDatabase getDB() {
        if (mDB == null) {
            mDB = getWritableDatabase();
        }
        return mDB;
    }
    
    public void close() {
        if (mDB != null) {
            getDB().close();
        }
        super.close();
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("CREATE TABLE IF NOT EXISTS " + TBL_CACHE + " ("
                + CacheColumn.ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
                + CacheColumn.REGIST_DATE + " INTEGER,"
                + CacheColumn.URL + " VARCHAR(300),"
                + CacheColumn.TYPE + " VARCHAR(100),"
                + CacheColumn.NAME + " VARCHAR(20))"
        );
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

    }
    
    public long insert(String url) {
        ContentValues values = new ContentValues();
        values.put(CacheColumn.REGIST_DATE, new Date().getTime());
        values.put(CacheColumn.URL, url);
        long id = getDB().insertOrThrow(TBL_CACHE, null, values);
        return id;
    }

    public int update(long id, String filename, String type) {
        ContentValues values = new ContentValues();
        values.put(CacheColumn.TYPE, type);
        values.put(CacheColumn.NAME, filename);
        return getDB().update(TBL_CACHE, values, CacheColumn.ID + " = ?", new String[]{String.valueOf(id)});
    }
    
    public int delete(long id) {
        return getDB().delete(TBL_CACHE, CacheColumn.ID + " = ?", new String[]{String.valueOf(id)});
    }
    
    public Cursor exists(String url) {
        return getDB().query(TBL_CACHE, null, CacheColumn.URL + " = ?", new String[]{url}, null, null, null);
    }
    public Cursor existsFile(String url) {
        return getDB().query(TBL_CACHE, null, CacheColumn.URL + " = ? AND " + CacheColumn.NAME + " IS NOT NULL", new String[]{url}, null, null, null);
    }
    
    public Cursor findOlderCache() {
        return getDB().query(TBL_CACHE,
                null,
                CacheColumn.REGIST_DATE + " < ?",
                new String[]{String.valueOf(new Date().getTime() - 604800)}, // 7*24*60*60
                null, null, null);
    }
    
    public Cursor findAll() {
        return getDB().query(TBL_CACHE,
                null,
                null,
                null,
                null, null, null);
    }
    
    @Override
    protected void finalize() throws Throwable {
        if (mDB != null) {
            mDB.close();
        }
        this.close();
        super.finalize();
    }
}

RemoteImageViewクラス(抽象クラス)は以下の通り。
抽象クラスにしているのは、アプリによってNow loadingやNot foundの画像を変えることができるようにするためです。

abstract public class RemoteImageView extends ImageView {
    private static final String TAG = "RemoteImageView";
    
    public static final int IMG_DOWNLOADING = 1;
    
    public final Handler mHandler = new Handler(){

        @Override
        public void handleMessage(Message msg) {
            // 1秒毎にキャッシュからヒットするか検索する
            // ヒットしたら、検索をやめる
            // 画像表示に10回挑戦してダメだったらフラグを立てるとかしないとマズい
            if (msg.what == IMG_DOWNLOADING) {
                Context cxt = getContext();
                final String url = (String)msg.obj;
                int count = msg.arg1;
                LogUtils.d(TAG, url);
                ImageCacheDB db = ImageCacheDB.getInstance(cxt);
                if (url != null && !url.equals("")) {
                    final Cursor c = db.existsFile(url);
                    if (c.moveToFirst()) {
                        final String filename = c.getString(c.getColumnIndex(CacheColumn.NAME));
                        final String type = c.getString(c.getColumnIndex(CacheColumn.TYPE));
                        if (type.equals("image/jpg")
                                || type.equals("image/jpeg")
                                || type.equals("image/png")
                                || type.equals("image/gif")) {
                            Drawable drawable = Drawable.createFromPath(cxt.getFileStreamPath(filename).getAbsolutePath());
                            setImageDrawable(drawable);
                            setVisibility(RemoteImageView.VISIBLE);
                        } else {
                            // 表示できる類いではない
                            setImageNotFound();
                        }
                    } else {
                        if (count <= 10) {
                            setImageNowLoading();
                            msg = obtainMessage(IMG_DOWNLOADING, ++count, 0, url);
                            long current = SystemClock.uptimeMillis();
                            long nextTime = current + 1000;
                            sendMessageAtTime(msg, nextTime);
                        } else {
                            // チャレンジ10回して失敗したので失敗扱い
                            setImageNotFound();
                        }
                    }
                    c.close();
                } else {
                    setImageNotFound();
                }
            }
        }
    };

    public RemoteImageView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    public RemoteImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public RemoteImageView(Context context) {
        super(context);
    }

    abstract public void setImageNotFound();
    
    abstract public void setImageNowLoading();
}

あと、画像のURLのArrayListを渡したらマルチスレッドで画像をダウンロードしてくれるMultiThreadImageDownloaderクラス。単純に画像をダウンロードするAsyncTask(ImageDownloadCacheTaskクラス)を3つ起動しているだけです。パフォーマンス次第では、ダウンロード用のAsyncTaskの数を増減させてもいいかなと思いますが、まぁ3つもあればいいかなと思います。

public class MultiTheadImageDownloader {

    public static void execute(Context context, ArrayList<String> urls) {
        ArrayList<String> list1 = new ArrayList<String>();
        ArrayList<String> list2 = new ArrayList<String>();
        ArrayList<String> list3 = new ArrayList<String>();
        int i = 0;
        for (String url : urls) {
            switch (i % 3) {
                case 0: list1.add(url); break;
                case 1: list2.add(url); break;
                case 2: list3.add(url); break;
            }
            i++;
        }
        new ImageDownloadCacheTask(context).execute((String[])list1.toArray(new String[0]));
        new ImageDownloadCacheTask(context).execute((String[])list2.toArray(new String[0]));
        new ImageDownloadCacheTask(context).execute((String[])list3.toArray(new String[0]));
    }
}

で、実際に画像をダウンロードするImageDownloadCacheTaskクラスです。配列で画像のURLをもらうので、それを全てダウンロードするようにループを回します。対象URLの画像が既にダウンロード済みなら、無視。まだダウンロードしてなかったら、ダウンロードします。

public class ImageDownloadCacheTask extends AsyncTask<String, Void, Void> {
    
    private static final String TAG = "ImageDownloadCacheTask";

    private Context mContext;
    
    public ImageDownloadCacheTask(Context context) {
        mContext = context;
    }
    
    @Override
    protected Void doInBackground(String... urls) {
        ImageCacheDB db = ImageCacheDB.getInstance(mContext);
        long id = 0;
        HttpClient httpClient = null;
        for (String url : urls) {
            Cursor c = db.exists(url);
            if (!c.moveToFirst()) {
                try {
                    id = db.insert(url);
                    HttpGet httpRequest = new HttpGet(url);
                    httpClient = new DefaultHttpClient();
                    HttpResponse response;
                    response = (HttpResponse) httpClient.execute(httpRequest);
                    HttpEntity entity = response.getEntity();
                    BufferedHttpEntity bufHttpEntity = new BufferedHttpEntity(entity);
                    
                    // ファイルに保存
                    String filename = String.format("%06d", id);
                    String type = entity.getContentType().getValue();
                    FileOutputStream stream = mContext.openFileOutput(filename, Context.MODE_PRIVATE);
                    InputStream is = bufHttpEntity.getContent();
                    byte[] data = new byte[4096];
                    int size;
                    while((size = is.read(data)) > 0) {
                        stream.write(data, 0, size);
                    }
                    stream.close();
                    is.close();
                    // キャッシュディレクトリに画像を保存する
                    db.update(id, filename, type);
                } catch (Exception e) {
                    Log.e(TAG, e.getClass().getSimpleName(), e);
                    if (id > 0) {
                        try {
                            db.delete(id);
                        } catch (Exception e2) {
                            Log.e(TAG, e2.getClass().getSimpleName(), e2);
                        }
                    }
                } finally {
                    // クライアントを終了させる
                    if (httpClient != null) {
                        httpClient.getConnectionManager().shutdown();
                    }
                }
            }
            c.close();
        }
        return null;
    }
}

上記で準備はできたので、実際に使ってみましょう。
実はサンプルプロジェクトを作成してgithubに公開しました。

github: RemoteImageViewSample

上のほうで説明したRemoteImageViewを使ってGridViewで画像を表示しています。
取得元ははてなフォトライフの、「猫」のタグが付いてる画像のRSSです。
Androidクラスタは猫が好きな人が多いみたいなので、これにしました。
アプリを起動しますと、RSSの解析中にプログレスダイアログを表示し、画像のDL中はDL中の画像、DLし終わると猫の画像が出てきます。下のような画面です。

RSSの解析は、ライブラリのRomeを使いました。
これはAndroid用にカスタムされたのがjarで公開されてます。

http://code.google.com/p/android-rome-feed-reader/

では、これの説明です。

public class RemoteImageViewSampleActivity extends Activity {

    private static final String TAG = "RemoteImageViewSampleActivity";

    private GridView mGrid;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        mGrid = (GridView) findViewById(R.id.grid);
        // はてなフォトライフから猫のタグのついたRSSを取得、解析する
        new HatenaPhotoLifeRssReaderTask(this).execute();
    }

    /**
     * はてなフォトライフの猫のタグのついたRSSを解析する
     * 
     * @author Toyoaki Oko <chariderpato@gmail.com>
     */
    private class HatenaPhotoLifeRssReaderTask extends AsyncTask<Void, Void, ArrayList<String>> {

        protected Context mContext;
        protected ProgressDialog mProgress;

        public HatenaPhotoLifeRssReaderTask(Context context) {
            this.mContext = context;
        }
        
        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            mProgress = new ProgressDialog(mContext);
            // 戻るボタンが押された場合の処理
            mProgress.setOnCancelListener(new OnCancelListener() {
                
                public void onCancel(DialogInterface dialog) {
                    HatenaPhotoLifeRssReaderTask.this.cancel(true);
                }
            });
            mProgress.setMessage(mContext.getString(R.string.now_loading));
            mProgress.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            mProgress.show();
        }
        
        @Override
        protected ArrayList<String> doInBackground(Void... params) {
            ArrayList<String> imageUrls = new ArrayList<String>();
            final String feedUrl = "http://f.hatena.ne.jp/t/%E7%8C%AB?mode=rss";
            RssAtomFeedRetriever feedRetriever = new RssAtomFeedRetriever();
            SyndFeed feed = feedRetriever.getMostRecentNews(feedUrl);
            List<SyndEntry> entries = feed.getEntries();
            
            // RSSより、サムネイル用画像URLを抽出する
            for (SyndEntry entry : entries) {
                ArrayList list = (ArrayList) entry.getForeignMarkup();
                for (int i = 0; i < list.size(); i++) {
                    Element elm = (Element) list.get(i);
                    if (elm.getName().equals("imageurlmedium")) {
                        imageUrls.add(elm.getValue());
                        continue;
                    }
                }
            }
            return imageUrls;
        }

        @Override
        protected void onPostExecute(ArrayList<String> imageUrls) {
            mProgress.dismiss();
            if (isCancelled()) {
                return;
            }
            if (imageUrls != null) {
                HatenaPhotoLifeAdapter adapter = new HatenaPhotoLifeAdapter(imageUrls);
                mGrid.setAdapter(adapter);
                // 画像のダウンロードを実行する
                MultiTheadImageDownloader.execute(RemoteImageViewSampleActivity.this, imageUrls);
            }
            super.onPostExecute(imageUrls);
        }
    }

    /**
     * GridViewに渡すAdapterクラス
     * 
     * @author Toyoaki Oko <chariderpato@gmail.com>
     */
    private class HatenaPhotoLifeAdapter extends BaseAdapter {
        private ArrayList<String> mImageUrls;
        private Resources mRes;
        public int getCount() {
            return this.mImageUrls.size();
        }

        public String getItem(int position) {
            return this.mImageUrls.get(position);
        }

        public long getItemId(int position) {
            return position;
        }

        public HatenaPhotoLifeAdapter(ArrayList<String> imageUrls) {
            this.mImageUrls = imageUrls;
            this.mRes = getResources();
        }

        public View getView(int position, View convertView, ViewGroup parent) {
            ViewHolder holder;
            if (convertView == null) {
                convertView = new HatenaImageView(RemoteImageViewSampleActivity.this);
                holder = new ViewHolder();
                holder.thumbnail = (HatenaImageView) convertView;
                int thumbnailSize = mRes.getDimensionPixelSize(R.dimen.thumbnail_width);
                holder.thumbnail.setLayoutParams(new GridView.LayoutParams(thumbnailSize, thumbnailSize));
                holder.thumbnail.setScaleType(ScaleType.FIT_CENTER);
                convertView.setTag(holder);
            } else {
                holder = (ViewHolder) convertView.getTag();
            }
            // 画像のURL
            final String imageUrlMedium = this.getItem(position);
            // RemoteImageViewに画像読み込み依頼を投げる
            Message msg = holder.thumbnail.mHandler.obtainMessage(RemoteImageView.IMG_DOWNLOADING,
                    1, 0, imageUrlMedium);
            msg.sendToTarget();
            return convertView;
        }
    }

    private class ViewHolder {
        HatenaImageView thumbnail;
    }

    private class HatenaImageView extends RemoteImageView {

        public HatenaImageView(Context context) {
            super(context);
        }

        /**
         * 画像が見つからなかった場合に表示する画像
         */
        @Override
        public void setImageNotFound() {
            setImageResource(R.drawable.not_found);
        }

        /**
         * 画像のダウンロード中に表示する画像
         */
        @Override
        public void setImageNowLoading() {
            setImageResource(R.drawable.now_loading);
        }
    }
}

160行くらいあるので、読むのが億劫になりそうですが、やっていることはシンプルです。

  1. onCreateで、GridViewをメンバ変数に入れ、その後、はてなフォトライフのRSSをHatenaPhotoLifeRssReaderTaskを使って非同期で解析
  2. HatenaPhotoLifeRssReaderTask.doInBackgorundでRomeを使ってサムネイルのURLを抽出してArrayListをonPostExecuteへ
  3. HatenaPhotoLifeRssReaderTask.onPostExecuteでHatenaPhotoLifeAdapterを生成。これがGridView用のアダプタ。画像のダウンロード自体はMultiTheadImageDownloaderに行なわせる
  4. HatenaPhotoLifeAdapter.getView内で、HatenaImageViewを作って画像のリクエストをハンドラに依頼
  5. HatenaImageViewに、画像をDL中の画像を表示させるメソッドsetImageNowLoadingや画像がない場合のメソッドsetImageNotFoundを定義しておく

キャッシュの削除処理とかは省略してます。
上記の処理を行ないますと、GridViewに猫の画像が表示されます。

いかがでしたでしょうか?1度起動した後に、再起動してみるとわかりますが、今度は直ぐに画像が表示されるはずです(RSSの解析は長いけど)。
ただ、この実装方法は、画像のキャッシュがSDカードではなく本体のメモリに保存してます。小さい画像とはいえ、数が多いと割とすぐに容量が多くなります。SDカードのキャッシュディレクトリに保存するように変更したほうがいいだろうなぁと思いつつ、今のところこのままです(^_^;)

参考になりましたら幸いです!


タグ Android | パーマリンク.

コメント・トラックバック一覧

  1. Hidetaka Kawase says:

    このTaskだと、DL中にコンフィギュレーションの変化
    (例えば画面回転)が起こると落ちるコードに見えます。

    Activityのライフサイクルと一致しない可能性があるスコープで
    Contextを参照したい場合は、WeakReferenceを用いて弱参照すべきです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です