【Android】ListViewのレイアウトを動的に切り替える際の問題点

以前[Android](Expandable)ListViewで各データごとに表示を切り替えると言う記事で作ったアイテムごとに動的にレイアウトを切り替えられるListViewですが、ViewHolderを使った時にちょっとした問題が起こったので、メモしておきます。

[2013/03/28追記]

なんかやたら検索されるので軽い補足。

ListViewそのもののレイアウトを変えたい場合はお帰りください。Adapterの話しかしません。

また、この記事では当たり前のように外部ライブラリを使用してます。

使いたくない場合は以下のコードに出てくるR4<View, Hoge, View, ViewGroup, LayoutInflater>の代わりにこんなインターフェースを作ればOKです。

public interface ViewProvider<T> {
    public View call(T data, View convertView, ViewGroup parent, LayoutInflater layoutInflater);
}

このViewProvider#callのシグネチャはAdapterのgetViewとLayoutInflater以外一緒です。

Adapterの中をこんな風にしておけばActivity側から切り替えられます。

public class HogeAdapter extends ArrayAdapter<Hoge> {
    
    private LayoutInflater _layoutInflater;
    private ViewProvider<Hoge> _viewProvider;
    
    public HogeAdapter(Context cont, List<Hoge> objects, ViewProvider<Hoge> viewProvider) {
        super(cont, 0, objects);
        _layoutInflater = (LayoutInflater) cont.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        _viewProvider = viewProvider;
    }
    
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Hoge data = (Hoge) this.getItem(position);
        return _viewProvider.call(data, convertView, parent, _layoutInflater);
    }
    
    public void setViewProvider(ViewProvider<Hoge> viewProvider) {
        _viewProvider = viewProvider;
    }
}

後はまぁ、後述する問題と全く一緒のものが出ますので、適当に読んでみてください。

[2013/03/28追記ここまで]

ViewHolderパターン

2010年のGoogle I/Oで紹介されていたListViewの高速化技法の一つとして、ViewHolderパターンとでも呼ぶべきパターンがあります。

発表資料のpdfからコードをそのまま引用すると、こんな感じです。

static class ViewHolder {
    TextView text;
    ImageView icon;
}

public View getView(int position, View convertView, ViewGroup parent) {
    ViewHolder holder;
    if (convertView == null) {
        convertView = mInflater.inflate(R.layout.list_item_icon_text, parent, false);
        holder = new ViewHolder();
        holder.text = (TextView) convertView.findViewById(R.id.text);
        holder.icon = (ImageView) convertView.findViewById(R.id.icon);
        convertView.setTag(holder);
    } else {
        holder = (ViewHolder) convertView.getTag();
    }
    
    holder.text.setText(DATA[position]);
    holder.icon.setImageBitmap((position & 1) == 1 ? mIcon1 : mIcon2);
    return convertView;
}

inflateしたレイアウトの各Viewの参照をViewHolderと言うクラスに保存しておき、convertViewのTagにセットすることで使いまわすと言う方法です。

getViewのシグネチャであるconvertViewは初回時はnullで来るので、そこで判定するわけです。

問題点

このViewHolderパターンとレイアウト動的切り替えは非常に相性が悪いです。

簡単な例としてはこんな感じです。まず以下のようなデータクラスがあるとします。

public class Hoge {
    private String piyo;
    private R4<View, Hoge, View, ViewGroup, LayoutInflater> viewProvider;
    
    public Hoge(String piyo, R4<View, Hoge, View, ViewGroup, LayoutInflater> viewProvider) {
        this.piyo = piyo;
        this.viewProvider = viewProvider;
    }
    
    //getterは省略
}

そしてこのHogeクラスを使うArrayAdapterを作ります。

getViewで返すViewは、Hogeクラスが持つviewProviderに委譲します。

public class HogeAdapter extends ArrayAdapter<Hoge> {
    
    private LayoutInflater _layoutInflater;
    
