【Android】FragmentPagerAdapterを実装し直す(補足編)

前回の記事に対する補足事項です。

文字数制限に引っかかってしまったので別の記事になってしまいました。

dataSetChangedの動き

そもそもPagerAdapter#notifyDataSetChanged()が呼ばれるとどうなるのでしょうか。

ViewPagerdataSetChangedの動きをたどってみましょう。

void dataSetChanged() {
    // This method only gets called if our observer is attached, so mAdapter is non-null.

    final int adapterCount = mAdapter.getCount();
    mExpectedAdapterCount = adapterCount;
    boolean needPopulate = mItems.size() < mOffscreenPageLimit * 2 + 1 &&
            mItems.size() < adapterCount;
    int newCurrItem = mCurItem;

    boolean isUpdating = false;
    for (int i = 0; i < mItems.size(); i++) {
        final ItemInfo ii = mItems.get(i);
        final int newPos = mAdapter.getItemPosition(ii.object);

        if (newPos == PagerAdapter.POSITION_UNCHANGED) {
            continue;
        }

        if (newPos == PagerAdapter.POSITION_NONE) {
            mItems.remove(i);
            i--;

            if (!isUpdating) {
                mAdapter.startUpdate(this);
                isUpdating = true;
            }

            mAdapter.destroyItem(this, ii.position, ii.object);
            needPopulate = true;

            if (mCurItem == ii.position) {
                // Keep the current item in the valid range
                newCurrItem = Math.max(0, Math.min(mCurItem, adapterCount - 1));
                needPopulate = true;
            }
            continue;
        }

        if (ii.position != newPos) {
            if (ii.position == mCurItem) {
                // Our current item changed position. Follow it.
                newCurrItem = newPos;
            }

            ii.position = newPos;
            needPopulate = true;
        }
    }

    if (isUpdating) {
        mAdapter.finishUpdate(this);
    }

    Collections.sort(mItems, COMPARATOR);

    if (needPopulate) {
        // Reset our known page widths; populate will recompute them.
        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = getChildAt(i);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            if (!lp.isDecor) {
                lp.widthFactor = 0.f;
            }
        }

        setCurrentItemInternal(newCurrItem, false, true);
        requestLayout();
    }
}

void populate() {
    populate(mCurItem);
}

