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 の頻度は抑えられそうです。

関連リンク

AAC ViewModel と StateFlow を組み合わせたときのユニットテスト

前回の続きで、Kotlin Coroutines の StateFlow を利用した AAC ViewModel のテストをする場合の Kotlin Coroutines 1.6.0 の記述について。

blog.keithyokoma.dev

StateFlow をつかった AAC ViewModel

次のような AAC ViewModel を継承した SomeViewModel を作ります。 内部で StateFlow<String> を持っており、この StateFlow<String> の値を Jetpack Compose などで購読して使うような想定です。 StateFlow<String> を更新する操作も changeText メソッドとして定義してあります。

前回も似たようなモデルクラスをテストしましたが、今回はこの changeText メソッドが viewModelScope の中で StateFlow<String> を更新する点が異なります。

class SomeViewModel(
  private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : ViewModel() {
  private val textMutation: MutableStateFlow<String> = MutableStateFlow("foo")
  val text: StateFlow<String> = textMutation.asStateFlow()

  fun changeText(text: String) {
    viewModelScope.launch(mainDispatcher) {
      textMutation.value = text
    }
  }
}

この ViewModel のテストを次のように記述すると、失敗することがあります。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch(UnconfinedTestDispatcher(testScheduler)) {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }

  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

  override fun afterTest(testCase: TestCase, result: TestResult) {
    Dispatchers.resetMain()
    super.afterTest(testCase, result)
  }
}

問題は changeText メソッドで StateFlow<String> に対する値の更新操作が、この記述では即座に実行されないためにテストでかき集めようとしている更新した値のリストが初期値のみを含むリストになってしまう点にあります(値の送出と購読がそれぞれ並列に動くとき)。 この問題を解消する方法として、runCurrent を使う方法があります。

StateFlow に対する更新操作をするたびに runCurrent を呼び出すことで、購読側の処理も即座に実行されるようになります。 先程のテストの場合、// When: Change text のあとで実行している model.changeText("bar") のあとに runCurrent を呼び出すことで、期待する順序と数の値がテストで観測できるようになります。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch(UnconfinedTestDispatcher(testScheduler)) {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")
        runCurrent()          // <<<<<<=== here!

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }

  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

  override fun afterTest(testCase: TestCase, result: TestResult) {
    Dispatchers.resetMain()
    super.afterTest(testCase, result)
  }
}

注意点としては、runCurrent を使うことで実際のアプリケーションの動作環境では保証のない処理の順序を強制してしまう点があります。

Links

グリッド表示とリスト表示を組み合わせた構造の画面を作る場合の Groupie と JetpackCompose の対比

様々な要素を組み合わせた一覧画面の構成

次の図のように、グリッドやリストを組み合わせた画面を作ることを考えます。

f:id:KeithYokoma:20220106173409j:plain
画面構成

RecyclerView を使う場合、Groupie を使うとかなり楽にこの画面構成を実現可能です。 Jetpack Compose を使う場合、1.1.0-beta03 から LazyVerticalGrid の機能が充実したことで、Groupie と同じような画面構成が作れるようになりました。

この記事では、先の図に示したグリッドとリストを組み合わせた画面を Groupie と Jetpack Compose のそれぞれで実現する方法を解説します。

前提とするアーキテクチャ

AAC ViewModel で画面の状態を表すオブジェクトを管理し、Activity や Fragment でその変更を監視して表示を更新するようなアーキテクチャを想定します。 この例では StateFlow を使いますが、LiveData に読み替えてもよいでしょう。

data class SampleState(
  val gridData: List<String>,
  val listData: List<String>,
)

class SampleViewModel : ViewModel() {
  private val stateMutation: MutableStateFlow<SampleState> = MutableStateFlow(
    SampleState(
      gridData = listOf("Foo", "Bar", "Baz", "Qux", "Quux", "Foobar", "Corge", "Grault", "Garply"),
      listData = listOf("Hoge", "Fuga", "Piyo", "Hogera"),
    )
  )
  val states: StateFlow<SampleState> = stateMutation.asStateFlow()
}

Groupie で実現する手順

ViewBinding *1 を使いつつ、次のような画面を SampleActivity のレイアウトとして定義します。

<FrameLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  >

  <androidx.recyclerview.widget.RecyclerView
    android:id="@+id/mainContentView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    />

