概要
DroidKaigi 公式アプリ誕生のお話は @konifar さんのブログ記事 を御覧ください。
この記事では、DroidKaigi 公式アプリの中でも、自分がプルリクエストを出した、NavigationView の操作とそれに連なる部分の構造について解説します。
NavigationView のイベントハンドリング
NavigationView に表示するメニューの各項目は、従来の OptionsMenu などと同じMenuItem
です。そのMenuItem
が選択されたタイミングのイベントを拾うコールバックとして、NavigationView.OnNavigationItemSelectedListener
があり、DroidKaigi アプリの場合はMainActivity
がこれを実装しています。
当初、NavigationView.OnNavigationItemSelectedListener
のメソッドonNavigationItemSelected(MenuItem)
では、メニューの id ごとswitch-case
で分岐してそれぞれのメニューを選択された時の処理を実装していました。
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
でこの実装に対応する基礎となるお話はこちら。
そういうわけで、一部で話題のEnum芸を以下のように実装してみました。
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
を取り除くことができます。
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)
でバックスタックに積むことができれば、期待した動作が実現できます。
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
のタイトルを変えなければいけなかったりと、片手落ちの挙動になってしまいます。
そこで、バックスタックに変更があったタイミングで、よしなにその辺りをハンドリングするようにします。
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 とすることにしました。
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 をもう少し汎用的にするならば、以下のようにするのも良さそうですね。
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 の目的はそうだったような気がする)。