    public HogeAdapter(Context cont, List<Hoge> objects) {
        super(cont, 0, objects);
        _layoutInflater = (LayoutInflater) cont.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }
    
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Hoge data = (Hoge) this.getItem(position);
        return data.getViewProvider().call(data, convertView, parent, _layoutInflater);
    }
    
}

また、ViewHolderも作っておきます。

public class ViewHolder {
    TextView txvPiyo;
    TextView txvFoo;
}

最後に、実際にAdapterを作ってListViewにセットしてみます。

public class FugaActivity extends Activity {
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_fuga);
        
        //表示させるViewを作成するFunction
        R4<View, Hoge, View, ViewGroup, LayoutInflater> defaultViewProvider =
            new R4<View, Hoge, View, ViewGroup, LayoutInflater>() {
                @Override
                public View call(Hoge data, View convertView, ViewGroup parent, LayoutInflater layoutInflater) {
                    ViewHolder vh = null;
                    if(convertView == null) {
                        convertView = layoutInflater.inflate(R.layout.adapter_hoge, parent, false);
                        vh = new ViewHolder();
                        vh.txvPiyo = (TextView) convertView.findViewById(R.id.txvPiyo);
                        convertView.setTag(vh);
                    } else {
                        vh = (ViewHolder) convertView.getTag();
                    }
                    
                    vh.txvPiyo.setText(data.getPiyo());
                    
                    return convertView;
                }
            };
        
        List<Hoge> hogeList = new ArrayList<Hoge>();
        hogeList.add(new Hoge("1", defaultViewProvider));
        hogeList.add(new Hoge("2", defaultViewProvider));
        //以下、画面がスクロールできるぐらいhogeListにデータを格納する
        
        //最後に別のレイアウトを使うHogeを作成する
        hogeList.add(new Hoge("End", new R4<View, Hoge, View, ViewGroup, LayoutInflater>(){
            @Override
            public View call(Hoge data, View convertView, ViewGroup parent, LayoutInflater layoutInflater) {
                ViewHolder vh = null;
                if(convertView == null) {
                    convertView = layoutInflater.inflate(R.layout.adapter_foo, parent, false);
                    vh = new ViewHolder();
                    vh.txvFoo = (TextView) convertView.findViewById(R.id.txvFoo);
                    convertView.setTag(vh);
                } else {
                    vh = (ViewHolder) convertView.getTag();
                }
                
                vh.txvFoo.setText(data.getPiyo());
                
                return convertView;
            }
        });
        
        HogeAdapter adapter = new HogeAdapter(getApplicationContext(), hogeList);
        ((ListView) findViewById(R.id.listview)).setAdapter(adapter);
    }
}

これでずらーっとadapter_hoge.xmlを使ったレイアウトが並んだ後、最後の1データだけadapter_foo.xmlを使うListViewの出来上がりです。

が、恐らく最後の1データを表示するときにNullPointerExceptionが飛んでくるはずです。

こんな単純な仕組みで何がぬるぽになるのかと言うと、vh.txvFooです。

convertViewの仕組み

わざわざコメントに「//以下、画面がスクロールできるぐらいhogeListにデータを格納する」と書いたのがミソで、getViewのシグネチャであるconvertViewは基本的に画面上からスクロールで消えてしまったViewがそのまま入ってきます。ViewHolderパターンはそれを利用してTagをセットしておき、後で取り出していると言うわけです。

スクロールで消えたViewがそのまま順番に流れてくると言うわけでもないらしいです。その辺は検証している人がいたのでそっちの記事読んでください。

当然最後に表示されるアイテムのconvertViewにも値が入っています。つまりconvertView != nullです。そしてgetTagでViewHolderを取得します。当然そのViewHolderはtxvFooに対する参照を持っていません。そりゃvh.txvFoo.setTextでぬるぽも出るってもんです。

対処法

