Navigation Component のニッチな落とし穴から得た Android アプリ開発の学び

あけましておめでとうございます! (遅すぎ)

モバイルチームの向井田 (@mk_mkee) です。
モバイル系の 2020 年最初のブログは Android について書いていこうと思います!

皆さん、Android Jetpack の Navigation Component は使っていますか? Navigation Component は Android アプリの画面遷移やバックスタック管理を楽にしてくれるライブラリです。 弊社では kintone モバイルの Android アプリで導入しています。

今回の記事では、Navigation Component を利用していて気になった画面遷移の挙動を調査し、その過程で得た学びをお伝えしたいと思います。 Navigation Component の学びというよりは、Android アプリ開発全般における学びとなるので、よかったら読んでいってください。

経緯

先日、kintone モバイルに SAML 認証によるシングルサインオン機能を実装していたときの話です。 Chrome Custom Tabs で認証をし、認証完了後にログイン画面からログイン後のポータル画面に遷移する機能を作っていました。

実装が終わって動作確認をしていたときです。 Chrome Custom Tabs が閉じられ、kintone モバイルのログイン画面に戻ってきたとき、タイミング悪く Recents ボタン (□のやつ) を押すと、 画面遷移が完了しない現象に出くわしました。 もうログインは完了しているので、フォアグラウンドに戻ってきたときにポータル画面に遷移する、というのが我々が期待していた動作でした。

シングルサインオンで画面遷移が成功するときシングルサインオンで画面遷移が失敗するとき
(左)シングルサインオンで画面遷移が成功するとき, (右)シングルサインオンで画面遷移が失敗するとき

原因

ログイン画面 -> ポータル画面 の画面遷移には Navigation Component の NavController#navigate() を使っていました。 結論から言うと、以下の2つが原因でした。

  1. Activity が onSaveInstanceState() 以降のライフサイクルのときに NavController#navigate() を呼び出しても画面遷移しない
  2. NavController#navigate() を非同期に呼び出すと、onSaveInstanceState() 以降に呼び出してしまう可能性がある

お察しの通り、起きている現象自体はめちゃくちゃコーナーケースです。 しかし、原因を調べてみると Android の基本的な仕組みをより深く知る良い機会となったので、皆さんにも共有しようと思い筆を執りました。

それでは、順を追って説明していきたいと思います。

Navigation Component を解剖していこう

1つ目の原因である Activity が onSaveInstanceState() 以降のライフサイクルのときに NavController#navigate() を呼び出しても画面遷移しない から解説していきます。

Navigation Component の基礎

まず Navigation Component の基礎から復習していきます。

Navigation Component は1つの Activity が複数の Fragment を入れ替えて画面遷移を実現しています。 Activity は NavHostFragment という Fragment の入れ物を持っており、NavHostFragment が各画面に相当する Fragment の入れ替えを処理しています。 そして、NavController#navigate() を呼び出すと画面遷移が実行されて、Fragment が入れ替わります。

developer.android.com

NavController#navigate() の中身

では、NavController#navigate()onSaveInstanceState() の関係を知るために、NavController#navigate() のコードを読んでいきましょう。

以下のコードは NavController#navigate() の実装です。

