Android Tips #39 LruCache で Bitmap をメモリキャッシュする

catch

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

LruCache とは

LruCache はアプリ上にメモリキャッシュを作るためのクラスです。Android 3.1 (APIレベル12) から導入されましたが Support Package に互換クラスがあるので Android 1.6 (APIレベル4) から使うことができます。
今回は画像を GridView で表示するアイテムをメモリキャッシュしてみたいと思います!

lru_cache

LruCache の使いかた

1. 最大キャッシュサイズを決める

まずはメモリにキャッシュする最大サイズを決めます。この最大サイズをいくつにするかは対応するバージョンやアプリが他で使用しているメモリ量によるので LruCache を使用する場面で適切な値を決めるようにします。今回はサンプルということで 10 MB としました。

int maxSize = 10 * 1024 * 1024;

2. LruCache をインスタンス化する

次に LruCache をインスタンス化します。今回は String をキーにして Bitmap をキャッシュします。コンストラクタには先ほど決めた最大キャッシュサイズを渡します。また LruCache#sizeOf() をオーバーライドして Bitmap のサイズを返すように実装する必要があります。こうしないと正しくメモリ計算してくれません。Bitmap のサイズを計算するには Bitmap#getByteCount() がありますが、これは Android 3.1 (APIレベル12) からしか使用できないので Bitmap#getRowBytes() と Bitmap#getHeight() を使って計算します。

mLruCache = new LruCache<String, Bitmap>(maxSize) {
    @Override
    protected int sizeOf(String key, Bitmap value) {
        return value.getRowBytes() * value.getHeight();
    }
};

3. LruCache にキャッシュを登録する

次に実際に LruCache にキャッシュを登録するところを作っていきます。

3.1. Bitmap を非同期で読み込む

まず Bitmap を読み込むところを実装します。今回は Bitmap を読み込むところは非同期にしたいと思うので AsyncTaskLoader を使います。

ImageLoader.java

package jp.classmethod.android.sample.lrucache;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v4.content.AsyncTaskLoader;

/**
 * {@link Bitmap} を非同期で読み込む {@link AsyncTaskLoader}.
 */
public class ImageLoader extends AsyncTaskLoader<Bitmap> {

    /** 対象のアイテム. */
    public ImageItem item;

    /**
     * コンストラクタ.
     * @param context {@link Context}
     * @param item {@link ImageItem}
     */
    public ImageLoader(Context context, ImageItem item) {
        super(context);
        this.item = item;
    }

    @Override
    public Bitmap loadInBackground() {
        return BitmapFactory.decodeResource(getContext().getResources(), R.drawable.item);
    }

}

また、以下のような DTO クラスも作成します。

ImageItem.java

package jp.classmethod.android.sample.lrucache;

import java.io.Serializable;

import android.graphics.Bitmap;

/**
 * アイテムのデータ.
 */
public class ImageItem implements Serializable {
    /** シリアルバージョン. */
    private static final long serialVersionUID = 1L;
    /** {@link Bitmap}. */
    public Bitmap bitmap;
    /** キー. */
    public String key;
}

3.2. LoaderCallBacks を実装する

次に作成した LoaderCallBacks を実装します。LoaderCallBacks#onCreateLoader() でさきほどの ImageLoader をインスタンス化し LoaderCallBacks#onLoadFinished() で Loader を破棄しつつ Bitmap を LruCache に登録します。

/**
 * ImageLoader のコールバック.
 */
private LoaderCallbacks<Bitmap> callbacks = new LoaderCallbacks<Bitmap>() {
    @Override
    public Loader<Bitmap> onCreateLoader(int i, Bundle bundle) {
        ImageItem item = (ImageItem) bundle.getSerializable("item");
        ImageLoader loader = new ImageLoader(getApplicationContext(), item);
        loader.forceLoad();
        return loader;
    }
    @Override
    public void onLoadFinished(Loader<Bitmap> loader, Bitmap bitmap) {
        int id = loader.getId();
        getSupportLoaderManager().destroyLoader(id);
        // メモリキャッシュに登録する
        ImageItem item = ((ImageLoader) loader).item;
        Log.i(TAG, "キャッシュに登録=" + item.key);
        mLruCache.put(item.key, bitmap);
        item.bitmap = bitmap;
        setBitmap(item);
    }
    @Override
    public void onLoaderReset(Loader<Bitmap> loader) {
    }
};

