コンテンツにスキップ

FragmentStatePagerAdapter で動的に Fragment を変更すると、Activity 復元時におかしくなる場合がある

modified: 2018-02-18

FragmentStatePagerAdapter で動的にページを変更するといろいろとハマる。 Activity の復元時にも気をつける点があったので整理してみた。

だめなパターン

具体的には FragmentStatePagerAdapter を使った Activity が OS に破棄され、再生成されて復帰時にページ(Fragment)のリストが変わっている場合。FragmentStatePagerAdapter は自前で各ページの状態を保持するため、復元後の Adapter に食い違いがあるとき、不整合が生じてしまうようだ。

PagerAdapter は以下のような感じで定義し、文字列 fragmentId で識別される PageFragment を各ページに表示してみる。

MyPagerAdapter

class MyPagerAdapter(
        fragmentManager: FragmentManager,
        private val fragmentIds: List<String>
) : FragmentStatePagerAdapter(fragmentManager) {
    override fun getCount(): Int = fragmentIds.count()
    override fun getItem(position: Int) =
        PageFragment.newInstance(fragmentIds[position])
    override fun getItemPosition(o: Any) = POSITION_NONE
}

Activity

private lateinit var adapter: MyPagerAdapter
private val fragmentIds = Collections.synchronizedList(mutableListOf<String>())

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // fragmentIds を設定
    adapter = MyPagerAdapter(supportFragmentManager, fragmentIds)
}

例えば Activity 起動時に fragmentIds["test2"] だった場合、PageFragment#newIntance("test2") で生成した Fragment がひとつ作成される。

Activity が一旦破棄されて復元されたときに fragmentIds["test1", "test2"] に変わっていた場合、1ページめの Fragment は作成済とみなされて元の Fragment が復元され、2ページめは新たに "test2" で作成される。結果、"test2" で生成した Fragment が2つできてしまう。

Activity の破棄を挟まず fragmentIds の内容を変更して MyPagerAdapter#notifyDataSetChanged() した場合は、MyPagerAdapter#getItemPosition()POSITION_NONE を返しているので一旦すべての Fragment が破棄されてから正しく生成し直されるため、問題は起こらない。

対策(案)

Activity が破棄されたときの Fragment と復元時に Adapter に与える fragmentIds に食い違いがないよう、Activity 再生成時の onCreate() で一旦破棄されたときの fragmentIdsを復元してみる。

private lateinit var adapter: MyPagerAdapter
private val fragmentIds = Collections.synchronizedList(mutableListOf<String>())

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    if (savedInstanceState == null) {
        // fragmentIds 初期化
    } else {
        // Activity 再生成時は破棄時のものに一旦復元
        (savedInstanceState?.getSerializable("fragmentIds") as? Array<String>)?.let {
            fragmentIds.addAll(it)
        }
    }
    adapter = MyPagerAdapter(supportFragmentManager, fragmentIds)
}

override fun onSaveInstanceState(outState: Bundle?) {
    super.onSaveInstanceState(outState)
    outState?.putSerializable("fragmentIds", fragmentIds.toTypedArray())
}

問題は変更後の fragmentIds にどこで更新するかだが、ページャが実際に初期化されるのは onResume() で画面描画を開始したあと、View に対する onMeasure() の延長のようだ。それが完了する前に fragmentIds を書き換えてしまうと、同じことになる。しかし更新が完了したタイミングは Activity には通知されない(と思う)。

処理を更に追ってみると onMeasure() から ViewPager がページを更新する際、PagerAdapter#startUpdate() が呼ばれ、完了すると PagerAdapter#finishUpdate() が呼ばれているようだ。なので以下のように finishUpdate() を override して1回でもこのメソッドが呼ばれたかどうかを保持して、これが呼ばれた後に fragmentIds を更新するようにしてみたところ、とりあえずうまくいった。

class MyPagerAdapter(
        fragmentManager: FragmentManager,
        private val fragmentIds: List<String>
) : FragmentStatePagerAdapter(fragmentManager) {
    ...

    override fun finishUpdate(container: ViewGroup) {
        super.finishUpdate(container)
        // 初期化完了とみなす
    }
}

これで本当に問題ないかの確証はないので、正しい対処の方法があれば知りたいです・・・