void populate(int newCurrentItem) {
    ItemInfo oldCurInfo = null;
    int focusDirection = View.FOCUS_FORWARD;
    if (mCurItem != newCurrentItem) {
        focusDirection = mCurItem < newCurrentItem ? View.FOCUS_RIGHT : View.FOCUS_LEFT;
        oldCurInfo = infoForPosition(mCurItem);
        mCurItem = newCurrentItem;
    }

    if (mAdapter == null) {
        sortChildDrawingOrder();
        return;
    }

    // Bail now if we are waiting to populate.  This is to hold off
    // on creating views from the time the user releases their finger to
    // fling to a new position until we have finished the scroll to
    // that position, avoiding glitches from happening at that point.
    if (mPopulatePending) {
        if (DEBUG) Log.i(TAG, "populate is pending, skipping for now...");
        sortChildDrawingOrder();
        return;
    }

    // Also, don't populate until we are attached to a window.  This is to
    // avoid trying to populate before we have restored our view hierarchy
    // state and conflicting with what is restored.
    if (getWindowToken() == null) {
        return;
    }

    mAdapter.startUpdate(this);

    final int pageLimit = mOffscreenPageLimit;
    final int startPos = Math.max(0, mCurItem - pageLimit);
    final int N = mAdapter.getCount();
    final int endPos = Math.min(N-1, mCurItem + pageLimit);

    if (N != mExpectedAdapterCount) {
        String resName;
        try {
            resName = getResources().getResourceName(getId());
        } catch (Resources.NotFoundException e) {
            resName = Integer.toHexString(getId());
        }
        throw new IllegalStateException("The application's PagerAdapter changed the adapter's" +
                " contents without calling PagerAdapter#notifyDataSetChanged!" +
                " Expected adapter item count: " + mExpectedAdapterCount + ", found: " + N +
                " Pager id: " + resName +
                " Pager class: " + getClass() +
                " Problematic adapter: " + mAdapter.getClass());
    }

    // Locate the currently focused item or add it if needed.
    int curIndex = -1;
    ItemInfo curItem = null;
    for (curIndex = 0; curIndex < mItems.size(); curIndex++) {
        final ItemInfo ii = mItems.get(curIndex);
        if (ii.position >= mCurItem) {
            if (ii.position == mCurItem) curItem = ii;
            break;
        }
    }

    if (curItem == null && N > 0) {
        curItem = addNewItem(mCurItem, curIndex);
    }

    // Fill 3x the available width or up to the number of offscreen
    // pages requested to either side, whichever is larger.
    // If we have no current item we have no work to do.
    if (curItem != null) {
        float extraWidthLeft = 0.f;
        int itemIndex = curIndex - 1;
        ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
        final int clientWidth = getClientWidth();
        final float leftWidthNeeded = clientWidth <= 0 ? 0 :
                2.f - curItem.widthFactor + (float) getPaddingLeft() / (float) clientWidth;
        for (int pos = mCurItem - 1; pos >= 0; pos--) {
            if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {
                if (ii == null) {
                    break;
                }
                if (pos == ii.position && !ii.scrolling) {
                    mItems.remove(itemIndex);
                    mAdapter.destroyItem(this, pos, ii.object);
                    if (DEBUG) {
                        Log.i(TAG, "populate() - destroyItem() with pos: " + pos +
                                " view: " + ((View) ii.object));
                    }
                    itemIndex--;
                    curIndex--;
                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                }
            } else if (ii != null && pos == ii.position) {
                extraWidthLeft += ii.widthFactor;
                itemIndex--;
                ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
            } else {
                ii = addNewItem(pos, itemIndex + 1);
                extraWidthLeft += ii.widthFactor;
                curIndex++;
                ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
            }
        }

        float extraWidthRight = curItem.widthFactor;
        itemIndex = curIndex + 1;
        if (extraWidthRight < 2.f) {
            ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
            final float rightWidthNeeded = clientWidth <= 0 ? 0 :
                    (float) getPaddingRight() / (float) clientWidth + 2.f;
            for (int pos = mCurItem + 1; pos < N; pos++) {
                if (extraWidthRight >= rightWidthNeeded && pos > endPos) {
                    if (ii == null) {
                        break;
                    }
                    if (pos == ii.position && !ii.scrolling) {
                        mItems.remove(itemIndex);
                        mAdapter.destroyItem(this, pos, ii.object);
                        if (DEBUG) {
                            Log.i(TAG, "populate() - destroyItem() with pos: " + pos +
                                    " view: " + ((View) ii.object));
                        }
                        ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
                    }
                } else if (ii != null && pos == ii.position) {
                    extraWidthRight += ii.widthFactor;
                    itemIndex++;
                    ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
                } else {
                    ii = addNewItem(pos, itemIndex);
                    itemIndex++;
                    extraWidthRight += ii.widthFactor;
                    ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
                }
            }
        }

        calculatePageOffsets(curItem, curIndex, oldCurInfo);
    }

    // 省略
}

ItemInfo addNewItem(int position, int index) {
    ItemInfo ii = new ItemInfo();
    ii.position = position;
    ii.object = mAdapter.instantiateItem(this, position);
    ii.widthFactor = mAdapter.getPageWidth(position);
    if (index < 0 || index >= mItems.size()) {
        mItems.add(ii);
    } else {
        mItems.add(index, ii);
    }
    return ii;
}

ItemInfo

ViewPagerは内部クラスとしてItemInfoと言うものを持っています。これは非常にシンプルな作りです。

static class ItemInfo {
    Object object;
    int position;
    boolean scrolling;
    float widthFactor;
    float offset;
}

現在表示されている(自分自身+両隣ぐらい)のItemInfoをmItemsと言うArrayListでキャッシュしており、ちょっとスワイプするぐらいならこのキャッシュからとってきます。処理としては極めて妥当です。

PagerAdapter#notifyDataSetChanged()を受けてViewPagerがやるべきことは、このmItemsに変更があるかどうかを調べることです。

PagerAdapter#getItemPosition(Object)

現在表示されている(mItemsが所持している)何かをPagerAdapter#getItemPosition(Object)に渡し、位置が変わっているかどうかを調べます。

final ItemInfo ii = mItems.get(i);
final int newPos = mAdapter.getItemPosition(ii.object);

if (newPos == PagerAdapter.POSITION_UNCHANGED) {
    continue;
}

if (newPos == PagerAdapter.POSITION_NONE) {
    mItems.remove(i);
    i--;

    if (!isUpdating) {
        mAdapter.startUpdate(this);
        isUpdating = true;
    }

    mAdapter.destroyItem(this, ii.position, ii.object);
    needPopulate = true;

    if (mCurItem == ii.position) {
        // Keep the current item in the valid range
        newCurrItem = Math.max(0, Math.min(mCurItem, adapterCount - 1));
        needPopulate = true;
    }
    continue;
}