// NavController#navigate() は 内部で FragmentNavigator#navigate() を呼び出す
public NavDestination navigate(
    @NonNull Destination destination,
    @Nullable Bundle args,
    @Nullable NavOptions navOptions,
    @Nullable Navigator.Extras navigatorExtras) {
    if (mFragmentManager.isStateSaved()) {
        Log.i(TAG, "Ignoring navigate() call: FragmentManager has already"
                + " saved its state");
        return null;
    }
    // 省略...

FragmentNavigator.java - Source

NavController#navigate() の実装を見ると、mFragmentManager.isStateSaved() の値が true だったとき、画面遷移の処理は ignore されます。 mFragmentManager.isStateSaved() の値は mFragmentManager を所持する NavHostFragment や、NavHostFragment をホストしている Activity のライフサイクルによって変わります。 そして、Activity の onSaveInstanceState() が呼ばれた後だと mFragmentManager.isStateSaved() は true になります。

public boolean isStateSaved() {
    // See saveAllState() for the explanation of this.  We do this for
    // all platform versions, to keep our behavior more consistent between
    // them.
    return mStateSaved || mStopped;
}

FragmentManager.java - Source

余談ですが、 isStateSaved() の実装で mStoppedonStop() の状態もチェックしている理由は、Android のバージョンによって onSaveInstanceState() の呼ばれるタイミングが異なるからだと思います。 (下図)

Activity のライフサイクル (一部)
Activity のライフサイクル (一部)
参考: Understand the Activity Lifecycle  |  Android Developers

では、なぜ Navigation Component は Activity の onSaveInstanceState() が呼ばれた後だと画面遷移しないのでしょうか? その答えは、Navigation Component が Fragment を入れ替えるために使用している FragmentTransaction という仕組みにあります。

FragmentTransaction の復習

FragmentTransaction とは、ある Fragment を Activity や 別の Fragment に追加、置換したり、削除したりする仕組みです。 Fragment が Android に導入されたときからある基本的な操作です。

追加や置換など、Fragment に関する変更処理のまとまりを FragmentTransaction という単位で管理します。 FragmentTransactionFragmentManager が内部で管理しているバックスタックに追加できます。 そのため、Fragment の変更は Navigation Bar の戻るボタンで元に戻すことが出来ます。

FragmentTransaction を利用して、ある Fragment を別の Fragment に置換する場合は以下のようなコードになります。 addToBackStack() を実行すると、beginTransaction() から commit() までの変更が1つのスタックとしてバックスタックに追加されます。

val newFragment = ExampleFragment()
val transaction = supportFragmentManager.beginTransaction()
// R.id.fragment_container の Fragment を newFragment に入れ替える
transaction.replace(R.id.fragment_container, newFragment)
transaction.addToBackStack(null)
transaction.commit()

developer.android.com

FragmentTransaction と Activity のライフサイクル

FragmentTransaction は Fragment をホストしている Activity のライフサイクルと深く関係しています。

beginTransaction() のドキュメントには、保存される FragmentTransactiononSaveInstanceState() より前に実行されたものだけであり、onSaveInstanceState() より後に FragmentTransaction を実行するとエラーになると書かれています。

A fragment transaction can only be created/committed prior to an activity saving its state. If you try to commit a transaction after FragmentActivity.onSaveInstanceState() (and prior to a following FragmentActivity.onStart or FragmentActivity.onResume(), you will get an error. This is because the framework takes care of saving your current fragments in the state, and if changes are made after the state is saved then they will be lost.

FragmentManager  |  Android Developers

なぜこういう仕様なのでしょう?

onSaveInstanceState() とは何だったか

画面を構成する Activity のインスタンスは、画面回転時やバックグラウンドでのメモリ不足時に Android OS によって破棄、再生成されます。 何もしなければ Activity のインスタンスが破棄されたタイミングで Activity が持つデータも失われてしまいます。

そこで、ある Activity のインスタンスが持つデータを再生成後の Activity インスタンスに引き継ぐために、onSaveInstanceState() が呼ばれます。 引き継ぎたいデータは onSaveInstanceState() の引数で受け取った outState に格納しておく必要があります。

FragmentTransaction と onSaveInstanceState()

Activity が再生成されるため、Activity が管理する Fragment も再生成されます。 Fragment が持つデータは FragmentActivity#onSaveInstanceState() ですべて outState に格納されます。 よって、FragmentManagerが管理するバックスタックに追加した FragmentTransaction も 最終的には FragmentActivity#onSaveInstanceState()outState に格納され、再生成された Activity に引き継がれます。

public class FragmentActivity extends ComponentActivity implements
        ActivityCompat.OnRequestPermissionsResultCallback,
        ActivityCompat.RequestPermissionsRequestCodeValidator {
    // 省略...

    @Override
    protected void onSaveInstanceState(@NonNull Bundle outState) {
        super.onSaveInstanceState(outState);
        markFragmentsCreated();
        mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP);

        // バックスタックを含む Fragment の全データを outState に格納
        Parcelable p = mFragments.saveAllState();
        if (p != null) {
            outState.putParcelable(FRAGMENTS_TAG, p);
        }
        // 省略...

FragmentActivity.java - Source

逆にいうと、FragmentActivity#onSaveInstanceState() 以降に発生したデータは outState に格納されず、インスタンス破棄とともに失われます。 つまり、FragmentActivity#onSaveInstanceState() より後に実行した FragmentTransaction も、Activity の再生成によって失われてしまう可能性があり、Fragment の変更を元に戻せることを保証できません。 だから、FragmentActivity#onSaveInstanceState() 後の FragmentTransaction 実行はエラーになる仕様になっています。

Navigation Component が Activity のライフサイクルを確認する理由

Navigation Component も FragmentTransaction を使って Fragment を入れ替える都合上、 FragmentActivity#onSaveInstanceState() 後に Fragment の入れ替えを実行するとエラーが起きてしまいます。 このエラーを避けるために、Navigation Component は Activity の onSaveInstanceState() が呼ばれたかどうかを mFragmentManager.isSavedState() で確認し、エラーなく画面遷移を完了できるようにしていたというわけです。

NavController#navigate() と非同期実行

次に、2つ目の原因であった NavController#navigate() を非同期に呼び出すと、onSaveInstanceState() 以降に呼び出してしまう可能性がある について解説していきます。

一般的に、ボタンを押すと画面遷移するようなコードは、Button の listener 内で NavController#navigate() を呼ぶと思います。

// A Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    // Kotlin Android Extensions を利用
    go_to_b_button_success.setOnClickListener {
        // A Fragment -> B Fragment の画面遷移をする
        Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
            .navigate(R.id.BFragment)
    }
}

このように、listener 内で同期的に NavController#navigate() を呼び出した場合は、Activity ライフサイクル図の Activity running の状態での呼び出しになります。 すなわち、onSaveInstanceState() 前の呼び出しなので、正常に画面遷移が成功します。

しかし、listener 内で非同期に NavController#navigate() を呼び出した場合は違います。 我々は、実装していたシングルサインオン機能の都合上、NavController#navigate() が非同期で実行されていました。

以下は検証用のコードです。

// A Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    go_to_b_button_success.setOnClickListener {
        // UIスレッドのループを回さずに遅延させる (同期実行)
        try {
            Thread.sleep(5000)
        } catch (e: Exception) {}

        Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
            .navigate(R.id.BFragment) // 成功 😘
    }

    go_to_b_button_failure.setOnClickListener {
        // UIスレッドのループを回して遅延させる (非同期実行)
        Handler().postDelayed({
            val option = NavOptions.Builder()
                .setEnterAnim(R.anim.nav_default_enter_anim)
                .setExitAnim(R.anim.nav_default_exit_anim)
                .setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
                .setPopExitAnim(R.anim.nav_default_pop_exit_anim)
                .build()

            Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
                .navigate(R.id.BFragment) // 失敗 🤮
        }, 5000)
    }
}

