Androidでリスト表示を実装する場合、ArrayAdapterを使う。
頻繁に使う基本的なクラスではあるが、使い方やお作法をよく理解していないと思わぬバグを作りこんでしまうなかなか奥が深いクラスである。最近リスト表示に関するバグでどうしても原因がわからないものに遭遇したので、Androidのソースを調べてみた。
不可解なバグ
上記のバグというのは、アダプタにセットしたデータソースをclear()でいったん削除してから、新しいデータをセットしなおした時に新しいデータがアダプタに反映されていないように見えるというもの。
新しく作成したデータ(データA)をアダプタのデータソース(ArrayAdapterを生成する時にコンストラクタに渡したデータ:データB)にセットし直してそれぞれをログ出力すると、なぜかデータAとデータBの値が違うという現象が発生した。
データAの内容をデータBに追加するだけなので両者のデータは一致するはずだが、どうもログを見るとデータBが生成時のデータソースとは別のインスタンスを参照しているような動作をしている。
どういうことなの…。
clear()するとどーなる?
データソースに対して操作しているのはclear()とデータの追加のみで、データの追加ではインスタンスの参照が変更されることはありえないため、ArrayAdapterのclear()が怪しいと思いドキュメントを見ると以下のように記載してあった。
Remove all elements from the list.
参考
http://developer.android.com/reference/android/widget/ArrayAdapter.html#clear()
「全ての要素をリストから削除する」と書いてある。普通に読めばアダプタにセットしたデータソース(これは配列とか何らかのList)の要素を全てnullにするという動作だと思うが、今回はリスト=データソースの参照が変更されているのではないか疑惑があったので、実際の内部的な動作を確認してみた。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Remove all elements from the list. */ public void clear() { synchronized (mLock) { if (mOriginalValues != null) { mOriginalValues.clear(); } else { mObjects.clear(); } } if (mNotifyOnChange) notifyDataSetChanged(); } |
上記がArrayAdapter.clear()のソースである。ソースを見ればわかるとおり、ArrayAdapter.clear()はmOriginalValuesかmObjectsのclear()メソッド呼び出しへの単なるラッパーということがわかった。
ついでに言うと、ほとんどの場合でclear()呼び出し側でnotifyDatasetChanged()(アダプタのデータソースが変更されたことを通知するメソッド)を呼び出す必要はないようである(mNotifyOnChangeはアダプタ生成時にtrueに設定されている)。
ArrayAdapter.clear()=List.clear()
ArrayAdapter.clear()がメンバのclear()呼び出しへのラッパーということはわかったが、このmOriginalValuesとmObjectsは何者かというと、mObjectsがコンストラクタで設定されたデータソース、mOriginalValuesがそのコピーのようである(今回は関係ないので詳細は省略)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
/** * Contains the list of objects that represent the data of this ArrayAdapter. * The content of this list is referred to as "the array" in the documentation. */ private List<T> mObjects; 〜略〜 // A copy of the original mObjects array, initialized from and then used instead as soon as // the mFilter ArrayFilter is used. mObjects will then only contain the filtered values. private ArrayList<T> mOriginalValues; 〜略〜 public ArrayAdapter(Context context, int resource, int textViewResourceId, List<T> objects) { init(context, resource, textViewResourceId, objects); } 〜略〜 private void init(Context context, int resource, int textViewResourceId, List<T> objects) { mContext = context; mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); mResource = mDropDownResource = resource; mObjects = objects; mFieldId = textViewResourceId; } |
上記のソースを見るとわかるように、mObjectsの型はListとなっている。通常はAndroidでリストビューを使う場合、データソースに使用するのはArrayListがほとんどだろうから、今回はArrayListのclear()を調べればいいだろう。
ということで、ようやく目的のclear()の実際の動作を確認するところまで辿り着いた。
ArrayList.clear()の内部実装
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Removes all elements from this {@code ArrayList}, leaving it empty. * * @see #isEmpty * @see #size */ @Override public void clear() { if (size != 0) { Arrays.fill(array, 0, size, null); size = 0; modCount++; } } |
上記がArrayList.clear()のソースである。これを見ると、arrayの0番目からsize-1番目(つまり全ての要素)にnullを設定するという動作になっている。つまりドキュメントに記載されている通り、まさしくデータソースの中身を全て削除=nullにするという動作である。
これで当初の「ArrayAdapter.clear()がインスタンスの参照に何か悪さしているのではなかろうか?」という疑問は無事解消できた。スッキリ!
ついでにArrayListがデータ数をメンバとして管理していること、modCountというデータが変更された回数を管理するメンバを持つことがわかった。ArrayList.size()を呼び出すとこのキャッシュした値を返すようだ。へー。
PS
よくよく考えてみると問題のバグはBroadcastReceiverでイベントを受信した時にアダプタのデータソースを変更する処理をしていたので、よくある変数の同期ミスのような気がしてきた…orz
<追記 2015/01/31>
解決しました。→ArrayAdapterをカスタマイズする時に絶対にやってはいけないこと