NavigationView と Fragment と BackStack: DroidKaigi 公式アプリの構造と設計・実装の判断の足跡を辿る

## 概要

DroidKaigi 公式アプリ誕生のお話は @konifar さんの[ブログ記事](http://konifar.hatenablog.com/entry/2016/02/11/031257) を御覧ください。

この記事では、DroidKaigi 公式アプリの中でも、自分がプルリクエストを出した、NavigationView の操作とそれに連なる部分の構造について解説します。

## NavigationView のイベントハンドリング

NavigationView に表示するメニューの各項目は、従来の OptionsMenu などと同じ`MenuItem`です。その`MenuItem`が選択されたタイミングのイベントを拾うコールバックとして、`NavigationView.OnNavigationItemSelectedListener`があり、DroidKaigi アプリの場合は`MainActivity`がこれを実装しています。

当初、`NavigationView.OnNavigationItemSelectedListener`のメソッド`onNavigationItemSelected(MenuItem)`では、メニューの id ごと`switch-case`で分岐してそれぞれのメニューを選択された時の処理を実装していました。

[`9002dc4`時点での MainActivity](https://github.com/konifar/droidkaigi2016/blob/9002dc4de38407fb5407c3f307fb26ca42ed7a0f/app/src/main/java/io/github/droidkaigi/confsched/activity/MainActivity.java)

```java:MainActivity.java
public class MainActivity extends AppCompatActivity
            implements NavigationView.OnNavigationItemSelectedListener {
    @Override
    public boolean onNavigationItemSelected(MenuItem item) {
        binding.drawer.closeDrawer(GravityCompat.START);

        int id = item.getItemId();
        switch (id) {
            case R.id.nav_all_sessions:
                toggleToolbarElevation(false);
                changePage(R.string.all_sessions, SessionsFragment.newInstance());
                break;
            case R.id.nav_my_schedule:
                toggleToolbarElevation(false);
                changePage(R.string.my_schedule, MyScheduleFragment.newInstance());
                break;
            case R.id.nav_map:
                toggleToolbarElevation(true);
                changePage(R.string.map, MapFragment.newInstance());
                break;
            case R.id.nav_settings:
                toggleToolbarElevation(true);
                changePage(R.string.settings, SettingsFragment.newInstance());
                break;
            case R.id.nav_sponsors:
                toggleToolbarElevation(true);
                changePage(R.string.sponsors, SponsorsFragment.newInstance());
                break;
            case R.id.nav_about:
                toggleToolbarElevation(true);
                changePage(R.string.about, AboutFragment.newInstance());
                break;
        }

        return true;
    }
}
```

ごく普通の素直な実装です。メニューごとに`Toolbar`の`elevation`やタイトルを変えたり、`Fragment`のインスタンスを生成して置き換えたりしています。

ただ、よくみてみるとパターンごと常に決まりきった処理がずらずらと並んでおり、メニューが増えた時に同じような処理を`case`で増やしていくことになりそうです。

それぞれメニューごとに、`Toolbar`の`elevation`を変えるかどうか、`Toolbar`のタイトルはどれか、対応する`Fragment`はどれかが一つずつに定まるので、それらをメニューに属するプロパティとして考えると、何かしら一つのオブジェクトで取り扱って纏めてあげれば、`case`分をポチポチと増やす事が無くなりそうです。またそのオブジェクトは状態の変更をする必要はなく、かつ、メニューは列挙として捉えられるので、イミュータブルなオブジェクトを列挙する目的で`enum`を使えば`enum`の定数を増やしたり消したりだけでメニューの増減に対応できそうです。

Javaの`enum`でこの実装に対応する基礎となるお話は[こちら](http://qiita.com/KeithYokoma/items/9681b130ea132cfad64d)。

そういうわけで、一部で話題のEnum芸を以下のように実装してみました。

```java:Page.java

public enum Page {
    ALL_SESSIONS(R.id.nav_all_sessions, R.string.all_sessions, false, SessionsFragment.class.getSimpleName()) {
        @Override
        public Fragment createFragment() {
            return SessionsFragment.newInstance();
        }
    },
    MY_SCHEDULE(R.id.nav_my_schedule, R.string.my_schedule, false, MyScheduleFragment.class.getSimpleName()) {
        @Override
        public Fragment createFragment() {
            return MyScheduleFragment.newInstance();
        }
    },
    MAP(R.id.nav_map, R.string.map, true, MapFragment.class.getSimpleName()) {
        @Override
        public Fragment createFragment() {
            return MapFragment.newInstance();
        }
    },
    SETTINGS(R.id.nav_settings, R.string.settings, true, SettingsFragment.class.getSimpleName()) {
        @Override
        public Fragment createFragment() {
            return SettingsFragment.newInstance();
        }
    },
    SPONSORS(R.id.nav_sponsors, R.string.sponsors, true, SponsorsFragment.class.getSimpleName()) {
        @Override
        public Fragment createFragment() {
            return SponsorsFragment.newInstance();
        }
    },
    ABOUT(R.id.nav_about, R.string.about, true, AboutFragment.class.getSimpleName()) {
        @Override
        public Fragment createFragment() {
            return AboutFragment.newInstance();
        }
    };

    private final int menuId;
    private final int titleResId;
    private final boolean toggleToolbar;
    private final String pageName;

    Page(int menuId, int titleResId, boolean toggleToolbar, String pageName) {
        this.menuId = menuId;
        this.titleResId = titleResId;
        this.toggleToolbar = toggleToolbar;
        this.pageName = pageName;
    }

    public static Page forMenuId(MenuItem item) {
        int id = item.getItemId();
        for (Page page : values()) {
            if (page.menuId == id) {
                return page;
            }
        }
        throw new AssertionError("no menu enum found for the id. you forgot to implement?");
    }

    public static Page forName(Fragment fragment) {
        String name = fragment.getClass().getSimpleName();
        for (Page page : values()) {
            if (page.pageName.equals(name)) {
                return page;
            }
        }
        throw new AssertionError("no menu enum found for the id. you forgot to implement?");
    }

    public int getMenuId() {
        return menuId;
    }

    public boolean shouldToggleToolbar() {
        return toggleToolbar;
    }

    public int getTitleResId() {
        return titleResId;
    }

    public String getPageName() {
        return pageName;
    }

    public abstract Fragment createFragment();
}
```

`enum`が持つのは、メニューのID、タイトル用の文字リソースのID、そして`Toolbar`の`elevation`を変えるかどうかのフラグ、Fragment のクラス名です。

また、static なメソッドとして、`MenuId`から対応する`enum`のオブジェクトを一つ引いてくる`forMenuId`と、Fragmentのクラス名から対応する`enum`のオブジェクトを一つ引いてくる`forName`があります。Fragment の生成はそれぞれの`enum`定数ごとに振る舞いが変わるので、一旦 abstract なメソッドを生やしておいて、定数列挙のところに実装を書くことで対応しています。

さてこれを使うと、先ほどのコールバックメソッドの中の`switch-case`を取り除くことができます。

```java:MainActivity.java

public class MainActivity extends AppCompatActivity
            implements NavigationView.OnNavigationItemSelectedListener {
    @Override
    public boolean onNavigationItemSelected(MenuItem item) {
        binding.drawer.closeDrawer(GravityCompat.START);

        Page page = Page.forMenuId(item);
        toggleToolbarElevation(page.shouldToggleToolbar());
        changePage(page.getTitleResId(), page.createFragment());

        return true;
    }
}
```

やろうと思えば`toggleToolbarElevation(boolean)`や`changePage(int, Fragment)`といったイベントハンドリング中の各種の処理そのものもまとめて`enum`に投げてしまえるようにも思いますが、それはそれで結局`switch-case`をコピペするのと変わらなくなってしまうので、辞書をひくような使い方にとどめてあります。

[`enum`の使い方にはいくつかの議論](http://qiita.com/tomoima525/items/962ec58ad8332e165d17)がありますが、関連する情報をまとめて管理する分にはとても有用です。本当に単なる定数列挙でよければ`enum`である必要性は無いですが。。

## Fragment の管理

その後、ナビゲーションの関係で、バックボタンを押した時に以前に表示していた`Fragment`に戻すような実装をしたいということで、`FragmentManager`が持っているバックスタックで管理するような実装をすることになりました。

バックスタックに積んだり、バックスタックに積んだものを戻すのは非常に簡単です。戻るボタンを押された時に、バックスタックに詰んだものがあればそれを`pop`すること、そして`FragmentTransaction#commit()`前に`FragmentTransaction#addToBackStack(String)`でバックスタックに積むことができれば、期待した動作が実現できます。

```java:MainActivity.java

public class MainActivity extends AppCompatActivity
            implements NavigationView.OnNavigationItemSelectedListener {

    @Override
    public void onBackPressed() {
        if (binding.drawer.isDrawerOpen(GravityCompat.START)) {
            binding.drawer.closeDrawer(GravityCompat.START);
            return;
        }
        FragmentManager fm = getSupportFragmentManager();
        if (fm.getBackStackEntryCount() > 0) {
            fm.popBackStack();
            return;
        }
        super.onBackPressed();
    }

    private void replaceFragment(Fragment fragment) {
        final FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
        ft.setCustomAnimations(R.anim.fragment_fade_enter, R.anim.fragment_fade_exit);
        ft.replace(R.id.content_view, fragment, fragment.getClass().getSimpleName());
        ft.addToBackStack(null);
        ft.commit();
    }
}
```

ただし、`FragmentManager`のバックスタック管理はあくまで`Fragment`の操作のことしかしてくれません。本当にこれだけで済むかというと、例えば、`NavigationView`の選択状態を変えなければいけなかったり、`Toolbar`のタイトルを変えなければいけなかったりと、片手落ちの挙動になってしまいます。

そこで、バックスタックに変更があったタイミングで、よしなにその辺りをハンドリングするようにします。

```java:MainActivity.java

public class MainActivity extends AppCompatActivity
            implements NavigationView.OnNavigationItemSelectedListener, FragmentManager.OnBackStackChangedListener {

    @Override
    public void onBackStackChanged() {
        FragmentManager fm = getSupportFragmentManager();
        Fragment current = fm.findFragmentById(R.id.content_view);
        if (current == null) {
            // no more fragments in the stack. finish.
            finish();
            return;
        }
        Page page = Page.forName(current);
        binding.navView.setCheckedItem(page.getMenuId());
        binding.toolbar.setTitle(page.getTitleResId());
        toggleToolbarElevation(page.shouldToggleToolbar());
    }
}
```

引数には何もないため、自分で現在表示されている`Fragment`を調べるところから始まりますが、幸い`Fragment`は毎回`replace`されますので、`findFragmentById(int)`で調べるだけで済みました。
ここで`Fragment`が得られなければ即終了、なにかあれば、その`Fragment`から`Page`を引いてきて必要なプロパティをセットするようにしています。

この実装では、履歴の中に同じページが有っても気にせずどんどんバックスタックを積んでいくようになるため、ページの切り替えの分だけバックキーを押さないとアプリが終了できない問題があります。アプリによっては、バックスタックに積むものと積まないものを分けていることもあるようです。その場合は、`FragmentTransaction#addToBackStack(String)`で適切な名前を引数に渡すことと、バックスタックに積む前に同じ名前のものがスタックにあるかどうかを確認することが必要になります。

## Fragment in Fragment

ここで会場の地図を表示する Fragment をバックスタックの管理に入れると、バックキーで戻ってきた時にクラッシュする問題が起きました。

クラッシュログでは、Map が重複しているという理由でクラッシュしていることがわかりました。

[StackOverFlow の回答](http://stackoverflow.com/questions/14124354/android-supportmapfragment-with-googlemaps-api-2-0-giving-illegalargumentexcep)によれば、`Fragment#onDestroyView()`の時に`MapFragment`を引き剥がせば重複することはないとのことで、当初はこの実装で回避をしていましたが、実際には問題がありました。`Fragment#onDestroyView()`での`Fragment`の操作ということは、既に`onSaveInstanceState()`は通りすぎているはずなので、その操作は許可されない旨の例外が飛ぶことがあります。また、`FragmentTransaction`の取得にも問題がありました。

最終的には、@hydrakecat さんが`FragmentTransaction`の取り扱いを修正したことで問題の回避をしましたが、これを正しく扱うという意味では、親の Fragment では SupportMapFragment のために`<fragment>`タグを使わず、動的に追加・削除を行うべきだという話が上がりました。

確かに、`<fragment>`タグを xml に書いた場合に、その Fragment を`FragmentTransaction`で引き剥がしたり何らか操作することは Fragment in Fragment のサポート以前から[してはいけないこと](http://y-anz-m.blogspot.jp/2012/04/android-fragment-fragmenttransaction.html)でしたから、今回のような場合の対応策としてはよくない実装と見たほうがよかったのかもしれません。

## Fragment のライフサイクル管理

実はもう一つ、すべきことがあります。

バックスタックに`replace`で詰んだ`Fragment`は、`pop`したタイミングでライフサイクルが戻ってきます。一方で、`replace`をコミットすると、View が一旦破棄されます。つまり、View の生成のタイミングで非同期でデータを取得し表示している場合、戻ってきた時にデータの読込みをしなおす必要がでてきます。

自分はバックスタックのリスナ上で再読み込みを促すメソッドを呼び出す実装にしていましたが、実際には、`Fragment#onResume()`が呼ばれるため、このタイミングで読込みをすることでも対応が可能です（そしてそのほうがなにかとシンプルに実装できると思います）。

## Fragment が別の Fragment への遷移を要求する

自分がお気に入りに登録したセッションの一覧から、全てのセッションのページへと飛ばしたいという要求がありました。
それぞれ別の Fragment ですから、一旦 Activity に判断を委ねて、適宜 Activity で Fragment の切り替えを実行して貰う必要があります。

Activity がリスナを実装し、Fragment は Activity がリスナを実装していればそれを呼び出す、という従来の方法もありますが、その場合は Activity への参照を持つようになることに気をつける必要があります。
あるいは、EventBus のようなものを使用すれば、Fragment からの参照の管理は EventBus に任せることが出来、かつ手軽に扱うことが出来ます。ただし、[Otto](https://github.com/square/otto)が RxJava の登場で Deprecated になった経緯を踏まえると、[Otto から RxJava への移行ガイド](http://qiita.com/yyaammaa/items/57d8baa1e80346e67e47)にあるような実装を用意するほうが良さそうです。

基本的には移行ガイドの記事にある通りの実装を踏襲してみましたが、一点、イベントのsubscribeとemitで扱われるデータの型が`Object`なのが気になりました。汎用的に使える代わりに、subscribeする側で適宜型を判断しなければいけません。EventBus ではその辺りよしなに判断してくれたのですが……

そもそもあまりこのパターンを多用しすぎるとカオスになりがちなのは EventBus でも Otto でも変わらないため、できることなら局所化したいということと、DroidKaigi のアプリがこのパターンを多用するような作りではなさそうだということで、思い切って特定の型専用の EventBus とすることにしました。

```java:MainContentStateBroker.java

public class MainContentStateBroker {
    private final Subject<Page, Page> bus = new SerializedSubject<>(PublishSubject.create());

    public void set(Page page) {
        bus.onNext(page);
    }

    public Observable<Page> observe() {
        return bus;
    }
}
```

Generics をもう少し汎用的にするならば、以下のようにするのも良さそうですね。

```java:AbstractStateBroker.java

public class AbstractStateBroker<E> {
    private final Subject<E, E> bus = new SerializedSubject<>(PublishSubject.create());

    public void set(E page) {
        bus.onNext(page);
    }

    public Observable<E> observe() {
        return bus;
    }
}
```

この実装により、Fragment は`MainContentStateBroker`にどのページを表示して欲しいかを渡すだけで、Activity が適宜判断して処理をしてくれるようになりました。

## まとめ

`Fragment`を触るのは実は久しぶりで、長らく`CustomView`を使い続けてきたので、忘れていることも多くまた新しい発見もありました。`FragmentTransaction`による Fragment の操作が非同期で行われるという点が何かと厄介なことを引き起こしますが、`Fragment`を再利用可能なパーツとして見るのではなく、それ単体で動くモジュールとして見ると色々シンプルに作ることができそうだな、と思いました(というか当初の Fragment の目的はそうだったような気がする)。