【Android】ListViewを使うための基礎知識(1)

Androidアプリを作り始めた人が必ずつまづくListViewバッドノウハウを書き溜めていこうと思います。

AdapterViewのしくみ

全体的にそうなんですが、ListViewはListViewのドキュメントだけ読んでいても全然わかりません。AbsListViewのドキュメントもあわせて読む必要があります。AdapterViewのドキュメントも読んでおくとなおよしです。

と言うか、結構な人がAdapterViewについて理解していません。さらっと解説しておきましょう。

Class Overviewには

An AdapterView is a view whose children are determined by an Adapter.

と書かれています。って言うかそれしか書いてありません。とりあえず何の役割を持っているかだけでも知っておきましょう。

View生成の委譲

AdapterViewを継承しているViewは中で表示するViewのことを一切知りません。中のViewはAdapterが生成することになっているからです。

そう、あの忌まわしきgetViewメソッドによって生成されます。

クリックイベントの管理

AdapterViewはどんなデータがどんなレイアウトで表示されているかなど全く知りませんが、何番目のアイテムがクリックされたのかぐらいはわかります。

だから以下のクリックイベントを管理するListenerを登録することができます。

各Listenerのメソッドシグネチャを見てみると必ずこうなっています。

  • AdapterView<?> parent
  • View view
  • int position
  • long id

parentはそのListenerが登録されているAdapterViewです。viewはクリックされたview、positionはクリックされたアイテムの表示位置。idはAdapter#getItemId(int)で取得した値です。

たまにListView+OnItemClickListenerのサンプルとして、改めてonItemClick内でListViewを取得するものが紹介されているんですが、そんなことする必要はありません。parentがそれです。キャストすれば普通に使えます。

ListView listView = (ListView) findViewById(R.id.listview);

listView.setOnItemClickListener(new OnItemClickListener() {
    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        ListView lv = (ListView) parent;
        //TODO: click event
    }
});

もっと言えば、単にpositionからアイテムを取得するだけならAdapterView#getItemAtPositionを使えばいいです。返ってくるのがObjectなのが悲しいですね…。

ListView listView = (ListView) findViewById(R.id.listview);

listView.setOnItemClickListener(new OnItemClickListener() {
    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        Object item = parent.getItemAtPosition(position);
        
        // 以下と全く同じ処理。下手したらfindViewByIdを使ってるだけ遅いかも。
        // ListView lv = (ListView) findViewById(R.id.listview)
        // ListAdapter adapter = lv.getAdapter();
        // Object item = adapter.getItem(position);
    }
});

Adapterのしくみ

Adapterの種類

じゃあ次はAdapterを見ていきましょう。と言ってもAdapterのサブクラスが既に大量に用意されているので、それぞれどんな時に使うべきか整理しておきましょう。

Adapter 概要
BaseAdapter インターフェースであるAdapterの基本的な実装。
(ただし実際にimplementしているのはListAdapterとSpinnerAdapter)
SpinnerAdapter Spinner用のAdapter。
ListAdapter ListViewで表示するために最適化されたAdapter。
HeaderViewListAdapter ヘッダ用のViewを持ったListAdapter。
使われることはまずないし、public classとして存在している理由もわからない。
きっとこれを作った人がパッケージプライベートの存在を知らなかったのだろう。
WrapperListAdapter ListViewのaddHeaderView / addHeaderViewを呼ぶと内部のAdapterが勝手にこれに置き換わる。
ArrayAdapter<T> List構造の表示に特化。
List構造ならなんでもいいので汎用性があまりにも高い。
使い方さえ覚えてしまえば他のAdapterを一切使わなくても済む。
CursorAdapter CursorをListViewに紐付けることに適したAdapter。
ContentProcider経由で取得したデータを扱う時に使うと便利。
アプリ内のみで使用するDBでも適用できるが、「_id」と言うカラムが存在しないテーブルには使えないので注意。
また、Support Libary版が存在するのでimport時には注意。
ResourceCursorAdapter CursorAdapterをレイアウトXMLと紐付るためのAdapter。
これもSupport Libary版が存在する。
SimpleAdapter staticなコレクションを表示するのに最適。
シンプルと銘打っている割に面倒臭い。
SimpleCursorAdapter 簡単に使えるような努力をした形跡が見られるCursorAdapter。
簡単には使えない。
当然Support Library版が用意されている。