ライフサイクルの更新は UIスレッド (メインスレッド) のループ処理の中で更新されます。 ユーザがボタンを押したとき、listener 自体は Activity running の状態で呼び出されます。 listener 内で同期的に処理が行われる限り、UIスレッドのループが進むことは無いため、listener 内の処理は Activity running 状態で実行されます。

しかし、listener 内に非同期処理がある場合、listener が呼び出されたときのループでは非同期処理が実行されず、次以降のループで実行されます。 そのため、ライフサイクルが onSaveInstanceState() 以降に更新される可能性が出てくるのです。

kintone モバイルの例では、NavController#navigate() を非同期で実行しており、かつ Resents ボタンを押してライフサイクルが更新されたため、画面遷移が ignore されてしまったというわけです。

上記の非同期処理は昔ながらの Handler を用いたコードですが、RxJava や Kotlin Coroutines でも同様の現象が発生すると思います。

学びまとめ

今回は、Navigation Component の NavController#navigate() を呼んでも画面遷移しない場合がある理由について、詳しめに解説しました。

現象としてはとてもニッチな内容ですが、今回の件で学んだことが2つあります。

  1. Android アプリ開発においてライフサイクルの考慮は忘れてはならない
  2. 非同期処理は UIスレッドの処理が進むため、ライフサイクルが更新されて意図しない挙動になる場合がある

これらの学びは Android アプリエンジニアなら知ってて当たり前な内容かもしれません。 しかし同時にハマりやすいポイントでもあります。 特にライフサイクルは、Jetpack が提供する ViewModelLifecycle を使えば、随分と楽にはなってきていますが、 完全に忘れて良いものでは無いなと再認識しました。

この記事を読んだ方の何かの助けになれば幸いです!😘

おまけ

ちなみに、今回のようにアプリがバックグラウンドになってしまって ignore された画面遷移を、 アプリがフォアグラウンドに復帰した際に再実行したい場合は、NavController#navigate() を呼び直すしかありません。 kintone モバイルでは ignore された画面遷移の処理をキューとして保持しておき、フォアグラウンド復帰時にキューを処理するという方法で対処しました。


サイボウズでは、今回のように iOS/Android の開発技術について探求したい人を募集中です💪
We are hiring!!! 🔜 cybozu.co.jp