これでメモリキャッシュへの格納ができました! あとはこのタイミングで GridView に表示するアイテムに Bitmap を反映させたいので setBitmap() というメソッドを作って呼ぶようにしておきます。すぐ反映させたいので GridView#invalidateViews() を呼んで再描画させます。

/**
 * アイテムの View に Bitmap をセットする.
 * @param item
 */
private void setBitmap(ImageItem item) {
    ImageView view = (ImageView) mGridView.findViewWithTag(item);
    if (view != null) {
        view.setImageBitmap(item.bitmap);
        mGridView.invalidateViews();
    }
}

3.3. Bitmap を表示する View を実装する

GridView に使う Adapter は以下のような感じに実装します。View#setTag() で ImageItem をタグとして使用し、 Bitmap の読み込みが完了したら View#findViewWithTag() でタグをもとにアイテムの View を取得し Bitmap をセットします。

ImageAdapter.java

package jp.classmethod.android.sample.lrucache;

import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.ArrayAdapter;
import android.widget.GridView;
import android.widget.ImageView;

/**
 * {@link GridView} の {@link ArrayAdapter}.
 */
public class ImageAdapter extends ArrayAdapter<ImageItem> {

    /**
     * コンストラクタ.
     * @param context {@link Context}
     */
    public ImageAdapter(Context context) {
        super(context, 0);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {

        ImageItem item = getItem(position);

        ImageView view;
        if (convertView == null) {
            view = new ImageView(getContext());
            view.setLayoutParams(new AbsListView.LayoutParams(100, 100));
            view.setTag(item);
        } else {
            view = (ImageView) convertView;
        }
        
        view.setImageBitmap(item.bitmap);

        return view;
    }

}

4. LruCache からキャッシュを取得する

最後に LruCache からキャッシュを取得するところの実装です。GridView は現在表示されているアイテムだけ読み込むことができればよいので LruCache#get() で表示されているアイテムのキーに該当する Bitmap が含まれているか確認します。存在すればキャッシュされているということなので、取得した Bitmap をそのまま使います。存在しない場合は ImageLoader を実行して読み込みます。

private void loadBitmap() {
    ImageAdapter adapter = (ImageAdapter) mGridView.getAdapter();
    int first = mGridView.getFirstVisiblePosition();
    int count = mGridView.getChildCount();
    for (int i = 0; i < count; i++) {
        ImageItem item = adapter.getItem(i + first);
        Bitmap bitmap = mLruCache.get(item.name);
        if (bitmap != null) {
            // キャッシュに存在
            Log.i(TAG, "キャッシュあり=" + item.name);
            setBitmap(item);
            mGridView.invalidateViews();
        } else {
            // キャッシュになし
            Log.i(TAG, "キャッシュなし=" + item.name);
            Bundle bundle = new Bundle();
            bundle.putSerializable("item", item);
            getSupportLoaderManager().initLoader(i, bundle, callbacks);
        }
    }
}

これで完成です!以下は Activity のソースです。LoaderManager を使うので FragmentActivity を継承しています。

MainActivity.java

package jp.classmethod.android.sample.lrucache;

import android.app.Activity;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.support.v4.app.LoaderManager.LoaderCallbacks;
import android.support.v4.content.Loader;
import android.support.v4.util.LruCache;
import android.util.Log;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.GridView;
import android.widget.ImageView;

/**
 * GridView を表示する {@link Activity}.
 */
public class MainActivity extends FragmentActivity {

    /** ログ出力用のタグ. */
    private static final String TAG = MainActivity.class.getSimpleName();

    /** メモリキャッシュクラス. */
    private LruCache<String, Bitmap> mLruCache;
    /** {@link GridView}. */
    private GridView mGridView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mGridView = new GridView(this);
        mGridView.setNumColumns(4);
        setContentView(mGridView);

