LoginSignup
106
101

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-02-24

概要

DroidKaigi 公式アプリ誕生のお話は @konifar さんのブログ記事 を御覧ください。

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

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

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

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

9002dc4時点での MainActivity

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;
    }
}

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

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

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

Javaのenumでこの実装に対応する基礎となるお話はこちら

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

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、そしてToolbarelevationを変えるかどうかのフラグ、Fragment のクラス名です。

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

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

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の使い方にはいくつかの議論がありますが、関連する情報をまとめて管理する分にはとても有用です。本当に単なる定数列挙でよければenumである必要性は無いですが。。

Fragment の管理

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

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

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のタイトルを変えなければいけなかったりと、片手落ちの挙動になってしまいます。

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

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 の回答によれば、Fragment#onDestroyView()の時にMapFragmentを引き剥がせば重複することはないとのことで、当初はこの実装で回避をしていましたが、実際には問題がありました。Fragment#onDestroyView()でのFragmentの操作ということは、既にonSaveInstanceState()は通りすぎているはずなので、その操作は許可されない旨の例外が飛ぶことがあります。また、FragmentTransactionの取得にも問題がありました。

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

確かに、<fragment>タグを xml に書いた場合に、その Fragment をFragmentTransactionで引き剥がしたり何らか操作することは Fragment in Fragment のサポート以前からしてはいけないことでしたから、今回のような場合の対応策としてはよくない実装と見たほうがよかったのかもしれません。

Fragment のライフサイクル管理

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

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

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

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

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

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

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

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

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 をもう少し汎用的にするならば、以下のようにするのも良さそうですね。

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 の目的はそうだったような気がする)。

106
101
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
106
101