ちなみに、今回は説明しませんがExpandableListAdapterはAdapterの系譜ではありません。Group(親)とChild(子)の概念が必要になるからですね。

Adapter#getViewとconvertView

それじゃあ次にAdapter#getViewを見ていきましょう。

これは先ほど説明した通り、AdapterViewで表示するViewを生成するためのメソッドです。Adapterの最も重要な役割と言っても過言ではありません。

パラメータとしてこのような要素が渡されてきます。

position
表示するitemの位置
convertView
以前まで表示されていたView
parent
getViewで生成されたViewの親となるViewGroup

基本的な流れとしては

  1. convertViewを使いまわすか判断する
    • 使いまわせない場合はLayoutInflaterで使用するViewを生成する
  2. positionとAdapter#getItemを使って表示すべきアイテムを取得する
  3. 1で取得したViewと2で取得したアイテムの情報を紐付ける

となります。

convertViewが使いまわせるかどうかの判定は非常に大事です。LayoutInflaterによる生成はかなり高コストなため、出来る限り使用しないよう気をつける必要があります。

じゃあどうやって判断するかですが、nullかどうか見てあげればOKです。

public View getView(int position, View convertView, ViewGroup parent) {
    View v = convertView;
    if(v == null) {
        v = _inflater.inflate(R.layout.adapter_test, null);
    }

    //TODO: get item and bind view properties

    return v;
}

このconvertViewには画面上から表示しきれなくなったViewがやってきます。そのため、初回生成時にはnullとなっています。

表示しきれなくなったViewであっても(普通は)同じレイアウトリソースから生成されたViewでしょう。なので、内部で持っているViewの情報だけ書き換えてあげれば使いまわせるわけです。

やりようによってはアイテムが持っている諸々(フラグとか)からレイアウトリソースを決めることも出来ますが、convertViewを上手く使いまわすための工夫は入れておくべきでしょう。

public View getView(int position, View convertView, ViewGroup parent) {
    View v = convertView;
    MyData item = (MyData) super.getItem(position);
    int resourceId = item.isA() ? R.layout.adapter_a : R.layout.adapter_b;
    
    if(v == null) {
        v = _inflater.inflate(resourceId, null);
        // このViewのResource IDをTagとして持たせておく
        v.setTag(resourceId);
    } else {
        // convertViewがnullでなければResource IDを取得する
        int prevResourceId = (int) v.getTag();

        if(resourceId != prevResourceId) {
            v = _inflater.inflate(resourceId);
            v.setTag(resourceId);
        }
    }

    //TODO: bind view properties

    return v;
}

各AdapterのgetView

折角だからArrayAdapterのソースでも覗いてみましょう。

ArrayAdapterのコンストラクタではレイアウトファイルのIDとデータを表示するためのTextViewのIDを渡すことができます。

このコンストラクタで渡されたIDとitemsを使って表示してくれるわけですね。

ListView listView = (ListView) findViewById(R.id.listview);
String[] items = {"a", "b", "c"};
ArrayAdapter<String> adapter
    = new ArrayAdapter<String>(getApplicationContext()
        // 表示したいレイアウトXMLのID
        , R.layout.adapter_array
        // レイアウトXMLの中にあるTextViewのID
        , R.id.textview
        // 表示するデータ
        , items);
listView.setAdapter(adapter);

public View getView(int position, View convertView, ViewGroup parent) {
    return createViewFromResource(position, convertView, parent, mResource);
}