</FrameLayout>

次に RecyclerViewAdapter を定義します。Groupie が提供している GroupieAdapter などを使います。

class SampleAdapter : GroupieAdapter()

RecyclerView には GridLayoutManager を設定します。グリッドの列数を柔軟に変えるため、先ほど作った SampleAdapterspanSizeLookupGridLayoutManager に渡します。 今回は最大列数を 3 列に設定しています。

// グリッドの列数
const val SPAN_COUNT = 3

class SampleActivity : AppCompatActivity(R.layout.activity_sample) {
  private val binding: ActivitySampleBinding by viewBinding(ActivitySampleBinding::inflate)

  override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    val sampleAdapter: SampleAdapter = SampleAdapter()
    binding.mainContentView.layoutManager = GridLayoutManager(this, SPAN_COUNT).apply {
      spanSizeLookup = sampleAdapter.spanSizeLookup
    }

    // ...
  }
}

あとは、ViewModel が通知する状態オブジェクトを監視し、状態オブジェクトから View を生成するオブジェクトを作り SampleAdapter に渡して表示を更新するだけです。

SampleAdapter には次のようなデータクラスのインスタンスを渡します。これで1つのデータに対応した View が作成できます。それぞれ、文字列を表示する TextView が一つあるレイアウトを定義しているものとします。

sealed class MainContent<V : ViewBinding> : BindableItem<V>

data class GridItem(
  val text: String,
) : MainContent<ViewGridItemBinding>() {
  override fun bind(viewBinding: ViewGridItemBinding, position: Int) {
    viewBinding.label.text = text
  }

  override fun getLayout(): Int = R.layout.view_grid_item

  override fun initializeViewBinding(view: View): ViewGridItemBinding = ViewGridItemBinding.bind(view)

  override fun getSpanSize(spanCount: Int, position: Int): Int = 1 // 3 (SPAN_COUNT) 列のうち1列をつかって GridItem を表示する
}

data class ListItem(
  val text: String,
) : MainContent<ViewListItemBinding>() {
  override fun bind(viewBinding: ViewListItemBinding, position: Int) {
    viewBinding.label.text = text
  }

  override fun getLayout(): Int = R.layout.view_list_item

  override fun initializeViewBinding(view: View): ViewListItemBinding = ViewListItemBinding.bind(view)

  override fun getSpanSize(spanCount: Int, position: Int): Int = SPAN_COUNT // 3 (SPAN_COUNT) 列のうち 3 列をつかって ListItem を表示する
}

状態オブジェクトにはグリッド表示するデータとリスト表示するデータが別々のプロパティとして定義してあります。これらを上記の GridItem または ListItem に変換し 1 つの List<MainContent> にマージずれば、SampleAdapter の更新ができます。

class SampleActivity : AppCompatActivity(R.layout.activity_sample) {
  private val viewModel: SampleViewModel by viewModels()
  private val binding: ActivitySampleBinding by viewBinding(ActivitySampleBinding::inflate)

  override fun onCreate(savedInstanceState: Bundle?) {
    // ...
    val sampleAdapter: SampleAdapter = SampleAdapter()

    lifecycleScope.launch {
      viewModel.states.collect { state: SampleState ->
        // 状態オブジェクトの監視
        // 状態オブジェクトから Groupie の BindableItem を生成し 1 つの List にまとめ、Adapter に与える
        adapter.update(state.convertToMainContentItems())
      }
    }
  }
}

fun SampleState.convertToMainContentItems(): List<MainContent<*>> =
  gridData.map { data -> GridItem(data) } + listData.map { data -> ListItem(data) }

Jetpack Compose で実現する手順

Jetpack Compose では LazyVerticalGrid コンポーザブルを使って縦方向にスクロール可能なグリッド表示を作ります。 グリッドとリストの表示を組み合わせる場合、Jetpack Compose のバージョンは 1.1.0-beta03 以上である必要があります。 ただし Jetpack Compose のバージョンと Kotlin のバージョンの組み合わせによってはコンパイルエラーとなって利用できない組み合わせがあることに注意します*2

Jetpack Compose を使う場合は Groupie の場合に比べて準備するものが圧倒的に少なくなります。必要なものは LazyVerticalGrid と、状態オブジェクトが持っているグリッド表示するデータとリスト表示するデータを対応するコンポーザブルに変換する記述だけです。 Groupie と異なり、グリッド表示用のデータとリスト表示用のデータは別々に扱うことができます。

