Android

Android アプリの ListView で非同期の画像を表示する方法

はじめに

Android 開発は初心者で勉強中なのですが、ListView でウェブ上にある画像を表示するのに苦戦したので記録を残しておきます。
例えば、API でデータ一覧をJSONで取得し、そのデータ1件1件に画像パスがあるような場合に、その一覧を ListView で表示するアプリを想定してます。
環境:Android Studio 3.0.1

ListView の表示部分について

まず Activity、Loader、Adapter (一部抜粋)は下記のように作りました。
Sample の List を非同期で取得し、SampleAdapter の getView で ListView の表示を作ります。

MainActivity.java
    private ListView sampleListView;
    private SampleAdapter sampleAdapter;
    private LoaderManager loaderManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        loaderManager = getLoaderManager();

        // ListView に空のリストをいったん設定
        sampleListView = (ListView) findViewById(R.id.sample);
        sampleAdapter = new SampleAdapter(MainActivity.this, loaderManager);
        sampleListView.setAdapter(sampleAdapter);

        // 非同期で ListView にリストの要素を取得して設定
        loaderManager.initLoader(0, null, new SampleListLoaderCallbacks());
    }

    private class SampleListLoaderCallbacks implements LoaderManager.LoaderCallbacks<List<Sample>> {
        @Override
        public Loader<List<Sample>> onCreateLoader(int i, Bundle bundle) {
            return new SampleListLoader(MainActivity.this);
        }

        @Override
        public void onLoadFinished(Loader<List<Sample>> loader, List<Sample> list) {
            sampleAdapter.setSampleList(list);
            sampleAdapter.notifyDataSetChanged();
        }

        @Override
        public void onLoaderReset(Loader<List<Sample>> loader) {
            sampleAdapter.setSampleList(null);
        }
    }
SampleListLoader.java
    @Override
    public List<Sample> loadInBackground() {
        return sampleList(); // ここで Sample クラスの List を生成して返す
    }
SampleAdapter.java
    private Context context;
    private LayoutInflater layoutInflater;
    private List<Sample> sampleList = new ArrayList<Sample>();
    private LoaderManager loaderManager;

    public SampleAdapter(Context context, LoaderManager loaderManager) {
        this.context = context;
        this.loaderManager = loaderManager;
        this.layoutInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    @Override
    public int getCount() {
        return sampleList.size();
    }

    @Override
    public Object getItem(int i) {
        return sampleList.get(i);
    }

    @Override
    public long getItemId(int i) {
        return sampleList.get(i).getId();
    }

    public void setSampleList(List<Sample> sampleList) {
        this.sampleList = sampleList;
    }

    private class ViewHolder {
        public TextView title;
        public ImageView imageView;
    }

    @Override
    public View getView(int i, View view, ViewGroup viewGroup) {
        Sample sample = sampleList.get(i);
        ViewHolder viewHolder = null;

        if (view == null) {
            view = layoutInflater.inflate(R.layout.adapter_sample, viewGroup, false);
            viewHolder = new ViewHolder();
            viewHolder.title = (TextView) view.findViewById(R.id.title);
            viewHolder.imageView = (ImageView) view.findViewById(R.id.imageView);
            view.setTag(viewHolder);
        } else {
            viewHolder = (ViewHolder) view.getTag();
        }

        viewHolder.title.setText(sample.getTitle());

        // ここで viewHolder.imageView に画像をセットして表示したい

        return view;
    }

この getView の中で画像パスから ImageView に画像を非同期で設定するというのが難しかった箇所です。

非同期で画像を取得する方法

まず、こちらの「AsyncTaskLoaderとImageViewで非同期読み込みしよう」という記事を参考にさせていただきました。
ただ ListView の場合の対応についてはこれだけだとうまくできませんでした。
その原因が「Android で ListView に非同期で取ってきた画像を表示したら位置がおかしい件」の記事にありました。
ListView が getView で view を使いまわしてるから参照がずれるというもの。
そして解決方法は、ImageView と AsyncTask のタグを比較することで同じ行と判断するという方法を提示していただいてます。

ただ個人的に adapter の中で AsyncTask を使う実装に気持ち悪さを感じ、何か他に方法はないかと考え実装したみたのが次の方法になります。

MainActivity.java
    private List<Sample> sampleList;

    private class SampleLoaderCallbacks implements LoaderManager.LoaderCallbacks<List<Sample>> {
        @Override
        public Loader<List<Sample>> onCreateLoader(int i, Bundle bundle) {
            return new SampleListLoader(MainActivity.this);
        }

        @Override
        public void onLoadFinished(Loader<List<Sample>> loader, List<Sample> list) {
            sampleList = list;
            SampleAdapter.setSampleList(sampleList);
            SampleAdapter.notifyDataSetChanged();

            // adapter でリストを取得した時点では画像パスのみを持ってくる
            // 画像そのものの取得は Activity で行う
            for (int i = 0; i < sampleList.size(); i++) {
                Sample sample = sampleList.get(i);
                Bundle bundle = new Bundle();
                bundle.putInt("index", i);
                bundle.putString("imgPath", sample.getImgPath());
                loaderManager.initLoader(i + 1, bundle, new ImgLoaderCallbacks());
            }
        }

        @Override
        public void onLoaderReset(Loader<List<Sample>> loader) {
            sampleAdapter.setSampleList(null);
        }
    }

    // 画像を非同期で取得する用の LoaderCallbacks
    private class ImgLoaderCallbacks implements LoaderManager.LoaderCallbacks<Bitmap> {

        private Integer index;

        @Override
        public Loader<Bitmap> onCreateLoader(int i, Bundle bundle) {
            index = bundle.getInt("index");
            return new ImgLoader(MainActivity.this, bundle.getString("imgPath"));
        }

        @Override
        public void onLoadFinished(Loader<Bitmap> loader, Bitmap bitmap) {
            // 非同期の画像を取得できた都度、adapter を更新
            sampleList.get(index).setImg(bitmap);
            sampleAdapter.setSampleList(sampleList);
            sampleAdapter.notifyDataSetChanged();
        }

        @Override
        public void onLoaderReset(Loader<Bitmap> loader) {

        }
    }

簡単なサンプルの実装ではこれでうまくいきました。
ただ、データが多い場合や ListView をスクロールし続けた場合など、一気に画像を取ってくるこの方法は現実的でないのではと思いました。
負荷を考慮するとやはり、キャッシュの実装も当然必要ですし、表示部分についてのみ画像を取得しにいく対応が必要では?と思います。
が、実際ここまでやってみて思ったのは、果たしてこんな一般的に使われてそうな機能をここまで作り込まないといけないのだろうか?という疑問です。

ライブラリの使用

というわけで最終的にライブラリを探しました。
いろいろ試したわけではないのですが、評判よさそうだった Picasso を入れてみました。
結果的に adapter の getView の中に下記の一行を入れればうまくいきました。

SampleAdapter.java
Picasso.get().load(sample.getImgPath()).resize(100, 100).into(viewHolder.imgPath);

ここまでの苦労はなんだったんだ・・・という思いもありますが、アプリ開発に慣れてない身としてはけっこう勉強になりました。
Picasso が内部的にどうやって実装しているのか、というところも興味はありますが、まずは簡単なアプリを作れるようになることを優先したいので、今回はこの辺りまでとしました。