Jetpack Navigation 2.4.x から、multiple backstack がサポートされ、BottomNavigationView と組み合わせて下タブのタブごとにバックスタックを分けて管理できるようになりました。
例えば、A
/B
/C
の 3 つのタブがあったとき、A
タブでの画面遷移と B
タブでの画面遷移が個別に管理されます。
前提となる Navigation Graph と画面遷移の実装
ここでは Jetpack Navigation 2.4.1 と BottomNavigationView (com.google.android.material:material:1.5.0
)、Fragment を組み合わせた構成での実装を例にあげます。
次のような構成の Navigation Graph を考えてみます。
画面遷移の例
SplashFragment
を起点に HomeFragment
へ遷移し、そこからは下タブの切り替えで DashboardFragment
や NotificationsFragment
へ移動します。各タブは ScreenAFragment
と ScreenBFragment
へ遷移するボタンを持っており、ScreenAFragment
と ScreenBFragment
はさらにお互いと自分自身へ遷移するボタンを持っています。
SplashFragment
の性質上 HomeFragment
へ遷移したらもう戻ってくる必要はないため、HomeFragment
でバックボタンやバック操作を行ったらアプリは終了します。
おおまかに、次に示す XML で上記の Navigation Graph に現れる Fragment を定義しています。
xml version ="1.0" encoding ="utf-8"
<navigation
xmlns android ="http://schemas.android.com/apk/res/android"
xmlns app ="http://schemas.android.com/apk/res-auto"
xmlns tools ="http://schemas.android.com/tools"
android id ="@+id/mobile_navigation"
app startDestination ="@+id/navigation_splash"
>
<fragment
android id ="@+id/navigation_splash"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.splash.SplashFragment"
android label ="Splash"
tools layout ="@layout/fragment_splash"
>
</fragment>
<fragment
android id ="@+id/navigation_home"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.home.HomeFragment"
android label ="@string/title_home"
tools layout ="@layout/fragment_home"
>
</fragment>
<fragment
android id ="@+id/navigation_dashboard"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.dashboard.DashboardFragment"
android label ="@string/title_dashboard"
tools layout ="@layout/fragment_dashboard"
>
</fragment>
<fragment
android id ="@+id/navigation_notifications"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.notifications.NotificationsFragment"
android label ="@string/title_notifications"
tools layout ="@layout/fragment_notifications" >
</fragment>
<fragment
android id ="@+id/navigation_screen_a"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.content.ScreenAFragment"
android label ="Screen A"
tools layout ="@layout/fragment_screen_a" >
</fragment>
<fragment
android id ="@+id/navigation_screen_b"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.content.ScreenBFragment"
android label ="Screen B"
tools layout ="@layout/fragment_screen_b" >
</fragment>
</navigation>
SplashFragment から HomeFragment への遷移の実装方法による挙動の違い
SplashFragment から HomeFragment への遷移には他の画面遷移にはない特徴があるため、Navigation Graph に要素を足す以外の追加の実装が必要です。
popUpTo と popUpToInclusive を使った場合
Jetpack Navigation には popUpTo
と popUpToInclusive
という、画面遷移後の戻り先を定義する属性があります。画面遷移を定義する <action>
要素の属性として指定して使います。
次の例では、SplashFragment
から HomeFragment
への画面遷移について、popUpTo
の指定により HomeFragment
からの戻り先を SplashFragment
に設定していますが、さらに popUpToInclusive
を true
とすることで、SplashFragment
のさらに前に戻るように指示しています。
しかし SplashFragment
はこの Navigation Graph の startDestination
のため SplashFragment
はのさらに前の画面は存在しません。よって、HomeFragment
でバックキーを押すと backstack が空になったとして Activity が終了 (finish) します。
xml version ="1.0" encoding ="utf-8"
<navigation
xmlns android ="http://schemas.android.com/apk/res/android"
xmlns app ="http://schemas.android.com/apk/res-auto"
xmlns tools ="http://schemas.android.com/tools"
android id ="@+id/mobile_navigation"
app startDestination ="@+id/navigation_splash"
>
<fragment
android id ="@+id/navigation_splash"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.splash.SplashFragment"
android label ="Splash"
tools layout ="@layout/fragment_splash"
>
<action
android id ="@+id/nav_splash_to_home"
app destination ="@id/navigation_home"
app popUpTo ="@id/navigation_splash"
app popUpToInclusive ="true"
/>
</fragment>
</navigation>
画面遷移の挙動の変化と問題点
一見これで問題ないように見えますが、HomeFragment
へ遷移したあとの BottomNavigationView によるタブ切り替えの動きが本来の期待値と少し違います。
具体的には、Navigation の backstack がタブごとの管理ではなく、Activity 単位で一元的に管理されるようになります。
例えば
HomeFragment
-> ScreenAFragment
-> ScreenBFragment
と遷移したあとで下タブから DashboardFragment
へ遷移し、再度 HomeFragment
へ戻ったとき、本来の BottomNavigation の定義では、先程の操作で ScreenBFragment
まで遷移していたので ScreenBFragment
が表示されるはずです。
しかしこの期待値とは違い、実際には HomeFragment
に切り替わってしまします。
この HomeFragment
でバックキーを操作すると、
HomeFragment
-> DashboardFragment
-> ScreenBFragment
-> ScreenAFragment
-> HomeFragment
というように遷移していきます。
このようにタブごとの個別の backstack とは異なる画面遷移になってしまします。
またこれとは別に、Activity#onSaveInstanceState
で Bundle
に保存しているデータにも問題があります。
Jetpack Navigation および Fragment は、各 Fragment
ごとに各種 Contract (Activity Result や Permission Request など) を扱うための固有のデータを内部で保持しています。
popUpTo
を利用した <action>
が Navigation Graph にある場合、この Fragment
ごとに固有のデータは backstack に Fragment
を詰むほど増えていきます。
Bundle
に保存されるデータを文字列化し Log に吐き出した例を示します。はじめのコード片は SplashFragment
から HomeFragment
へ遷移したあとの onSaveInstanceState
で保存される Bundle
の中身です。
V/MainActivity: Extra[android:views] :{16908290=android.view.AbsSavedState$1@b96c99f, 2131230768=androidx.appcompat.widget.Toolbar$SavedState@9c7aec, 2131230770=android.view.AbsSavedState$1@b96c99f, 2131230776=android.view.AbsSavedState$1@b96c99f, 2131230832=android.view.AbsSavedState$1@b96c99f, 2131230845=android.view.AbsSavedState$1@b96c99f, 2131231000=com.google.android.material.navigation.NavigationBarView$SavedState@e9c6eb5, 2131231001=android.view.AbsSavedState$1@b96c99f, 2131231002=android.view.AbsSavedState$1@b96c99f, 2131231003=android.view.AbsSavedState$1@b96c99f, 2131231004=android.view.AbsSavedState$1@b96c99f, 2131231005=android.view.AbsSavedState$1@b96c99f, 2131231006=android.view.AbsSavedState$1@b96c99f, 2131231007=android.view.AbsSavedState$1@b96c99f, 2131231009=android.view.AbsSavedState$1@b96c99f, 2131231010=android.view.AbsSavedState$1@b96c99f}
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_RANDOM_OBJECT] :java.util.Random@754794a
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS] :[FragmentManager:bbf17dc5-3d36-4f45-8591-e8c694172a8f:StartIntentSenderForResult, FragmentManager:StartIntentSenderForResult, FragmentManager:StartActivityForResult, FragmentManager:bbf17dc5-3d36-4f45-8591-e8c694172a8f:StartActivityForResult, FragmentManager:f18d466b-b7da-4dfb-8e64-e70ca2877fd3:StartIntentSenderForResult, FragmentManager:RequestPermissions, FragmentManager:f18d466b-b7da-4dfb-8e64-e70ca2877fd3:RequestPermissions, FragmentManager:bbf17dc5-3d36-4f45-8591-e8c694172a8f:RequestPermissions, FragmentManager:f18d466b-b7da-4dfb-8e64-e70ca2877fd3:StartActivityForResult]
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_REGISTERED_RCS] :[970362824, 1449023170, 673057182, 1980615787, 732030492, 880125299, 34756747, 180096537, 2037503277]
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_LAUNCHED_KEYS] :[]
V/MainActivity: Extra[android:support:fragments] :androidx.fragment.app.FragmentManagerState@52860bb
V/MainActivity: Extra[android:lastAutofillId] :1073741823
V/MainActivity: Extra[android:fragments] :android.app.FragmentManagerState@b4c59d8
次のコード片は HomeFragment
-> ScreenAFragment
-> ScreenAFragment
-> DashboardFragment
-> ScreenBFragment
-> ScreenBFragment
と遷移したあとの onSaveInstanceState
で保存される Bundle
の中身です。
V/MainActivity: Extra[android:views] :{16908290=android.view.AbsSavedState$1@1f414da, 2131230768=androidx.appcompat.widget.Toolbar$SavedState@de37d0b, 2131230770=android.view.AbsSavedState$1@1f414da, 2131230776=android.view.AbsSavedState$1@1f414da, 2131230832=android.view.AbsSavedState$1@1f414da, 2131230845=android.view.AbsSavedState$1@1f414da, 2131231000=com.google.android.material.navigation.NavigationBarView$SavedState@b77c7e8, 2131231001=android.view.AbsSavedState$1@1f414da, 2131231002=android.view.AbsSavedState$1@1f414da, 2131231003=android.view.AbsSavedState$1@1f414da, 2131231004=android.view.AbsSavedState$1@1f414da, 2131231005=android.view.AbsSavedState$1@1f414da, 2131231006=android.view.AbsSavedState$1@1f414da, 2131231007=android.view.AbsSavedState$1@1f414da, 2131231009=android.view.AbsSavedState$1@1f414da, 2131231010=android.view.AbsSavedState$1@1f414da}
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_RANDOM_OBJECT] :java.util.Random@658fe01
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS] :[FragmentManager:f2e23a57-64b7-40c1-987c-67b229942a52:StartActivityForResult, FragmentManager:StartActivityForResult, FragmentManager:1d3d73b5-510f-46d0-83c5-67a57967f946:StartActivityForResult, FragmentManager:e99f9029-feea-46d6-992a-dd53987f4352:StartActivityForResult, FragmentManager:f72307d2-b184-4c93-83ae-716268eda9d4:RequestPermissions, FragmentManager:c59f328b-217a-462c-bbfd-bfaa3fca412d:RequestPermissions, FragmentManager:c59f328b-217a-462c-bbfd-bfaa3fca412d:StartIntentSenderForResult, FragmentManager:f72307d2-b184-4c93-83ae-716268eda9d4:StartIntentSenderForResult, FragmentManager:c909f600-5e0a-40b9-ae48-1bc129091fbc:RequestPermissions, FragmentManager:0c462cb4-081c-45d7-b392-8c7a87d48441:StartIntentSenderForResult, FragmentManager:c909f600-5e0a-40b9-ae48-1bc129091fbc:StartIntentSenderForResult, FragmentManager:f2e23a57-64b7-40c1-987c-67b229942a52:RequestPermissions, FragmentManager:RequestPermissions, FragmentManager:f72307d2-b184-4c93-83ae-716268eda9d4:StartActivityForResult, FragmentManager:StartIntentSenderForResult, FragmentManager:e99f9029-feea-46d6-992a-dd53987f4352:StartIntentSenderForResult, FragmentManager:e99f9029-feea-46d6-992a-dd53987f4352:RequestPermissions, FragmentManager:f2e23a57-64b7-40c1-987c-67b229942a52:StartIntentSenderForResult, FragmentManager:1d3d73b5-510f-46d0-83c5-67a57967f946:StartIntentSenderForResult, FragmentManager:1d3d73b5-510f-46d0-83c5-67a57967f946:RequestPermissions, FragmentManager:c909f600-5e0a-40b9-ae48-1bc129091fbc:StartActivityForResult, FragmentManager:0c462cb4-081c-45d7-b392-8c7a87d48441:StartActivityForResult, FragmentManager:c59f328b-217a-462c-bbfd-bfaa3fca412d:StartActivityForResult, FragmentManager:0c462cb4-081c-45d7-b392-8c7a87d48441:RequestPermissions]
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_REGISTERED_RCS] :[2089706753, 1706080793, 1158485467, 51228202, 1846532602, 1814449583, 397828847, 1371144438, 1258320399, 561754436, 162788726, 425072616, 1475652960, 1394065825, 410585168, 1762544164, 1172876950, 107606938, 731728962, 1712210388, 1580543357, 1350577612, 1637639531, 409318320]
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_LAUNCHED_KEYS] :[]
V/MainActivity: Extra[android:support:fragments] :androidx.fragment.app.FragmentManagerState@70ea4a6
V/MainActivity: Extra[android:lastAutofillId] :1073741823
V/MainActivity: Extra[android:fragments] :android.app.FragmentManagerState@5cadee7
注目すべきは KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS
と KEY_COMPONENT_ACTIVITY_REGISTERED_RCS
です。HomeFragment
へ遷移した直後から数えると、どちらも対応する値に保存されている配列の要素数が 15 件ほど増えています。
このまま画面遷移を続けると、どんどん Bundle
に保存するデータが増えていき、いつか Bundle
に保存可能なサイズを超えて TransactionTooLargeException
がスローされてしまいます。
解決方法
popUpTo
を利用することで画面遷移の挙動が来たいと食い違ってしまったり、潜在的な TransactionTooLargeException の問題が起きるため、別の方法で HomeFragment
から戻る操作をしたときに Activity を閉じる処理を作ります。
まず次のように popUpTo
および popUpToInclusive
を <action>
から削除します。
xml version ="1.0" encoding ="utf-8"
<navigation
xmlns android ="http://schemas.android.com/apk/res/android"
xmlns app ="http://schemas.android.com/apk/res-auto"
xmlns tools ="http://schemas.android.com/tools"
android id ="@+id/mobile_navigation"
app startDestination ="@+id/navigation_splash"
>
<fragment
android id ="@+id/navigation_splash"
android name ="dev.keithyokoma.navigationbehaviorissuepoc.ui.splash.SplashFragment"
android label ="Splash"
tools layout ="@layout/fragment_splash"
>
<action
android id ="@+id/nav_splash_to_home"
app destination ="@id/navigation_home"
/>
</fragment>
</navigation>
そして、Navigation Graph を保持している Activity で onBackPressed
をオーバーライドし、super.onBackPressed
を呼ぶより前に、Navigation Graph における現在の destination が HomeFragment
だったときに Activity#finish
を呼ぶようにします。
class MainActivity : AppCompatActivity() {
override fun onBackPressed() {
val navController = findNavController(R.id.nav_host_fragment_activity_main)
val currentDestination = navController.currentDestination
if (currentDestination != null && currentDestination.id == R.id.navigation_home) {
finish()
return
}
super .onBackPressed()
}
}
こうすると、BottomNavigationView のタブごとに backstack が管理されるようになります。
そのため、
HomeFragment
-> ScreenAFragment
-> ScreenBFragment
と遷移したあとで下タブの切り替えにより DashboardFragment
に切り替え、再度下タブを切り替えて Home に戻ると ScreenBFragment
が表示されます。
次の例は HomeFragment
-> ScreenAFragment
-> ScreenAFragment
-> DashboardFragment
-> ScreenBFragment
-> ScreenBFragment
と遷移したあとの onSaveInstanceState
で保存される Bundle
の中身です。先程の popUpTo
を使ったときの Bundle
よりも要素が少ないことがわかります。
V/MainActivity: Extra[android:views] :{16908290=android.view.AbsSavedState$1@9f9ac00, 2131230768=androidx.appcompat.widget.Toolbar$SavedState@9b66c39, 2131230770=android.view.AbsSavedState$1@9f9ac00, 2131230776=android.view.AbsSavedState$1@9f9ac00, 2131230832=android.view.AbsSavedState$1@9f9ac00, 2131230845=android.view.AbsSavedState$1@9f9ac00, 2131231000=com.google.android.material.navigation.NavigationBarView$SavedState@4bba27e, 2131231001=android.view.AbsSavedState$1@9f9ac00, 2131231002=android.view.AbsSavedState$1@9f9ac00, 2131231003=android.view.AbsSavedState$1@9f9ac00, 2131231004=android.view.AbsSavedState$1@9f9ac00, 2131231005=android.view.AbsSavedState$1@9f9ac00, 2131231006=android.view.AbsSavedState$1@9f9ac00, 2131231007=android.view.AbsSavedState$1@9f9ac00, 2131231009=android.view.AbsSavedState$1@9f9ac00, 2131231010=android.view.AbsSavedState$1@9f9ac00}
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_RANDOM_OBJECT] :java.util.Random@adac3df
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS] :[FragmentManager:StartActivityForResult, FragmentManager:a8648866-1799-4725-bf9a-3c18fd16871c:StartIntentSenderForResult, FragmentManager:b6e4fd95-7ef8-458c-bcb9-b2225938cbb0:StartActivityForResult, FragmentManager:3a2bacbf-a86d-489d-9c1d-a71fd5bd4488:StartActivityForResult, FragmentManager:a8648866-1799-4725-bf9a-3c18fd16871c:StartActivityForResult, FragmentManager:c93f4daf-374f-4783-8f0f-4bd6653fc5f2:StartActivityForResult, FragmentManager:c47d14ec-009c-421a-af55-06de42d1f394:StartActivityForResult, FragmentManager:c47d14ec-009c-421a-af55-06de42d1f394:RequestPermissions, FragmentManager:c47d14ec-009c-421a-af55-06de42d1f394:StartIntentSenderForResult, FragmentManager:RequestPermissions, FragmentManager:b6e4fd95-7ef8-458c-bcb9-b2225938cbb0:StartIntentSenderForResult, FragmentManager:3a2bacbf-a86d-489d-9c1d-a71fd5bd4488:StartIntentSenderForResult, FragmentManager:StartIntentSenderForResult, FragmentManager:b6e4fd95-7ef8-458c-bcb9-b2225938cbb0:RequestPermissions, FragmentManager:c93f4daf-374f-4783-8f0f-4bd6653fc5f2:RequestPermissions, FragmentManager:a8648866-1799-4725-bf9a-3c18fd16871c:RequestPermissions, FragmentManager:3a2bacbf-a86d-489d-9c1d-a71fd5bd4488:RequestPermissions, FragmentManager:c93f4daf-374f-4783-8f0f-4bd6653fc5f2:StartIntentSenderForResult]
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_REGISTERED_RCS] :[1620747589, 1570699793, 1461996471, 1427902856, 1446742021, 1292291998, 1067111087, 1099599798, 1366014542, 1112382134, 1149720256, 100641342, 663980103, 1486593643, 44957792, 578900699, 1940924946, 392594151]
V/MainActivity: Extra[KEY_COMPONENT_ACTIVITY_LAUNCHED_KEYS] :[]
V/MainActivity: Extra[android:support:fragments] :androidx.fragment.app.FragmentManagerState@44d882c
V/MainActivity: Extra[android:lastAutofillId] :1073741823
V/MainActivity: Extra[android:fragments] :android.app.FragmentManagerState@28892f5
こちらも画面遷移を経るとデータは増えていきますが、一定回数で頭打ちになっていく挙動もあるようなので(ちょっとここは確認が足りないので、ライブラリのコードを調べる必要がありそう)ひとまず TransactionTooLargeException の頻度は抑えられそうです。
関連リンク