結局のところ、ViewHolderをレイアウトのリソースごとに分割してgetView内でinstanceofで判断するのが一番早いです。

そんなわけでとりあえず分割しましょう。ついでにコンストラクタで各Viewを初期化しておくと楽です。readonlyって書きたくなりますね。

public class PiyoViewHolder {
    TextView txvPiyo;
    
    public PiyoViewHolder(View v) {
        txvPiyo = (TextView) v.findViewById(R.id.txvPiyo);
    }
}

public class FooViewHolder {
    TextView txvFoo;
    
    public FooViewHolder(View v) {
        txvFoo = (TextView) v.findViewById(R.id.txvFoo);
    }
}

次にviewProviderに色々と処理を追加します。

public class FugaActivity extends Activity {
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_fuga);
        
        //表示させるViewを作成するFunction
        R4<View, Hoge, View, ViewGroup, LayoutInflater> defaultViewProvider =
            new R4<View, Hoge, View, ViewGroup, LayoutInflater>() {
                @Override
                public View call(Hoge data, View convertView, ViewGroup parent, LayoutInflater layoutInflater) {
                    PiyoViewHolder vh = null;
                    if(convertView == null) {
                        
                        convertView = layoutInflater.inflate(R.layout.adapter_hoge, parent, false);
                        vh = new PiyoViewHolder(convertView);
                        convertView.setTag(vh);
                    } else {
                        //convertViewのTagがPiyoViewHolderかどうかを調べる
                        if(!(convertView.getTag() instanceof PiyoViewHolder)) {
                            //PiyoViewHolder以外の場合には初期化する
                            convertView = layoutInflater.inflate(R.layout.adapter_hoge, parent, false);
                            vh = new PiyoViewHolder(convertView);
                            convertView.setTag(vh);
                        } else {
                            vh = (PiyoViewHolder) convertView.getTag();
                        }
                    }
                    
                    vh.txvPiyo.setText(data.getPiyo());
                    
                    return convertView;
                }
            };
        
        List<Hoge> hogeList = new ArrayList<Hoge>();
        hogeList.add(new Hoge("1", defaultViewProvider));
        hogeList.add(new Hoge("2", defaultViewProvider));
        //以下、画面がスクロールできるぐらいhogeListにデータを格納する
        
        //最後に別のレイアウトを使うHogeを作成する
        hogeList.add(new Hoge("End", new R4<View, Hoge, View, ViewGroup, LayoutInflater>(){
            @Override
            public View call(Hoge data, View convertView, ViewGroup parent, LayoutInflater layoutInflater) {
                FooViewHolder vh = null;
                if(convertView == null) {
                    convertView = layoutInflater.inflate(R.layout.adapter_foo, parent, false);
                    vh = new FooViewHolder(convertView);
                    convertView.setTag(vh);
                } else {
                    //convertViewのTagがFooViewHolderかどうかを調べる
                    if(!(convertView.getTag() instanceof FooViewHolder)) {
                        //FooViewHolder以外の場合には初期化する
                        convertView = layoutInflater.inflate(R.layout.adapter_foo, parent, false);
                        vh = new FooViewHolder(convertView);
                        convertView.setTag(vh);
                    } else {
                        vh = (FooViewHolder) convertView.getTag();
                    }
                }
                
                vh.txvFoo.setText(data.getPiyo());
                
                return convertView;
            }
        });
        
        HogeAdapter adapter = new HogeAdapter(getApplicationContext(), hogeList);
        ((ListView) findViewById(R.id.listview)).setAdapter(adapter);
    }
}

まとめ

前回も書きましたが、もともとコスト度外視なやり方なので、heightがmatch_parentなListViewでスクロールするほどデータを詰め込む時点である程度カクカクすることも覚悟しといた方がいいんですが、ViewHolderパターンまでやめてしまうと目に見えてパフォーマンスに影響が出るようになったので、対処法をメモしておきました。

参考