読者です 読者をやめる 読者になる 読者になる

Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

ViewPager+FragmentPagerAdapterにおけるFragmentのライフサイクル

Android

こんにちは、間藤です。今回もAndroidネタです。
ActivityやFragmentのライフサイクルは、把握できているようで実はあまりわかってなかったってことがよくあります。少なくとも私はそうです。
ViewPager+FragmentPagerAdapterの構成で、Fragmentの生成/破棄がどうようになるか、いまひとつわかってないことに気付き、サンプルコードを作成して確認してみることにました。以下にその結果をまとめてみます。この記事を読んで、「おやっ?」と感じた方は、一度ご自分でも調べてみては如何でしょうか。

なお、間違った内容にならないよう注意して確認はしていますが、見落としもあるかもしれませんので、以下の内容をそのまま鵜呑みにせずに、実際に動作確認されることをお勧めします。

サンプルコード

サンプルコードは、FragmentPagerAdapterの「Class Overview」に掲載されているコードをベースに作成しました。

FragmentPagerAdapter | Android Developers

ですから、実験用の用意したサンプルコードが、本質的に実装方法を間違えているということはないのではないかと考えています。以下、今回の確認で利用したプログラムです。(レイアウト等は元ネタそのままなので省いてます)

MainActivity.java

このサンプルでは、ViewPagerで10ページ分の画面遷移が行えるようになっています。各ページは、内部クラスとして定義されているArrayListFragmentクラスで実装されています。つまり、各ページで表示する画面は同一です。パラメータで渡された番号を画面上に表示するので、そこだけが画面表示上の差異になります。また、下部に配置されたボタンで最初のページと最後のページにジャンプできるようになっています。

f:id:IntelligentTechnology:20141217105659p:plain:h400

FragmentPagerAdapterの実装は、MyAdapterクラス(こちらも内部クラスです)になります。

ライフサイクル確認用のデバッグ出力

Fragmentの生成は、MyAdapterクラスのgetItemメソッド内で行っています。

@Override
public Fragment getItem(int position) {
    Log.i("MyAdapter", "getItem[" + (position+1) + "]");
    return ArrayListFragment.newInstance(position+1);
}

ArrayListFragmentクラスのコンストラクタでは、デバッグ用の出力をするようにしました。インスタンスを識別できるように、ハッシュコードを出力するようにしています。

void outputLog() {
    Log.i("FragmentList", String.format("%s:[%d](%x)", Thread.currentThread().getStackTrace()[3].getMethodName(), mNum, this.hashCode()));
}
public ArrayListFragment() {
    super();
    outputLog();
}

また、他のライフサイクルメソッド(onCreateなど)にも、同様のデバッグ出力を仕込みました。

実験

それでは、アプリを動かして、ログがどのように出力されるかを確認してみます。以下の端末で確認しています。

起動時

I/MyAdapter(24960): getItem[1]
I/FragmentList(24960): <init>:[0](4206f710)
I/MyAdapter(24960): getItem[2]
I/FragmentList(24960): <init>:[0](42074090)
I/FragmentList(24960): onCreate:[1](4206f710)
I/FragmentList(24960): onCreateView:[1](4206f710)
I/FragmentList(24960): onResume:[1](4206f710)
I/FragmentList(24960): onCreate:[2](42074090)
I/FragmentList(24960): onCreateView:[2](42074090)
I/FragmentList(24960): onResume:[2](42074090)

起動時にMyAdapterのgetItemが2回呼ばれ、2ページ分のFragmentインスタンスが生成されていることがわかります。<init>は、コンストラクタです。この時点ではページ番号(mNum)が設定されていないため、初期値の0になっています。
2ページ目のFragmentがこの時点で用意されるのは、スワイプによるページ遷移に備えるためと思われます。
以下の記事が参考になると思います。

ViewPagerのキャッシュする画面数を変更する - outcesticide

2ページ目に遷移

次にスワイプして2ページ目に遷移してみます。

I/MyAdapter(24960): getItem[3]
I/FragmentList(24960): <init>:[0](420adec0)
I/FragmentList(24960): onCreate:[3](420adec0)
I/FragmentList(24960): onCreateView:[3](420adec0)
I/FragmentList(24960): onResume:[3](420adec0)

この場合は、3ページ目のFragmentを準備するための処理が動きます。表示された2ページ目のFragmentについては、onResumeも呼ばれません。

ボタン押下で最終ページに遷移

2ページ目の「last!」ボタンをタップして、最終ページ(10ページ)にジャンプします。

I/MyAdapter(24960): getItem[10]
I/FragmentList(24960): <init>:[0](420bc590)
I/MyAdapter(24960): getItem[9]
I/FragmentList(24960): <init>:[0](420bea80)
I/FragmentList(24960): onCreate:[10](420bc590)
I/FragmentList(24960): onCreateView:[10](420bc590)
I/FragmentList(24960): onResume:[10](420bc590)
I/FragmentList(24960): onCreate:[9](420bea80)
I/FragmentList(24960): onCreateView:[9](420bea80)
I/FragmentList(24960): onResume:[9](420bea80)
I/FragmentList(24960): onDestroyView:[3](420adec0)
I/FragmentList(24960): onDestroyView:[2](42074090)
I/FragmentList(24960): onDestroyView:[1](4206f710)

表示される10ページ目の生成以外にも、隣の9ページ目が生成されています。また、1~3ページのonDestroyViewが呼ばれています。

ボタン押下で最初のページに遷移

10ページ目の「first!」ボタンをタップして、1ページ目にジャンプします。