        // LruCache のインスタンス化
        int maxSize = 10 * 1024 * 1024;
        mLruCache = new LruCache<String, Bitmap>(maxSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getRowBytes() * value.getHeight();
            }
        };

        // Adapter の作成とアイテムの追加
        ImageAdapter adapter = new ImageAdapter(this);
        mGridView.setAdapter(adapter);
        for (int i = 0; i < 50; i++) {
            ImageItem item = new ImageItem();
            item.key = "item" + String.valueOf(i);
            adapter.add(item);
        }

        // onScrollListener の実装
        mGridView.setOnScrollListener(new OnScrollListener() {
            @Override
            public void onScrollStateChanged(AbsListView view, int scrollState) {
                if (scrollState == SCROLL_STATE_IDLE) {
                    // スクロールが止まったときに読み込む
                    loadBitmap();
                }
            }
            @Override
            public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            }
        });
    }

    /**
     * 画像を読み込む.
     */
    private void loadBitmap() {
        // 現在の表示されているアイテムのみリクエストする
        ImageAdapter adapter = (ImageAdapter) mGridView.getAdapter();
        int first = mGridView.getFirstVisiblePosition();
        int count = mGridView.getChildCount();
        for (int i = 0; i < count; i++) {
            ImageItem item = adapter.getItem(i + first);
            // キャッシュの存在確認
            Bitmap bitmap = mLruCache.get(item.key);
            if (bitmap != null) {
                // キャッシュに存在
                Log.i(TAG, "キャッシュあり=" + item.key);
                setBitmap(item);
                mGridView.invalidateViews();
            } else {
                // キャッシュになし
                Log.i(TAG, "キャッシュなし=" + item.key);
                Bundle bundle = new Bundle();
                bundle.putSerializable("item", item);
                getSupportLoaderManager().initLoader(i, bundle, callbacks);
            }
        }
    }

    /**
     * アイテムの View に Bitmap をセットする.
     * @param item
     */
    private void setBitmap(ImageItem item) {
        ImageView view = (ImageView) mGridView.findViewWithTag(item);
        if (view != null) {
            view.setImageBitmap(item.bitmap);
            mGridView.invalidateViews();
        }
    }

    /**
     * ImageLoader のコールバック.
     */
    private LoaderCallbacks<Bitmap> callbacks = new LoaderCallbacks<Bitmap>() {
        @Override
        public Loader<Bitmap> onCreateLoader(int i, Bundle bundle) {
            ImageItem item = (ImageItem) bundle.getSerializable("item");
            ImageLoader loader = new ImageLoader(getApplicationContext(), item);
            loader.forceLoad();
            return loader;
        }
        @Override
        public void onLoadFinished(Loader<Bitmap> loader, Bitmap bitmap) {
            int id = loader.getId();
            getSupportLoaderManager().destroyLoader(id);
            // メモリキャッシュに登録する
            ImageItem item = ((ImageLoader) loader).item;
            Log.i(TAG, "キャッシュに登録=" + item.key);
            item.bitmap = bitmap;
            mLruCache.put(item.key, bitmap);
            setBitmap(item);
        }
        @Override
        public void onLoaderReset(Loader<Bitmap> loader) {
        }
    };
}

実行結果はこんな感じです。

lru_cache

ソースコード

今回のサンプルコードを github で公開しました!ぜひ参考にしてください。

suwa-yuki/LruCacheSample

まとめ

今回はそのまま実装に使えそうなサンプルに仕上げてみました。ですが LruCache の実装だけ見てみるととてもシンプルであることがお分かりいただけたかなとおもいます。LruCache を使わなければもっと複雑になってしまうと思います。LruCache を使うと簡単にキャッシュの仕組みが実装できてとても便利ですね!ぜひ使っていきましょう。
また、メモリキャッシュで重要になってくるのが最大メモリキャッシュをどのくらいにするかという点です。キャッシュを持ちすぎると OutOfMemory が発生する可能性が高くなりますし、逆に少なすぎるとパフォーマンスが上がりません。。LruCache を使おうとしているタイミングでどのくらいメモリに空きがあるのかよく把握しておくようにしましょう。

参考