if (ii.position != newPos) {
    if (ii.position == mCurItem) {
        // Our current item changed position. Follow it.
        newCurrItem = newPos;
    }

    ii.position = newPos;
    needPopulate = true;
}

PagerAdapter#POSITION_UNCHANGEDないしはItemInfoと同じpositionが返ってきた場合は何もしません。

PagerAdapter#POSITION_NONEが返ってきた場合はPagerAdapter#destoryItem(ViewGroup, int, Object)を呼び出し、なくなったItemが現在表示しているItemと同じ位置であれば場所を調整します。

ItemInfoと違うpositionが返ってきたら新しい場所に入れ替えます。

ViewPagerでgetItemPositionはここからしか呼んでいません。

オブジェクトの再生成をなるべく控えようとするのは理解できるんですが、あまりスマートなやり方には見えません。少なくともPOSITION_UNCHANGEDと言う定数は必要なかったと思います。

PagerAdapter#destoryItem(ViewGroup, int, Object)とViewPager#populate()

dataSetChangedのPagerAdapter#getItemPosition(Object)でPagerAdapter#POSITION_NONEが返されるとこれが呼ばれる、と言うのは先述した通りですが、その場合ViewPager#populate()も必ず呼ばれます。

そしてpopulateのこの部分。

final int N = mAdapter.getCount();

if (N != mExpectedAdapterCount) {
    String resName;
    try {
        resName = getResources().getResourceName(getId());
    } catch (Resources.NotFoundException e) {
        resName = Integer.toHexString(getId());
    }
    throw new IllegalStateException("The application's PagerAdapter changed the adapter's" +
            " contents without calling PagerAdapter#notifyDataSetChanged!" +
            " Expected adapter item count: " + mExpectedAdapterCount + ", found: " + N +
            " Pager id: " + resName +
            " Pager class: " + getClass() +
            " Problematic adapter: " + mAdapter.getClass());
}

populateが呼ばれた時点でのmExpectedAdapterCountとmAdapter#getCount()の値が一致していないとIllegalStateExceptionが発生します。そしてmExpectedAdapterCountは何処で設定されているかと言うと、dataSetChangedの一番最初です。

final int adapterCount = mAdapter.getCount();
mExpectedAdapterCount = adapterCount;

つまりdataSetChanged内で呼ばれるdestoryItemでPagerAdapter#getCount()の結果に関わる部分を操作してしまうと、例外がとんでくるわけですね。

注意すべき点はこのdestoryItemと言うメソッド名が指すItemはPagerAdapterのItemではなくViewPagerのItemであると言うことです。Adapterが内部で所持するItemはnotifyDataSetChanged(destoryItem)が呼ばれる前に削除済みである必要があると言うことでもあります。

また、destoryItemでPagerAdapterのItemを削除するべきではない理由がもう一つあります。

populateが呼ばれるトリガはPagerAdapter#notifyDataSetChanged()だけでなく、スワイプによるページ送りも含まれています。

加えて、populate内でもdestoryItemは呼ばれています。これは画面上で表示する必要がなくなったmItemsをremoveするためです。

mExpectedAdapterCountはスワイプによるページ送りでは再設定されません。これは当然でしょう。ページを変えただけでAdapterのCountが増えるなんてことは…まぁ、やろうと思えば出来るでしょうが、それならnotifyDataSetChangedを呼んでくれって話になります。

何が言いたいのかというと、destoryItemでPagerAdapter#getCount()の結果に関わる部分を操作してしまうと、ページ送りの度にAdapterのCountが変更されてしまうと言うことです。そうなれば当然、二度目のページ送りでIllegalStateExceptionが発生します。

PagerAdapter#addNewItem(int, int)

ここまでは削除の話ばかりでした。では実際にPagerAdapterからItemを取得、つまり、PagerAdapter#instantiateItem(ViewGroup, int)が呼ばれるのはどこなのでしょう?

これはpopulate内で度々呼ばれているaddNewItemと言うメソッドです。

ItemInfo addNewItem(int position, int index) {
    ItemInfo ii = new ItemInfo();
    ii.position = position;
    ii.object = mAdapter.instantiateItem(this, position);
    ii.widthFactor = mAdapter.getPageWidth(position);
    if (index < 0 || index >= mItems.size()) {
        mItems.add(ii);
    } else {
        mItems.add(index, ii);
    }
    return ii;
}

