Infinito Nirone 7

白羽の矢を刺すスタイル

BottomNavigationView と Jetpack Navigation の組み合わせでバックスタック管理の挙動が変わり TransactionTooLargeException になるパターン

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 を考えてみます。 

f:id:KeithYokoma:20220221204450p:plain
画面遷移の例

SplashFragment を起点に HomeFragment へ遷移し、そこからは下タブの切り替えで DashboardFragmentNotificationsFragment へ移動します。各タブは ScreenAFragmentScreenBFragment へ遷移するボタンを持っており、ScreenAFragmentScreenBFragment はさらにお互いと自分自身へ遷移するボタンを持っています。 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 には popUpTopopUpToInclusive という、画面遷移後の戻り先を定義する属性があります。画面遷移を定義する <action> 要素の属性として指定して使います。 次の例では、SplashFragment から HomeFragment への画面遷移について、popUpTo の指定により HomeFragment からの戻り先を SplashFragment に設定していますが、さらに popUpToInclusivetrue とすることで、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#onSaveInstanceStateBundle に保存しているデータにも問題があります。 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_KEYSKEY_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 の頻度は抑えられそうです。

関連リンク