Fragmentとの通信で「setFragmentResultListenerを使うべき」みたいな記事をどっかで見て妄信していたが、画面回転に伴うActivity再生成時に期待した動作をしないことに気づき、Fragmentと通信する方法について考察した。
結論
- FragmentResultListenerは原則使わない方がいい。
- DialogFragmentは呼び出し元のActivityやFragment自体にListenerを実装させ、結果を処理する方式がベスト。
- DialogFragmentを部品ではなく機能として使用することで、結果を返さなくて良くなり、呼び出し元はダイアログを放し飼いにできる。
考察に使用したバージョン
androidx.fragment:fragment:1.5.4
考察
そもそもFragment同士やActivity-Fragment間での通信にはどういった方法があるのか?
- Fragment参照を使用し、直接操作
- 同一スコープを使用したViewModel
- Fragment Result API ※FragmentResultListenerのこと
各方法について、
- モジュール結合度
- 画面回転時の対応
- "スパゲッティコード"度
- メンテナンス性
の観点で見ていく。
なお、画面回転時はActivity同様にFragmentも再生成されることを先に書いておく。
・Fragment参照を使用し、直接操作
TestFragment fragment = fragmentContainerView.getFragment(); Hoge hoge = fragment.getHoge(); fragment.setListener(result->{ //do something });
Fragmentのメソッドを公開して、値を取得したり、操作を行う。
Listenerを登録する形でもいい。
FragmentManagerのfindFragmentByTagやfindFragmentByIdでFragmentを取得できる。
子Fragmentの参照さえ取れれば、なんでもできる。
子Fragment側では、呼び出し元が何なのか考慮する必要がないため、モジュール結合度は低い。
画面回転時は呼び出し元が子Fragmentの参照を取り直す必要がある。
Listenerを登録する方式の場合、再登録する必要がある。
コードがありそうなところにあり、仕組みも単純なため、スパゲッティ度は低い。
インタフェースを変更しても呼び出し元の修正漏れが発生しないためメンテナンス性はまあまあ高い。
・同一スコープを使用したViewModel
public static class TestFragment extends Fragment { public TestViewModel vm; @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { //ActivityスコープでViewModelを共用する場合 vm = new ViewModelProvider(requireActivity()).get(TestViewModel.class); //親FragmentスコープでViewModelを共用する場合 vm = new ViewModelProvider(requireParentFragment()).get(TestViewModel.class); //子FragmentスコープでViewModelを共用する場合 vm = new ViewModelProvider(this).get(TestViewModel.class); //todo inflate view return inflated view; } } public static class TestViewModel extends ViewModel { public final MutableLiveData<String> live = new MutableLiveData<>(); } public static class TestActivity extends AppCompatActivity { Observerobserver; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState);
//todo create fragment observer = s->{ //do something }; } @Override protected void onStart() { super.onStart(); TestFragment fragment = fragmentContainerView.getFragment(); fragment.vm.live.observe(this, observer); //observerインスタンスが1つなので、何回呼び出しても大丈夫 } }
呼び出し元と呼び出し先で同じViewModelインスタンスを参照することで通信を行う。
値を相互に設定することができる。
子FragmentはViewModelの状態に応じて動作する必要がある。
同一スコープのViewModelを使うためには、呼び出し元と呼び出し先で
同じViewModelのクラスと、
同じViewModelStoreOwnerを使う必要がある。
ViewModelのクラスについては、子Fragment専用で作るとモジュール結合度が低くなり望ましい。
ViewModelは値が構造化されていて使いやすいが、あくまで値のやり取りしかできないため、操作をする場合は前述の直接操作を行うことになる。
一方、ViewModelStoreOwnerについては、Activity(Fragment#requireActivity)かFragment(Fragment#requireParentFragmentなど)を使用できるが、呼び出し元と呼び出し先でどれを使用するか統一しなくてはいけないため、モジュール結合度が高くなってしまうが、子FragmentにViewModelを返すようなメソッドを定義してやることで、モジュール結合度を下げることができる。
画面回転時は呼び出し元と呼び出し先が共に、
"新しいViewModelStoreOwner" を元に "同じViewModel"を再生成するため、手間がない。
呼び出し元 | 呼び出し先 | 画面回転時の再生成 | 備考 |
---|---|---|---|
Activity.this Fragment.requireActivity |
Fragment.requireActivity | 〇 |
小規模で極めて単純なアプリの場合はありかも 子Fragmentを複数生成するとバグる |
Fragment.this | Fragment.requireParentFragment | 〇 |
親Fragmentが存在するか実行時まで不明なので危険 子Fragmentを複数生成するとバグる |
Fragment.childFragment | Fragment.this | 〇 |
モジュール結合度が低いのでオススメ 子Fragmentを複数生成しても正常に動作する |
ViewModelクラスを別途定義するため、直接操作よりもスパゲティ度がほんの少しだけ高い。
インタフェースはViewModelのみであり、クラスが分離されているためメンテナンス性は非常に高い。
・Fragment Result API ※FragmentResultListenerのこと
public static class TestActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); //TestFragmentの結果をListen getSupportFragmentManager().setFragmentResultListener(TestFragment.REQUEST_KEY, this, this::onFragmentResult); } /** 各FragmentResultListenerへの応答 */ protected void onFragmentResult(@NonNull String requestKey, @NonNull Bundle bundle){ switch (requestKey){ case TestFragment.REQUEST_KEY:{ final @Nullable String result = bundle.getString(TestFragment.RESULT_KEY); //do something break; } } } } public static class TestFragment extends Fragment{ /** 戻り値を設定する */ protected void setResult(@NonNull String result){ final @NonNull Bundle bundle = new Bundle(); bundle.putString(RESULT_KEY, result); getParentFragmentManager().setFragmentResult(REQUEST_KEY, bundle); } }
Fragment Result APIはFragmentManagerの恒常性※を利用して、戻り値の設定と取得を仲介する。
※ActivityやFragmentがそれぞれFragmentManagerのインスタンス※※を持っており、再生成時に復元される。
※※Activity.getSupportFragmentManager、Fragment.getParentFragmentManager、Fragment.getChildFragmentManagerでアクセスできる。
子FragmentはFragmentManagerへRequestKeyと戻り値(Bundle)を設定し、
呼び出し元はFragmentManagerへRequestKeyとResultListenerを設定し戻り値(Bundle)を受け取る。
子Fragmentがアクティブな間に行う通信(親->子)については、Fragment Result APIは対応していないため、前述の他の方法と組み合わせる必要がある。
Fragment Result APIの実装を見てみると、FragmentManager毎に
戻り値を保持するResultMap<String, Bundle>と
Listenerを保持するListenerMap<String, WrappedListener>を持っていて、
いずれもStringのRequestKeyと呼ばれるキーによってアクセスする。
RequestKeyは子Fragment内の定数とするか、起動引数として貰う必要がある。
定数方式の場合は他のFragmentと重複してはいけないため、子Fragmentのクラス名を使えば良さそうだが、FragmentManager上に子Fragmentが複数生成される場合には正常に動作しない。
実質、起動引数として貰う方法一択である。
起動引数としてRequestKeyを貰う場合、何を指定したかを子Fragment側では気にする必要がないため、モジュール結合度は低い。
戻り値については、BundleをFragmentManagerで仲介するためモジュール結合度は低い。
画面回転時にはややこしい問題が発生する。
FragmentResultはFragmentManager再生成時に復元され、FragmentManagerが存在する続く限り生存するのに対し、
FragmentResultListenerはFragmentManager再生成時に破棄され復元されない。
そのため、子Fragmentを起動しているか否かによらず、ActivityやFragmentのonCreate系の中でFragmentManagerへListenerを登録する必要がある。
すると、起動と結果処理のコードが分離され、クリームパスタが完成する。
また、戻り値がKey-ValueでアクセスするBundleであるため、汎用性は最大だが、BundleのKeyが文字列であったり、ビルド時にgetがチェックされないためバグが発生しやすい。
RequestKeyの一意性、
起動と結果で分離されたコード、
ResultがKey-Value型(Bundle)、
いずれもビルドエラーが発生しないため、バグの温床になり得るし、メンテナンス性は最悪である。
[MEMO] JavaScriptやJSONの流れを汲んだのか巷でKey-Value型が流行っているように感じる。筆者はKotlinを使えないためKotlinについて評価できないが、Javaの言語思想とは真逆なので相性が悪いと思う。
[MEMO] Fragment Result APIはFragmentManager上に構築された一人しか受信できないBroadcastReceiverみたいなもの。BroadcastReceiverもonCreateで登録するし。
[MEMO] メッセージ型プログラミングと相性が良い。各FragmentManagerのスコープでResultKeyをswitch-caseで分岐させる。合わせてステートマシンも導入すれば最強。だがしかし、筆者は苦手だ。
[MEMO] RequestKeyで取り出されたResultMapの値は、FragmentManagerがRequestKeyに応じたListenerをコールしたタイミングでクリアされる。
[MEMO] 1つのRequestKeyに対し、FragmentResultListenerは1つしか登録できない。複数回呼び出すと上書きされる。
蛇足:DialogFragmentと通信する方法の考察
今回の記事の発端はDialogFragmentを使用する際に発生した問題である。
ざっくりいうと以下のようなフローでDialogFragmentを使用した。
↓
[Dialog]データ編集
↓
[Activity]結果保存処理
DialogFragmentは、画面回転時の再生成時に自動で表示状態も含め完全に復元される。
復元が自動であるがゆえに、ダイアログの結果の扱いが難しくなる。
"編集ボタンを押したというState"を呼び出し元が保存するなら、ダイアログの結果も正しく処理ができそうだが、ダイアログの呼び出し処理を書く度にStateが増えていく。
Stateと一言で言っても、ボタンを押した時、ダイアログを表示した時、OKボタンが押された時、キャンセルボタンが押された時、それ以外の方法でダイアログがキャンセルされた時、ダイアログが2重に起動された時・・・果たしてStateを完璧に管理できるだろうか?
さて、通信する方法について3つ挙げたが、いずれの方法もダイアログ復元時にも使えるベストプラクティスにはならないように思う。
ダイアログのガイドでは呼び出し元にListenerを実装させる手順が紹介されている。
少々癖があるが画面回転時にも正常に動作するため、ダイアログに限って言うならこの方法がベストな気がする。
他に思いつく限りでは、ダイアログを部品ではなく、機能として使用するのが無難だ。
結果を呼び出し元へ返さないなら、画面回転時にダイアログが復元されても問題は発生しない。
↓
[Dialog]データ編集&保存
ややこしいことを全部放り投げてダイアログを復元せずに破棄するのも一つの手。
UXは低下するが、プログラマは楽。
古典的なDialogをshowすると画面回転時に例外を吐いて消滅するが、
DialogFragmentを画面回転時に消滅させる方法が実は無い。
DialogFragmentのとあるメソッドの副作用を利用して消滅させる方法が見つかっている。
//Activity再生成時にFragmentを再利用するフラグを立てると、DialogFragmentが再表示された瞬間に閉じる DialogFragment.setRetainInstance(true)
以上。