Enjoy Architecting

Twitter: @taisho6339

【Android】MenuTabのFragmentの遷移を自前のスタックで管理する

はじめに

概要

仕事の開発の方でタブメニューを使っていて壁にぶつかったので備忘録として残しておきます。
トピックとしては、「Tabメニューの一つのタブで、Fragmentの遷移をさせた時の管理」でぶつかった課題とその解決策について記します。

具体的なエラー

Tabに紐付いたFragmentから、別のFragmentに遷移させ、BackStackに前の状態を積んだ時に、特定ケースで何故かアプリが落ちるといった問題に悩まされました。

成果物

問題解決の成果物としてTabのFragment遷移管理を簡単にするためのモジュールを作ったので公開しておきます。
(お粗末ですが。。。)
使い方はREADMEを見てくれれば。。。
https://github.com/taisho6339/tab_fragment_operation

問題にぶつかったケース

たとえばこういう設計のタブメニューの画面を作ります。

f:id:taisho6339:20140701214608p:plain

つまり、


・一つのActivityにタブのコンテンツとしてFragmentをぶら下げる。
・さらにそのFragmentから何らかのタイミングで別のFragmentに遷移させる。
・遷移は、Fragmentから一旦Activityにコールバックして、Activityで管理する。

といった仕組み。

ぶつかった問題

  1. まずFragmentA-1が開いている状態で、FragmentA-2を呼び出す。
  2. 親のActivity側で、コンテナをFragmentA-2にreplaceする。
  3. 呼び出す前の状態を保存するためにaddToBackStackを呼ぶ。
  4. タブを切り替える。
  5. 再び元のタブに戻る(この時点でFragmentA-2に帰る)
  6. バックキーを押して、FragmentA-2を呼び出す前の状態に戻す。
  7. 落ちる。

エラーログは、

java.lang.IllegalStateException: Fragment already added

こんな感じのが・・・

問題の考察

フレームワーク側のTabを管理するコードを読んでみるとわかりますが、

https://code.google.com/p/android/issues/attachmentText?id=40035&aid=400350000000&name=FragmentTabHost.java&token=WHVg3x7dbNNznLcPPSwWxnin_X0%3A1367343846708

タブを切り替えた時に、タブに紐づけてあるFragmentのインスタンスがnullだと新しく生成し、スタックにaddするという制御を行っています。
つまり、

  1. 親のActivity側で、コンテナをFragmentA-2にreplaceした時点でFragmentA-1のインスタンスが消える。
  2. タブを切り替えてからまた元のタブに戻る
  3. 消えていたFragmentA-1のインスタンスを再度生成
  4. FragmentA-2をattach
  5. バックキーを押すと、FragmentA-2を呼びだす前の状態にロールバックするために、FragmentA-1を再生成しスタックに積もうとする
  6. が、すでに3で蘇らせているのでバッティング(同じバックスタックに同じFragmentのインスタンスは積めない)

という問題っぽい。

解決策

バックキーによるロールバック時、タブの切り替え時にインスタンスを自動で復旧させてることで、バッティングしてしまっているので、

FragmentManagerのBackStackではなく自前でスタックを用意して管理する

ことで回避できるのではないかと考えました。
つまり、それぞれのタブに対してスタックを紐付けておいて、タブそれぞれでFragmentの遷移を管理しようってお話しです。

というわけで汎用的なモジュールを実装してみました。
https://github.com/taisho6339/tab_fragment_operation

仕様

・FragmentからFragmentに遷移した時に、バックキーで元の状態に戻れるようにする。
・タブのルートまで言った時にさらにバックキーを押すとActivityを終了させる。

ようはこれを満たせばいいわけです。

実現方法

・TabInfoというデータクラスを作り、Fragmentを積むスタックを持たせる。
・格タブにTabInfoをタグとしてくっつけておく。(これでTabInfoがタブから取得できる)
・FragmentからFragmentに遷移するときは、このTabInfoの持つスタックに積む(現在表示されているFragmentが一番上にくる)
・バックキーを押したら、スタックをポップして、その時の一番上のFragmentをコンテナに差し込む
・タブ切り替えは切り替え先のタブのスタックの一番上のFragmentをアタッチ

こんな感じ。

Tabを使うActivityで、

  @Override
    public void onBackPressed() {
        if (backControl())
            return;
        try {
            super.onBackPressed();
        } catch (IllegalStateException e) {
            e.printStackTrace();
        }
    }

    private boolean backControl() {

        TabInfo tabInfo = (TabInfo) getCurrentTab().getTag();
        Stack<Fragment> stack = tabInfo.getBackStack();
        if (!stack.isEmpty()) {
            stack.pop();
            if (stack.isEmpty()) {
                finish();
                return true;
            }
            Fragment newFragment = stack.peek();
            mManager.beginTransaction().replace(mFragmentContentId, newFragment).commit();
            return true;
        }
        return false;
    }


また、ActionBarのTabを使ったので、TabListenerの実装クラスに、


 @Override
    public void onTabSelected(ActionBar.Tab tab, FragmentTransaction ft) {
        Stack<Fragment> stack = ((TabInfo) tab.getTag()).getBackStack();
        Fragment fragment = null;
        if (stack.isEmpty()) {
            fragment = Fragment.instantiate(mActivity, mClass.getName());
            ft.add(mContentId, fragment);
            stack.add(fragment);
        } else {
            fragment = stack.peek();
            ft.attach(fragment);
        }
    }

    @Override
    public void onTabUnselected(ActionBar.Tab tab, FragmentTransaction ft) {
        Stack<Fragment> stack = ((TabInfo) tab.getTag()).getBackStack();
        if (!stack.isEmpty()) {
            Fragment fragment = stack.peek();
            ft.detach(fragment);
        }
    }

詳しくはgithubで。。。