// グリッドの列数
const val SPAN_COUNT = 3

class SampleActivity : ComponentActivity() {
  private val viewModel: SampleViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    setContent {
      SampleScreen(viewModel)
    }
  }

  @Composable
  fun SampleScreen(viewModel: SampleViewModel) {
    ConstraintLayout {
      val states = viewModel.states.collectAsState() // 状態オブジェクトの監視
      val gridItem = states.value.gridData
      val listItem = states.value.listData

      val gridRef = createRef()
      SampleVerticalGrid(
        gridItem = gridItem,
        listItem = listItem,
        modifier = Modifier.constrainAs(gridRef) {
          start.linkTo(parent.start)
          end.linkTo(parent.end)
          top.linkTo(parent.top)
          bottom.linkTo(parent.bottom)
        }
      )
    }
  }

  @Composable
  fun SampleVerticalGrid(
    gridItem: List<String>,
    listItem: List<String>,
    modifier: Modifier = Modifier,
  ) {
    LazyVerticalGrid(
      cells = GridCells.Fixed(count = SPAN_COUNT),
      modifier = modifier,
    ) {
      items(items = gridItem) { text ->
        Text(text = text)
      }
      items(items = listItem, spans = { GridItemSpan(SPAN_COUNT) }) { text ->
        Text(text = text)
      }
    }
  }
}

より複雑な UI の構築

今回は状態オブジェクトがシンプルで文字列のリストしか保持していませんでしたが、実際にはもっと複雑なデータを取り扱うはずです。 リストの一部だけを表示するようなケースでは、次のようなデータ型を定義し状態オブジェクトから取り出すときに表示するデータだけを抽出すれば、Groupie でも Jetpack Compose でも UI のロジックとして表示・非表示を切り替えるロジックは不要です。 また状態オブジェクトを作り込んでおくことで、どういう状態のときにどういう表示用のデータが取得できればよいかをユニットテストで検証可能になります。

data class Data(
  val text: String,
  val isVisible: Boolean
)

data class SampleState(
  val gridData: List<Data>,
  val listData: List<Data>,
) {
  fun getVisibleGridData(): List<String> = gridData.filter { data -> data.isVisible }.map { data -> data.text }

  fun getVisibleListData(): List<String> = listData.filter { data -> data.isVisible }.map { data -> data.text }
}

*1:https://github.com/wada811/ViewBinding-ktx

*2:Jetpack Compose Compiler の 1.1.0-beta03 では Kotlin 1.5.31 までが要求されるのに対し、1.1.0-beta04 以降は Kotlin 1.6.0 が要求されます。また 1.1.0-rc02 では Kotlin 1.6.10 が要求されます。コンパイラのエラーを無視するオプションを有効化しても、Kotlin 1.5.x を利用する状態で 1.1.0-beta04 以降を使おうとすると、Jetpack Compose Compiler に必要な API 郡がなくコンパイラが動きません。

RxJava と Kotlin Coroutines のテストチートシート

忘れそうになるのでメモ。テストは Kotest をつかっているが、極力 JUnit でのテストの記述と対比しやすいよう StringSpec で書いていく。 最近 RxJava から Kotlin Coroutines への乗り換えを進めていて、テストの書き方がどう異なるか(あるいは同様となるのか)についてまとめたくなったので残している。

RxJava

次の SomeModel をテストしたい。

class SomeModel {
  private val textMutation: BehaviorSubject<String> = BehabiorSubject.createDefault("foo")
  val text: Subject<String> = textMutation.hide()

  fun changeText(text: String) {
    textMutation.onNext(text)
  }
}

changeText 関数によるテキストの変更を text を観測して確かめる。 RxJava では TestObserver があるのでこれを使う。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      // Given: SomeModel 
      val model = SomeModel()
      val observer: TestObserver<String> = model.text.test()

      // When: Change text
      model.changeText("bar")

      // Then: Observe 2 updates
      observer.awaitCount(2).assertValueCount(2)

      // And: "foo" comes first
      observer.assertValueAt(0) { text ->
        text == "foo"
      }

      // And: "bar" comes second
      observer.assertValueAt(1) { text ->
        text == "bar"
      }
    }
  }
}

Kotlin Coroutines