private View createViewFromResource(int position, View convertView, ViewGroup parent,
        int resource) {
    View view;
    TextView text;

    if (convertView == null) {
        // ここのresourceがR.layout.adapter_array
        view = mInflater.inflate(resource, parent, false);
    } else {
        view = convertView;
    }

    try {
        // mFieldIdがR.id.textview
        if (mFieldId == 0) {
            text = (TextView) view;
        } else {
            text = (TextView) view.findViewById(mFieldId);
        }
    } catch (ClassCastException e) {
        Log.e("ArrayAdapter", "You must supply a resource ID for a TextView");
        throw new IllegalStateException(
                "ArrayAdapter requires the resource ID to be a TextView", e);
    }

    T item = getItem(position);
    if (item instanceof CharSequence) {
        text.setText((CharSequence)item);
    } else {
        text.setText(item.toString());
    }

    return view;
}

SimpleAdapterではもうちょっと手の込んだことをしています。

SimpleAdapterのコンストラクタは中々複雑です。

List<? extends Map<String, String>> dataList = new List<? extends Map<String, String>>();

// ひとつめのデータ
HashMap<String, String> data1 = new HashMap<String, String>();
data1.put("key1", "value1-1");
data1.put("key2", "value1-2");

dataList.add(data1);

// ふたつめのデータ
HashMap<String, String> data2 = new HashMap<String, String>();
data2.put("key1", "value2-1");
data2.put("key2", "value2-2");

dataList.add(data2);

ListView listView = (ListView) findViewById(R.id.listview);
String[] keys = {"key1", "key2"};

SimpleAdapter adapter
    = new SimpleAdapter(getApplicationContext()
        // 表示するデータ
        , dataList
        // 表示したいレイアウトXMLのID
        , R.layout.adapter_simple
        // Mapに登録したKEYの配列
        , keys
        // KEYと紐付けるViewのIDの配列
        , new int[]{
            R.id.textview_key_1,
            R.id.textview_key_2
        });

listView.setAdapter(adapter);

public View getView(int position, View convertView, ViewGroup parent) {
    return createViewFromResource(position, convertView, parent, mResource);
}

private View createViewFromResource(int position, View convertView,
        ViewGroup parent, int resource) {
    View v;
    if (convertView == null) {
        // ここのresourceがR.layout.adapter_simple
        v = mInflater.inflate(resource, parent, false);
    } else {
        v = convertView;
    }

    bindView(position, v);

    return v;
}

private void bindView(int position, View view) {
    // mDataがdataList
    final Map dataSet = mData.get(position);
    if (dataSet == null) {
        return;
    }

    final ViewBinder binder = mViewBinder;
    final String[] from = mFrom;
    final int[] to = mTo;
    final int count = to.length;

    for (int i = 0; i < count; i++) {
        // R.id.textview_key_1, R.id.textview_key_2のどちらかを取得
        final View v = view.findViewById(to[i]);
        if (v != null) {
            // "key1", "key2"のどちらかを取得
            final Object data = dataSet.get(from[i]);
            String text = data == null ? "" : data.toString();
            if (text == null) {
                text = "";
            }

            boolean bound = false;
            if (binder != null) {
                bound = binder.setViewValue(v, data, text);
            }

            if (!bound) {
                // 取得したViewの種類によって何をするのか振り分けている
                if (v instanceof Checkable) {
                    if (data instanceof Boolean) {
                        ((Checkable) v).setChecked((Boolean) data);
                    } else if (v instanceof TextView) {
                        setViewText((TextView) v, text);
                    } else {
                        throw new IllegalStateException(v.getClass().getName() +
                                " should be bound to a Boolean, not a " +
                                (data == null ? "<unknown type>" : data.getClass()));
                    }
                } else if (v instanceof TextView) {
                    setViewText((TextView) v, text);
                } else if (v instanceof ImageView) {
                    if (data instanceof Integer) {
                        setViewImage((ImageView) v, (Integer) data);
                    } else {
                        setViewImage((ImageView) v, text);
                    }
                } else {
                    throw new IllegalStateException(v.getClass().getName() + " is not a " +
                            " view that can be bounds by this SimpleAdapter");
                }
            }
        }
    }
}

とまぁ、見ての通り全然シンプルじゃないです。っつーかいちいち全部同じkeyを持ったMapを作らせるとか正気とは思えませんね。

