スポンサーリンク

2016年2月22日月曜日

Android Fragment トランザクション - バックスタックの落とし穴

Android アプリケーションで Fragment を使ったことのない人はまずいないと思います。Fragment のバックスタック操作については特に難しいことなないと思っていたのですが、意外な落とし穴があることに気付きました。

Fragment を replace() するときに addToBackStack() すればスタックに追加されるし、しなければ追加されない。スタックに追加されていれば Back キーで戻ることができる。何も難しいことはありません。しかし実際にやってみるとどうもうまくいきません。

どんなときにうまく行かないかと言うと、例えば以下の例を考えます。



FragmentA, FragmentB, FragmentC の三つの Fragment を考えます。FragmentA を起点に FragmentManager#replace() で FragmentB, FragmentC へ遷移していきます。A→B に遷移するときは addToBackStack() でバックスタックに登録し、B→C に遷移するときは登録しません。概略のコードは以下のようになります。



// (1) FragmentA を貼り付け
transaction.replace(R.id.frag_area, fragA).commit();
.....
// (2) FragmentB を貼り付け(Push to Back Stack) 
transaction.replace(R.id.frag_area, fragB).addToBackStack(null).commit();
.....
// (3) FragmentC を貼り付け
transaction.replace(R.id.frag_area, fragC).commit();

この状態で画面上には FragmendC が表示されています。直感的にスタックは [A][C] となっているように思えます。ここで Back キーを押すと FragmentA に戻ります。一見これはうまくいっているように見えます。スタックは [A] のみに戻った筈です。

ここでもう一度 (2) と同じ操作、FragmentB を addToBackStack() 付きで replace() します。(スタックは [A][B] になった筈) 更にこの状態で Back キーを押して再度 FragmentA に戻ります。あれっ?何故か FragmentC が表示されてしまいました。

実は最初の Back キーを押して C→A に戻った時、FragmentAに透明な部分があると、背後に FragmentC が消えずに残っていることが分かります。更に詳しく調べてみると、この時 FragmentC の onPause() や onStop() も呼ばれていません。

一体何が起こっているのでしょう?最初は何がなんだかさっぱり分かりませんでした。突破口となったのは、次の一文です。

addToBackStack() がやっているのは Fragment の記録ではなく、Transaction の記録である。

replace() というのは remove() と add() を続けて実行するのと同じことなので、上のコードを分解かつ省略した形で書くと以下の様になります。

// FragmentA を貼り付け
transaction.remove(null).add(fragA);
.....
// FragmentB を貼り付け (Push to Back Stack)
transaction.remove(fragA).add(fragB).addToBackStack();
.....
// FragmentC を貼り付け
transaction.remove(fragB).add(fragC);

この状態で Backキーを押すと、システムはまずバックスタックの中に(2)のトランザクションがあるのを見つけ、これと逆の操作を試みます。
transaction.remove(fragB).add(fragA);
しかし fragB は既に View 上に無いので、実際に行なわれる処理は以下になります。
transaction.remove(null).add(fragA);
ここで注目すべきは、この時点でView に実際に表示されている fragC に対しては何も行なわれないということです。
とにかくこの処理で fragA が一番上に表示されます。(実際は fragC の上にオーバーラップしている。)

次にもう一度 (2) の操作
transaction.replace(R.id.frag_area, fragB).addToBackStack().commit();
を実行すると、View 上に残っている fragC に対して操作が行なわれ、
transaction.remove(fragC).add(fragB);
が実行されます。ここで Back ボタンを押すとこれと逆の操作が行なわれ、
transaction.remove(fragB).add(fragC);
が実行されます。ああ、これで fragC が再度表示されてしまいました。

ではどうしたら良いでしょう?対策方法はいろいろと考えられます。ここでは Activity#onBackPressed() で Backキーイベントを拾ってスタックの深さが 1 になったら強制的に FragmentC を削除させてみます。とりあえずこれで期待通りには動くようになります。
@Override
public void onBackPressed() {
    FragmentManager fm = getFragmentManager();

    // Back Stack が最後の一つになったとき、FragmentC があれば強制的に削除
    if (fm.getBackStackEntryCount() == 1) {
        Fragment fragment = fm.findFragmentByTag("FragmentC");
        if (fragment != null) {
            FragmentTransaction xact = fm.beginTransaction();
            xact.remove(fragment);
            xact.commit();
        }
    }

    super.onBackPressed();
}
あまり汎用的ではありません。Activity の中に記述しなくてはならないので、Activityにべったり依存してしまうのも気になりますが、簡単にBackキーイベントを検出できるのでお手軽です。別の方法として、FragmentManager に OnBackStackChageListener を登録して、バックスタックが変化する度に Fragment 操作を行なうということも考えられます。状況に応じてどう対策するかは考えて下さい。

addToBackStack() を全てについて使うか、全てについて使わない実装であれば問題になることはありませんが、使う、使わないが混在するとこのような問題が起こります。
単純なスタックモデルではなく、何でこんなに複雑で直感に反したデザインになっているのか理解に苦しむところです。そもそも「スタック」という言葉を使っていること自体に問題があるようにも思います。

とにかく Fragment のバックスタックで問題が起こったら、バックスタックには Fragment ではなく、トランザクションが記録されていて、Back キーを押すとそれと逆の操作が行なわれる、ということを思い出してください。

テストで使ったコード
https://github.com/masamichi441/AndroidFragmentTransaction

参考ページ
http://stackoverflow.com/questions/12529499/problems-with-android-fragment-back-stack
http://www.andreabaccega.com/blog/2015/08/16/how-to-avoid-fragments-overlapping-due-to-backstack-nightmare-in-android/

1 件のコメント :

  1. The cliche "form is transient, class is permanent" does not apply to world777 fantasy cricket. One of the most important components of fantasy cricket is staying up to date on how players have performed in recent matches rather than selecting players based on their career records or popular image.

    返信削除