RxJava の例と似たようなクラスをテストしてみる。RxJava では BehaviorSubject をつかっていたが、Kotlin Coroutines では StateFlow を使う。

class SomeModel {
  private val textMutation: MutableStateFlow<String> = MutableStateFlow("foo")
  val text: StateFlow<String> = textMutation.asStateFlow()

  fun changeText(text: String) {
    textMutation.value = text
  }
}

SomeModel のテストは RxJava の例と同じく、changeText 関数によるテキストの変更を text を観測して確かめる。 RxJava の TestObserver に相当するものとして、StateFlow の通知する値を詰め込んでおく ArrayList を用意する。 ただし kotlinx-coroutines のバージョンによってテストの書き方が変わることに注意する。

次の例は 1.5.2 以前の書き方。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runBlockingTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }
}

次の例は 1.6.0 以降の書き方。 runBlockingTest が deprecated となり、代わりに runTest を使う。これにともなって、テストの前準備として StandardTestDispatcherDispatchers.setMain にわたすことと、runTest ブロック内で launch するときに UnconfinedTestDispatcher を利用して StateFlow が通知する全ての値を観測可能にすることが必要になる。 この変更についてはマイグレーションガイドが提供されている。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch(UnconfinedTestDispatcher()) {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }

  // Robolectric を利用したテストを記述するとき、StandardTestDispatcher の設定は必ず beforeTest で実行する
  //(beforeSpec では Looper クラスが見つからず失敗する)
  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

  override fun afterTest(testCase: TestCase, result: TestResult) {
    Dispatchers.resetMain()
    super.afterTest(testCase, result)
  }
}

2021 振り返り

去年の年末の振り返りも発掘したので、同じような感じで振り返ってみます。

チームで育てる Android アプリ設計の出版

2020年振り返りでは、技術者視点でDXを上げていく活動 として前職での活動やアウトプットを軽く振り返っていました。その結実として今年春に チームで育てる Android アプリ設計 を執筆・出版しました。

peaks.cc

クラウドファンディングの出だしも好調で、出版後もオンライン勉強会ということで YouTube 配信も実施しました。

www.youtube.com

共著の kgmyshin さんとともに、新規事業におけるチーム開発や大規模なチームでの開発という2つの軸で内容を詰め、最後に両者の対比をして締めくくるというような構成です。どちらも実際の事例をもとにした考察から様々な手法・手順の提案をしていて、この書籍をベースに各チームで議論を深め実践できる内容になっています。 現状の開発体制やコードの状態に課題を感じていたり、何らかのアクションを起こしたいが良いヒントはないかと探していたりする方にピッタリな本だと思いますので、是非手元に書籍または電子書籍を用意しつつ、YouTube のアーカイブ動画を視聴いただければと思います。

書評も書いていただいているので、合わせてご覧ください。

muumuutech.hatenablog.com

jmatsu.hatenablog.com

note.com

DroidKaigi における定期的な YouTube 配信業

当初は DroidKaigi 2021 のオンライン開催を事前に盛り上げていくための企画として動き始めた DroidKaigi: Weekend Chat ですが、会期後にも引き続き配信を続けています。

www.youtube.com

配信の頻度は不定期で間があくこともありますが、こちらは 2022 年以降も続けていく予定です。 配信は基本的に音声を YouTube に流せれば良いので Mac でやっています。Windows のほうが配信環境の作りやすさでは分があるのですが、普段遣いが全部 Mac なので Mac でやっています。 OBS ではマイクの音声しか拾えないので、Discord での会話や素材 BGM を OBS に流すためには Loopback という別のソフトウェアが必要です。無課金だと一定時間までしか音声が拾えず、時間を超えると雑音がのる仕様なことに気をつけてください(ライセンス課金しましょう)。

rogueamoeba.com

F1

2021 年はレッドブル・ホンダがついにドライバーズ・ワールドチャンピオンを取りましたね。物議を醸すことの多い年でもありましたが、実況・解説でも語られたとおりフィクションの筋書きでは臭すぎるくらい劇的な結末を迎えました。

F1 といえば近年は YouTube 動画や配信にも力を入れているようで、レース期間中の無線によるドライバーとチームの会話まとめ動画も毎レース作られています。今年は特にイタリア GP でマクラーレンが優勝したときの無線が好きで何度か繰り返し聞いています。チーム側がねぎらいの言葉をドライバーにかけている裏で、チーム代表が大はしゃぎで「HAHA!」と喜んでいる声が聞こえてきてとても良いです。伝統あるチームの雰囲気も今の代表で大分変わったんだなと思いました。