では次にこれが呼ばれる条件を見てみましょう。

// Locate the currently focused item or add it if needed.
int curIndex = -1;
ItemInfo curItem = null;
for (curIndex = 0; curIndex < mItems.size(); curIndex++) {
    final ItemInfo ii = mItems.get(curIndex);
    if (ii.position >= mCurItem) {
        if (ii.position == mCurItem) curItem = ii;
        break;
    }
}

if (curItem == null && N > 0) {
    curItem = addNewItem(mCurItem, curIndex);
}

まず一つは現在選択されているItemがない場合ですね。

これは最初にAdapterをセットしたらどうなるかを想像してみるとわかりやすいと思います。

では次の条件です。端末のサイズetcを計算する部分があって読みにくいですが、頑張って読んでください。

// Fill 3x the available width or up to the number of offscreen
// pages requested to either side, whichever is larger.
// If we have no current item we have no work to do.
if (curItem != null) {
    float extraWidthLeft = 0.f;
    int itemIndex = curIndex - 1;
    ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
    final int clientWidth = getClientWidth();
    final float leftWidthNeeded = clientWidth <= 0 ? 0 :
            2.f - curItem.widthFactor + (float) getPaddingLeft() / (float) clientWidth;
    for (int pos = mCurItem - 1; pos >= 0; pos--) {
        if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {
            // 省略(非表示のものをdestroyItemする処理)
        } else if (ii != null && pos == ii.position) {
            extraWidthLeft += ii.widthFactor;
            itemIndex--;
            ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
        } else {
            ii = addNewItem(pos, itemIndex + 1);
            extraWidthLeft += ii.widthFactor;
            curIndex++;
            ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
        }
    }

    float extraWidthRight = curItem.widthFactor;
    itemIndex = curIndex + 1;
    if (extraWidthRight < 2.f) {
        ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
        final float rightWidthNeeded = clientWidth <= 0 ? 0 :
                (float) getPaddingRight() / (float) clientWidth + 2.f;
        for (int pos = mCurItem + 1; pos < N; pos++) {
            if (extraWidthRight >= rightWidthNeeded && pos > endPos) {
                // 省略(非表示のものをdestroyItemする処理)
            } else if (ii != null && pos == ii.position) {
                extraWidthRight += ii.widthFactor;
                itemIndex++;
                ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
            } else {
                ii = addNewItem(pos, itemIndex);
                itemIndex++;
                extraWidthRight += ii.widthFactor;
                ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
            }
        }
    }

    calculatePageOffsets(curItem, curIndex, oldCurInfo);
}

もっと抜粋してみましょう。

int itemIndex = curIndex - 1;
ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;

for (int pos = mCurItem - 1; pos >= 0; pos--) {
    if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {
        // 省略(非表示のものをdestroyItemする処理)
    } else if (ii != null && pos == ii.position) {
        extraWidthLeft += ii.widthFactor;
        itemIndex--;
        ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
    } else {
        ii = addNewItem(pos, itemIndex + 1);
        extraWidthLeft += ii.widthFactor;
        curIndex++;
        ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
    }
}

ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
for (int pos = mCurItem + 1; pos < N; pos++) {
    if (extraWidthRight >= rightWidthNeeded && pos > endPos) {
        // 省略(非表示のものをdestroyItemする処理)
    } else if (ii != null && pos == ii.position) {
        extraWidthRight += ii.widthFactor;
        itemIndex++;
        ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
    } else {
        ii = addNewItem(pos, itemIndex);
        itemIndex++;
        extraWidthRight += ii.widthFactor;
        ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
    }
}

ざっくり言うと、ItemInfoを取得できない or ItemInfoのpositionが変わっている場合はaddNewItemを呼んでいます。

PagerAdapter#getItemPosition(Object)で返した結果とこの処理でちゃんと筋が通っていますね。

まとめ

以上がViewPagerの動きになります。これを考慮しながら実装したのが前回のやつですね。

PagerAdapter#getItemPosition(Object)の実装をかなり雑にやった節はあるんですが、全Fragmentをループするよりはさっさとdetach / attachをしてもらったほうがまだマシかな、と。シグネチャとしてexpectedPositionみたいな形でItemInfo.positionを渡して欲しかったです。

以上のことを踏まえた上でPagerAdapterはどう実装すべきか?みたいな話は、余裕があったらやります。

ちなみに余裕は今のところあんまりありません。ご了承ください。