NavigationでBackStackが消えた日 ―― あの日、戻るボタンが全く効かなくなった。

※ChatGPTを使用して記事を作成しています。

■ 序章:突然、戻れなくなった日

ある日のこと。
いつものようにAndroid Studioを立ち上げ、Navigationを使って画面遷移を確認していた。

アプリの動作は一見問題なし。
しかし、ふと「戻る」ボタンを押しても画面が戻らない。
いや、正確に言えば「戻る」ボタンを押すたびに、アプリ全体が強制終了してホーム画面に戻る

「え、なにこれ?」
Logcatを見ると、そこには無慈悲な一行。

java.lang.IllegalArgumentException: No destination with ID 2131362031 is on the NavController's back stack.

NavigationのBackStackが、跡形もなく消えていた

■ 第一章:Navigationの甘い罠

当時の私は、Jetpack Navigationを「Fragmentの置き換えツール」程度に考えていた。
画面遷移をXMLで管理できて、findNavController().navigate()でサクッと飛ばせる。
しかも戻るボタンも自動で効く。便利なことこの上ない。

だが、ある日を境に戻るボタンが効かなくなった

コードはこうだった。

// FragmentA から FragmentB へ遷移
findNavController().navigate(R.id.action_A_to_B)

そして FragmentB の中で、ユーザー操作に応じて別の画面へ遷移。

// FragmentB から FragmentC へ
findNavController().navigate(R.id.action_B_to_C)

ここまでは普通。
だが、C画面から「戻る」ボタンを押すと…
なぜか A に戻らず、アプリがクラッシュ。

「BackStackは自動で積まれるはずだろ?」と信じていた私は、そこで初めてNavigationの仕組みを深く調べることになる。

■ 第二章:BackStackが消える条件

NavigationのBackStackが吹き飛ぶ原因は、意外なところにあった。

犯人は、NavHostFragmentを複数置いた構成だった。

アプリでは、メイン画面にBottomNavigationViewを採用しており、各タブごとに別のNavHostFragmentを持っていた。

<fragment
    android:id="@+id/nav_host_home"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:navGraph="@navigation/nav_home"
    app:defaultNavHost="false" />

<fragment
    android:id="@+id/nav_host_settings"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:navGraph="@navigation/nav_settings"
    app:defaultNavHost="false" />

この構成で、ユーザーがタブを切り替えるたびに
別のNavControllerに切り替えていた。

val navController = when (selectedTab) {
    Tab.HOME -> homeNavController
    Tab.SETTINGS -> settingsNavController
}

しかし、ここで致命的な問題が。
NavigationはNavHostFragment単位でBackStackを保持している。
つまり、タブを切り替えた瞬間、他のタブのBackStackは破棄されてしまう。

結果として「Cから戻ろうとしたら、Aがもう存在していない」という地獄が生まれた。

■ 第三章:失敗例コード

当時の実装を簡略化したものがこちら。

// Activity上でNavHostFragmentを動的に切り替え
private fun switchTab(tab: Tab) {
    val transaction = supportFragmentManager.beginTransaction()
    val fragment = when (tab) {
        Tab.HOME -> homeFragment
        Tab.SETTINGS -> settingsFragment
    }
    transaction.replace(R.id.container, fragment)
    transaction.commit()
}

このように、replace()を使ってFragmentを切り替えていた。
だがこれを行うと、前のNavHostFragmentが破棄されるため、BackStackも同時に消える。

そのため、FragmentC → 戻る、の操作が次のような悲劇を招いた。

java.lang.IllegalArgumentException:
No destination with ID ... is on the NavController's back stack.

NavigationのBackStack管理はNavControllerに紐づいており、破棄された時点で履歴は消滅する。

■ 第四章:修正版 ― NavHostFragmentを再生成しない

解決策はシンプルだが、発想の転換が必要だった。
NavHostFragmentを破棄せず、show/hideで切り替える。

修正版コードはこちら。

private fun switchTab(tab: Tab) {
    val transaction = supportFragmentManager.beginTransaction()
    listOf(homeFragment, settingsFragment).forEach { transaction.hide(it) }

    val target = when (tab) {
        Tab.HOME -> homeFragment
        Tab.SETTINGS -> settingsFragment
    }
    transaction.show(target)
    transaction.commitAllowingStateLoss()
}

このようにしておけば、各タブのNavControllerが保持され、BackStackも個別に生き続ける。

実際に戻るボタンを押しても、きちんと前の画面に戻るようになった。

■ 第五章:NavigationとBackStackの正しい付き合い方

Navigationは便利だが、内部の挙動を理解していないと簡単に破綻する。
特に気をつけたいポイントは次の3つだ。

  1. NavHostFragmentはNavControllerを1つしか持たない
     → NavControllerごとにBackStackが別管理される。
  2. replace()はNavHostFragmentごと破棄する
     → BackStackも消える。
  3. show()/hide()で切り替えるのが安全
     → 状態保持が可能。

■ 終章:あの日の学び

Navigationは魔法ではない。
内部ではしっかりとFragmentManagerとBackStackを使って管理されている。

私は「自動でやってくれる」と思い込み、BackStackの存在を軽視していた。

だが、Navigationは便利さの裏に構造的な前提がある。
それを理解しないまま「戻るボタンが効かない」と嘆いても、それは自分の責任だ。

あの日、BackStackが消えたのは単なるバグではなく、「理解しないまま便利さに頼ること」の代償だった。

■ 今日の教訓

Navigationは優秀だが、万能ではない。
BackStackは、あなたが守らなければならない。

■ まとめ

  • replace()でNavHostFragmentを切り替えるとBackStackが消える
  • タブ構成ではshow()/hide()でNavHostFragmentを保持する
  • Navigationの構造を理解して使うことが大事
タイトルとURLをコピーしました