自転車

夏も終わり始めた頃に見かけた記事がきっかけで、11月に富山にサイクリングしに行きました。

めっちゃきれいな景色やん…とおもい、一年のうちでも晴れの確率の高い11/22にサイクリングを決行!とおもったら、見事に雨に振られてしまいました。悲しい。ただせっかくなので、雨天100km海岸沿いライドをしてきました。なかなか大変ではありましたが、普段見ることのない日本海を横目に射水から滑川まで往復しました。しっかりとコースが設定してあり、路面の標示と標識の両方が設置してあるため、初見でも迷うことなく走れるようになっています。

www.strava.com

あとは、細々したことですがものが壊れる一年でもありました。目立ったものではクリートの破断とタイヤ・チューブのパンクですね。パンクは11月と12月で連続して発生してしまい、何かと悲しい気持ちになりました…

ところで、去年は真面目にまとめていた運動の成果ですが今年はご覧の有様でございます。本当にありがとうございました。やはり苦手なランニングは続かなかったよ……

Dagger Hilt と WorkManager を組み合わせて使う

Dagger Hilt は Android 用に様々な場面で使いやすいようになっていて、WorkManager を使った場合にも Dagger Hilt で DI が実現できるようになっています。

依存関係

Dagger Hilt と WorkManager を依存関係に追加します。2021/12 時点で Dagger Hilt の KSP 対応は完了していないので、kapt を使います。

buildscript {
    dependencies {
        // Hilt Gradle Plugin への依存を追加
        classpath("com.google.dagger:hilt-android-gradle-plugin:2.40.3")
    }
}
plugins {
    // kapt を適用
    kotlin("kapt")
    // Hilt Gradle Plugin を適用
    id("dagger.hilt.android.plugin")
}

dependencies {
    // Dagger-Hilt の依存
    implementation("com.google.dagger:hilt-android:2.40.3")
    kapt("com.google.dagger:hilt-android-compiler:2.40.3")
    // AndroidX Hilt の依存
    implementation("androidx.hilt:hilt-common:1.0.0")
    kapt("androidx.hilt:hilt-compiler:1.0.0")
    implementation("androidx.hilt:hilt-work:1.0.0")
    // WorkManager の依存
    implementation("androidx.work:work-runtime-ktx:2.7.1")
}

kapt {
    correctErrorTypes = true
}

Worker の定義

Worker の定義は次の通りで、@HiltWorker, @AssistedInject, @Assisted の 3 つのアノテーションを使います。 - @HiltWorker: クラス定義に使うアノテーション - @AssistedInject: Worker のコンストラクタに使うアノテーション - @Assisted: WorkManager 内部で Worker に渡される Context と WorkerParameters に対して使うアノテーション

@HiltWorker
class SampleWorker @AssistedInject constructor(
    @Assisted applicationContext: Context,
    @Assisted params: WorkerParameters,
) : Worker(applicationContext, params) {
    override fun doWork(): Result {
      // ...
    }
}

この Worker に対し、さらに別のオブジェクトを差し込みたい場合は単純にコンストラクタに差し込みたいオブジェクトを追加していくだけです。

@HiltWorker
class SampleWorker @AssistedInject constructor(
    @Assisted applicationContext: Context,
    @Assisted params: WorkerParameters,
    // なんらかの data source レイヤのクラスを注入する(別途この data source の実体を提供するための module の宣言などは必要)
    private val sampleDataSource: SampleDataSource,
) : Worker(applicationContext, params) {
    override fun doWork(): Result {
      // ...
    }
}

WorkerFactory の追加

WorkManager では Work クラスの生成はほぼ自分で記述することがありません。Worker の定義がシンプルでコンストラクタの引数に Context と WorkerParameters しかない場合は、WorkRequest を経由して自動で Worker がインスタンス化されます。一方で引数に Context と WorkerParameters 以外のものがある場合は WorkManager が自動で Worker を生成できないため、Worker を生成するための手順を実装し WorkManager に教えてあげる必要があります。