SDKに事前に用意されているものでこれですから、汎用的なAdapterを作るのは相当難しいと考えていいです。そして作るべきではありません。

自作AdapterとViewHolderパターン

「独自のデータクラスを使いたい」「独自のレイアウトを使いたい」と言う場合は、何かしらのAdapterを継承し、getViewをオーバーライドしなければなりません。

継承するのにオススメなのは断然ArrayAdapterです。

まず、データソースがなんであれ、自分で変換してListに詰め込むぐらいならどうにでもなるでしょう。

また、addremovesortと言ったコレクション操作ライクなメソッドや、アイテムからpositionを割り出すgetPositionのようなメソッドArrayAdapterだけが唯一最初から実装しています。

独自クラスの場合はequalsやhashCodeをオーバーライドしておけばさらに便利になりますし、Serializableを実装しておけば「クリックされたアイテムを他Activityに渡すIntent」のようなものも簡単に作れます。

そんなわけで、ArrayAdapterを継承したものを作ってみましょう。

public class MyData {
    private int id;
    private String name;
    // getter / setterは省略
}

public class MyAdapter extends ArrayAdapter<MyData> {

    private final LayoutInflater _inflater;

    public MyAdapter(Context context, List<MyData> objects) {
        // resourceのIDを受け取らない場合、
        // 親のコンストラクタは0を指定しておけばOK
        super(context, 0, objects);
        _inflater = LayoutInflater.from(context);
    }

    public MyAdapter(Context context, MyData[] objects) {
        super(context, 0, objects);
        _inflater = LayoutInflater.from(context);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View v = convertView;
        
        if(v == null) {
            v = _inflater.inflate(R.layout.adapter_test, null);
        }

        MyData data = super.getItem(position);

        ((TextView)v.findViewById(R.id.txtId)).setText(String.valueOf(data.getId());
        ((TextView)v.findViewById(R.id.txtName)).setText(data.getName());

        return v;
    }
}

getView内で使うLayoutInflaterコンストラクタで作ってしまいましょう。

また、Contextは後から取得できないので必要なら保持しておきましょう。

ただし(Viewの生成を除く)Contextが必要な処理をAdapter内に記述するのは基本的にはよくないことです。あくまでもAdapterは「AdapterViewで表示するViewを生成する」ためのオブジェクトですから、Viewの表示以外のことをするべきではありません…。

また、「何度も何度もfindViewByIdをするのはコスト的に問題がある」と言う事で、ViewHolderパターンと呼ばれるものを使うことがほとんどです。

public class MyAdapter extends ArrayAdapter<MyData> {

    private final LayoutInflater _inflater;

    static class ViewHolder {
        TextView id;
        TextView name;
    }

    public MyAdapter(Context context, List<MyData> objects) {
        // resourceのIDを受け取らない場合、

        super(context, 0, objects);
        _inflater = LayoutInflater.from(context);
    }

    public MyAdapter(Context context, MyData[] objects) {
        super(context, 0, objects);
        _inflater = LayoutInflater.from(context);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View v = convertView;
        ViewHolder vh = null;
        if(v == null) {
            v = _inflater.inflate(R.layout.adapter_test, null);
            vh = new ViewHolder();
            vh.id = (TextView)v.findViewById(R.id.txtId);
            vh.name = (TextView)v.findViewById(R.id.txtName);
            v.setTag(vh);
        } else {
            vh = (ViewHolder) v.getTag();
        }

        MyData data = super.getItem(position);

        vh.id.setText(String.valueOf(data.getId());
        vh.name.setText(data.getName());

        return v;
    }
}

事前にViewの参照だけを保持しておくことで更にコストを低減させることができます。

やってみるとわかるんですが、かなり高速化されます。ListView、と言うか、Adapterを使うのであれば必ず覚えておきましょう。

(一旦の)まとめ

とりあえず今回はAdapterに焦点を絞って説明しました。DataSetObserverの話とかもしたかったんですが、字数が全然足りないですね。

また時間がある時に思いつくままに書こうと思います…。