I/FragmentList(24960): onCreateView:[1](4206f710)
I/FragmentList(24960): onResume:[1](4206f710)
I/FragmentList(24960): onCreateView:[2](42074090)
I/FragmentList(24960): onResume:[2](42074090)
I/FragmentList(24960): onDestroyView:[9](420bea80)
I/FragmentList(24960): onDestroyView:[10](420bc590)

1~3ページは、10ページ目に遷移した際にViewが破棄(onDestroyView)されているため、ここでViewが再生成(onCreateView)されています。但し、Fragmentのインスタンス自体は、起動時に生成したものが再利用(ハッシュ値が同一)されていることがわかります。

Activityが破棄された場合

開発者向けオプションで「アクティビティを破棄」にチェックするなどして、アプリをバッググランドにしたときにアクティビティが破棄されるようにしてみます。その上でアプリをフォアグランドにします。
※後になって気づきましたが、端末を回転するほうがお手軽でした。

I/FragmentList(24960): onDestroyView:[1](4206f710)
I/FragmentList(24960): onDestroyView:[2](42074090)
I/FragmentList(24960): <init>:[0](42118770)
I/FragmentList(24960): <init>:[0](4211aaa8)
I/FragmentList(24960): <init>:[0](4211cd88)
I/FragmentList(24960): <init>:[0](4211f068)
I/FragmentList(24960): <init>:[0](42121348)
I/FragmentList(24960): onCreate:[1](42118770)
I/FragmentList(24960): onCreate:[2](4211aaa8)
I/FragmentList(24960): onCreate:[3](4211cd88)
I/FragmentList(24960): onCreate:[10](4211f068)
I/FragmentList(24960): onCreate:[9](42121348)
I/FragmentList(24960): onCreateView:[1](42118770)
I/FragmentList(24960): onCreateView:[2](4211aaa8)
I/FragmentList(24960): onResume:[1](42118770)
I/FragmentList(24960): onResume:[2](4211aaa8)

ここでは、Fragmentのインスタンスが再生成されています。生成されるのは、その前までの操作ですでに生成された実績のあるページのFragmentです。つまり、1~3ページと9~10ページです。これらインスタンスは、MyAdapterのgetItemメソッドを経て生成されるものではなく、Androidシステムによりリストアされたものです。その際、ちゃんとページ番号(mNum)も再現されていることがわかります。また、Viewが再構築されているのは、1ページ目と2ページ目だけだとわかります。

FragmentStatePagerAdapter

FragmentPagerAdapter以外にFragmentStatePagerAdapterというクラスもあります。
こちらを利用した場合は、不要となったFragmentのインスタンスも破棄されるようになります。つまり、onDestroyViewが呼ばれるタイミングでインスタンスが破棄されるようになり、再び必要となった際には、MyAdapterのgetItemメソッドが呼ばれ、インスタンスが再生成されることになります。

OnPageChangeListener

Fragmentで表示する画面上にネットワークから取得する情報がある場合は、そのデータの取得タイミングを考慮する必要があります。なるべく最新の情報を表示するのが望ましいのであれば、ページを切り替えた際にデータ取得したくなるでしょう。ですが、ViewPagerを利用していると、表示される前にonCreateView~onResumeまでが実行されており、いざページ切替でFragmentが表示されたタイミングでデータ取得したいと思っても、その機会が与えられません。
上で示したサンプルで、ボタンにより最終ページに遷移するようなケースでは、画面表示のタイミングでonResumeが呼ばれるので、そこでデータ取得することはできます。しかし、スワイプで画面遷移するパターンでは、ページ表示のタイミングでFragment自身でデータ更新することができないのです。

考えられる解決策としては、OnPageChangeListenerを実装して、ViewPagerのページ切替のイベントを捕捉することです。少々行儀の悪いコードになりますが、以下のような感じで、ページ切替の際にFragmentのデータ更新を促すことができます。以下は、ActivityでOnPageChangeListenerを実装した例です。

@Override
public void onPageSelected(int position) {
    List<Fragment> fragments = getSupportFragmentManager().getFragments();
    for(Fragment f: fragments) {
        if (f instanceof ArrayListFragment) {
            if ( ((ArrayListFragment)f).getPageNum() == (position+1) ) {
                ((ArrayListFragment)f).reload();
            }
        }
    }
}

新たに表示されたページに対応するFragmentかどうかを確認するため、ArrayListFragmentクラスが持っているページ番号を取得するメソッド(getPageNum)を追加しています。また、データ更新用のメソッド(reload)も追加してあります。
ただ、さらに実装を工夫しないと、ボタンによって最終ページに遷移するようなパターンでは、2回データ取得の処理が走ってしまうことになるので、注意が必要です。

Fragmentのインスタンスは、MyAdapterクラスで生成しているので、このクラスで生成したFragmentを管理して制御できないかとも思ったのですが、上に示したようにAndroidシステムがFragmentを再生成するパターンもあるため、それはなかなか難しいと思います。

上の例では、onPageSelectedを実装していますが、onPageScrollStateChangedに実装したほうがよいかもしれません。onPageSelectedが呼ばれるタイミングは、遷移先のページが「決定した」タイミングであり、ページスクロールは完了していないそうです。そのため、このタイミングで処理を挟むと、ページスクロールが若干もたつくように見えることがあります。以下の記事が参考になります。

ViewPagerのイベントをハンドルする(ページ移動) - outcesticide


手軽にスワイプによるページ切替を実現できるViewPagerとFragmentPagerAdapterですが、いろいろと「お仕事」をやってくれるので、そのことをある程度把握しておかないと、実装が破綻してしまうことになりかねません。

正しくコーディングするのもなかなか簡単ではないなと思った次第です。