鯰の住処

Namazuが真面目な話を書きます

【Android】 Fragmentが持つFlowのobserverはviewlifecycleOwner.lifecycleScopeで監視させるのが良い

概要

ViewModelが発行するSharedFlowに対しFragmentでObserverを設定していたのですが、Fragmentがback stackから戻った際にObserverが二重で登録されてしまうバグに遭遇しました。 FragmentのライフサイクルとLifycycleOwnerの挙動が主な原因でした。 結論としてはFragmentでlifecycleScopeではなくviewLifecycleScopeを使えば良かったのですが、気になったのでこの記事で深堀します。

バグが起きた状況

FragmentAのonCreateViewで以下のようなコードを書いていました。

override onCreateView() {
  viewModel.hogeFlow.onEach {
     fuga()
  }.launchIn(viewLifecycleOwner.lifecycleScope) 
}

そして、以下の状況でバグが起きました。

  1. FragmentAに遷移する
  2. FragmentAからNavigationの遷移でFragmentBに遷移する
  3. FragmentBからpopBackStack()でFragmentAに戻ってくる
  4. FragmentAでhogeFlowに対するobserverが二重に登録されてしまう(fuga()が二回呼び出される)

この際、実際に起こっていたことは以下の通りです。

  1. FragmentAでonCreateViewが呼ばれ、hogeFlowに対しobserverがセットされる
  2. FragmentAからFragmentBへの遷移に際してFragmentAがback stackに積まれる(ここで購読がキャンセルされない)
  3. FragmentBからpopBackStackでFragmentAに戻る際、FragmentAがback stackから取り出される
  4. FragmentAでonCreateViewが呼ばれ、hogeFlowに対しobserverがセットされる

この時FragmentAのライフサイクルの挙動は公式ドキュメントの図が分かりやすいです。

https://developer.android.com/images/fragment_lifecycle.png?hl=ja

さて、今回バグが起きた原因はFragmentのlifecycleScopeの挙動が影響します。 上の図に示されている通り、Fragmentがback stackに行っただけではFragmentは破棄されず、onDestroy()が呼ばれない(StateがDESTROYEDにならない)のでした。 バグを起こした主な要因は「実際に起こっていたこと」の2番でhogeFlowの購読がキャンセルされていると勘違いしていたことにあります。 Flowのobserve(今回はonEach)メソッドは、launchInで渡されたLifecycleScopeのON_DESTROYイベントが発火された時に購読をキャンセルしますが、今回渡したFragmentのライフサイクルはback stackに移っただけではDESTROYEDになっていないため購読がキャンセルされません。

解決策

FragmentにはFragment自体のライフサイクルの他にFragmentのViewのライフサイクルが用意されており、それがviewLifecycleOwnerです。 ViewLifecycleOwnerのLifecycleScopeではFragmentのonDestroyViewが呼ばれた時にON_DESTROYが発火されます。 Fragmentがback stackに遷移する際にonDestoryViewが呼ばれること、FlowのobserveメソッドのlaunchInで渡されたLifecycleScopeのON_DESTROYイベントが発火された時に購読をキャンセルすることを考慮すると、今回はこのViewLifecycleOwnerを使えば良さそうです。

すると、コードは以下のようになります。

override onCreateView() {
  viewModel.hogeFlow.onEach {
     fuga()
  }.launchIn(viewLifecycleOwner.lifecycleScope) 
}

前回との差分はlaunchInで渡していたlifecycleScopeをviewLifecycleOwner.lifecycleScopeに変更しただけですが、理由が分かると納得できますね。

参考文献

同じ問題について書かれていた先達の記事

補足

FragmentのViewLifecycleOwnerとLifecycleOwnerでON_DESTROYの発火はそれぞれ以下で記述されているようです。

ソースコード

    public void onDestroyView() {
        mCalled = true;
        if (mView != null) {
            mViewLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
        }
    }

ソースコード

    void performDestroy() {
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
        if (mChildFragmentManager != null) {
            mChildFragmentManager.dispatchDestroy();
        }
        mState = INITIALIZING;
        mCalled = false;
        mIsCreated = false;
        onDestroy();
        if (!mCalled) {
            throw new SuperNotCalledException("Fragment " + this
                    + " did not call through to super.onDestroy()");
        }
        mChildFragmentManager = null;
    }