Dagger Hilt を使う場合、先ほどの SampleWorker を生成するには Dagger が知っている object graph から SampleDataSource の実体を取り出して Worker にわたす手順が必要ですが、その手順は hilt-work が用意してくれていて、アプリの実装としては Application クラスを拡張し必要なインタフェースの実装を追加するだけになります。

@HiltAndroidApp
// androidx.work の Configuration.Provider を実装する
class ExampleApplication : Application(), Configuration.Provider {

    // Dagger Hilt 用の WorkerFactory
    @Inject lateinit var workerFactory: HiltWorkerFactory

    // Dagger Hilt 用の WorkerFactory を設定して Configuration を返す
    override fun getWorkManagerConfiguration() =
        Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

この設定のため AndroidManifest.xml では WorkManager の default initializer を無効化しておきます。default initializer の無効化手順は、アプリで App Startup を使っているかどうかによって変わります。WorkManager は内部で App Startup の仕組みを使っているため、アプリが WorkManager 以外の用途で App Startup を使っている場合は、その App Startup から WorkManager のみを無効化する必要があります。

<!-- アプリで他に AppStartup を使わない場合 -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    tools:node="remove">
</provider>
<!-- アプリで他に AppStartup を使っている場合 -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <meta-data
        android:name="androidx.work.WorkManagerInitializer"
        android:value="androidx.startup"
        tools:node="remove" />
</provider>

WorkRequest を使って動かす

あとは Worker を動かす手順を実装するだけです。注意点として、WorkManager のインスタンスは必ず WorkManager.getInstance(Context) を使って取得します(引数のない getInstance メソッドでは WorkerFactory の設定などが無視されてしまう)。

val delay = 1000L
val params = Data.Builder()
    .putInt("number", 1)
    .build()

val workManager = WorkManager.getInstance(Context)
val work: OneTimeWorkRequest = OneTimeWorkRequest.Builder(SampleWorker::class.java)
    .setInitialDelay(delay, TimeUnit.MILLISECONDS)
    .setInputData(params)
    .build()
workManager.beginUniqueWork("sample_work", REPLACE, work).enqueue()

Target SDK Version を 31 に上げるときに引っかかるポイント: SparseArray

Kotlin を使用したアプリケーションという前提で、次のような構成のアプリがあり、このアプリの Target SDK Version および Compile SDK Version を 31 (Android 12) に上げることを考えます。

Target SDK Version を上げる前の構成: build.gradle

android {
    compileSdk 30

    defaultConfig {
        applicationId "dev.keithyokoma.sample"
        minSdk 23
        targetSdk 30
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.6.0'
}

SparseArray に値をセットする

上記の構成のとき、次のようなコードは正しく意図通り動作します。 注目すべきは SparseArray に値をセットするコードです。AndroidX Core KTX の set 拡張関数 を使うことで、メソッド呼び出しではなく指定した添字への値の代入のように記述できます。

import androidx.core.util.set

val array = SparseArray<String>()
array[0] = "test" // AndroidX Core KTX の set 拡張関数を使っている

Target SDK Version および Compile SDK Version を 31 にあげる

ここで構成を変え、Target SDK Version と Compile SDK Version を最新の 31 に上げます。 するとビルド時にエラーが出力されます。

Call requires API level S (current min is 23): android.util.SparseArray#set

エラーメッセージが示す関数が androidx.core.util.set ではなく android.util.SparseArray#set になっています。これは、Android 12 (API Level 31) から新たに SparseArray#set メソッドが追加されたためです。 この新しい SparseArray#set メソッドを Kotlin で解釈したとき、Java で定義されたメソッドを添え字での値の代入として扱えるようにする仕組みが働き、array[0] = "test" はそのまま正しくコンパイル可能なコードになります。 同時に AndroidX Core KTX の set 拡張関数が使われなくなり(31 に上げると、上記 Kotlin のコードの import 文が unused になります)、minSdkVersion = 31 を要求する新しいメソッドを使っていることになるため、このようなエラーになります。

回避策

Target SDK Version および Compile SDK Version を 31 にした状態では拡張関数が利用できないので、次のように拡張関数が呼び出していた SparseArray#put を使います。

val array = SparseArray<String>()
array.put(0, "test")

類似のケース

実は Android 11 (API 30) 対応でも SparseArray の使用方法で実行時にエラーとなるケースがあったようです。

https://issuetracker.google.com/issues/168724187?pli=1