Infinito Nirone 7

白羽の矢を刺すスタイル

CircleCI で Android アプリプロジェクトのビルドに利用する Docker Image が変わったので更新する

次のような記述で Android 用の Docker Image を利用すると、ジョブのステータスを表示する画面で You’re using a deprecated Docker convenience image. Upgrade to a next-gen Docker convenience image. といったメッセージが表示されます。

executors:
  android:
    docker:
      - image: circleci/android:api-30

メッセージのリンク部分をたどると、新しい next-gen Docker convenience image へ置き換えるための手順が示されていますが、Android に関してはどの Docker Image を使うべきか明示されていません。

discuss.circleci.com

代わりに、この手順を示した記事のコメントにて新しい Docker Image の情報を記載したページのリンクが示されています。

Legacy Convenience Image Deprecation - #6 by zmarkan - Announcements - CircleCI Discuss

DockerHub で CircleCI が公開している Docker Image は api-30 を最後に更新が止まっていて、api-31 以上を含むイメージは提供されていません。 代わりに、DockerHub で cimg という別の organization (実体は CircleCI)が提供している android のイメージを利用します。

circleci.com

cimg で公開しているイメージはこれまでと Docker Image のタグの付け方が異なります。 これまでは API Level ごとにタグを付けていましたが、今後はビルドした日付ごとにタグが付与されます。 また Docker Image に含まれる Android SDK の内容にも違いがあり、最新の API Level から数えて直近 4 つ分の API Level のものが Docker Image に含まれています。

この他、node や ndk などを含むイメージもあり、それぞれタグの日付を示す部分の後ろに variant 名を付けています。

この記事をかいた時点では 2022.04 が最新イメージとなっているので、上記 YAML は次のように書き換えることになります。

executors:
  android:
    docker:
      - image: cimg/android:2022.04

Jetpack Compose で GridLayout を実現する

Jetpack Compose には LazyVerticalGrid というグリッド表示をしてくれる Composable がありますが、LazyColumn など他のスクロール可能な Composable にネストしてグリッド表示を作りたい場合は LazyVerticalGrid は利用できません(スクロール可能な Composable をネストしてしまうので)。 Android View にある GridLayout の代替となる Composable があればよいのですが、現状はまだないようです *1

そこでなんとか GridLayout をうまく再現する方法として、accompanist にある FlowLayout を利用して実装してみます。 今回は Grid の各セルの横幅を画面幅やセル間のマージンから算出して設定し、GridLayout を横幅いっぱいに表示するものを作ります。あんまり汎用性はないです。

@Composable
fun GridLayout(
  modifier: Modifier = Modifier
  items: List<String>,
) {
  // カラム数
  val numOfColumns = 2
  // セル間のマージン
  val gridSpacing = 8.dp,
  // セル間のマージンを取るための Spacer の数
  val spacerCount = numOfColumns - 1
  val config = LocalConfiguration.current
  val gridPadding = // ... グリッド両脇につける padding の dp
  // 画面の横幅からセル間のマージンとグリッド両脇の padding を取り除き、カラム数で割ってセル1個の横幅を出す
  val cellWidth = (config.screenWidthDp.dp - (gridSpacing * spacerCount) - (gridPadding * 2)) / numOfColumns

  Box(
    modifier = modifier.fillMaxWidth().wrapContentHeight()
  ) {
    FlowRow(
      modifier = Modifier.fillMaxWidth().wrapContentHeight()
    ) {
      // [0, 1, 2, 3, 4] のようなリストを [0, 1], [2, 3], [4] といった形の 2 個要素をもつチャンクに分割し、チャンクを Grid の 1 行分として扱う
      categoryTags.chunked(numOfColumns).forEach { chunkedList ->
        val left = chunkedList[0] // チャンクの最初の要素は必ず存在する
        val right = chunkedList.getOrNull(1) // チャンクの 2 個目の要素は存在しないかもしれない

        Box(
          modifier = Modifier
            .requiredWidth(cellWidth)
            .wrapContentHeight(),
        ) {
          // left を使って UI をつくる
        }

        // セル間のマージンを取る Spacer
        Spacer(modifier = Modifier.width(gridSpacing))

        if (right == null) {
          // 要素はなくてもスペースは確保したい
          Spacer(
            modifier = Modifier
              .requiredWidth(cellWidth)
              .wrapContentHeight()
          )
        } else {
          Box(
            modifier = Modifier
              .requiredWidth(cellWidth)
              .wrapContentHeight(),
          ) {
            // right を使って UI をつくる
          }
        }

        // 最後に、セルの下部にもマージンをつける。横幅いっぱいに Spacer を置いて、次のセルが確実にこの Spacer の下に来るようにする。
        Spacer(
          modifier = Modifier
            .fillMaxWidth()
            .height(gridSpacing)
        )
      }
    }
  }
}

*1:Issue Tracker: https://issuetracker.google.com/issues/190893487 イシューは切られていますが優先度は高くないようです

age++

おかしい。ついこの間 0x20 歳になったばかりだと思っていたのに、もう年齢がインクリメントされてしまった。

コロナはまだまだ収束しそうにないし、それどころか戦争が始まるし、あちこちてんやわんやのドッタンバッタン大騒ぎ感がありますが、私は元気です。

今年は年始からいきなり全力で Jetpack Compose をしはじめたり、それに合わせて Kotlin coroutines にも手を出したりと、仕事でつかうスキルを刷新していく方に目標を置いている感があります。 特に Jetpack Compose はいままで仕事で使うのは消極的だったのが、Stable になったことで一気に機運が高まったところに新規の UI 開発がはじまったので入れることにしました。 もともとアプリ開発初期から Jetpack Compose 自体はどこかで入れたいと思っていて、設計も合わせやすいように作っていたので、導入自体は純粋に Jetpack Compose のことを学ぶことに集中できました。 このあたりの話は別途記事がかけるかなと思っています。

他には、DroidKaigi の活動の一環として DroidKaigi: Weekend Chat を毎週金曜日 YouTube Live で配信するということもやっています。

www.youtube.com

ことしもやっていくぞ!

干し芋

Bundle の大きさを知りたい

前回の Navigation Component の振る舞いに関する記事 に関連して、Activity#onSaveInstanceState などで最終的に Bundle がどの程度の大きさになっているのかを知りたくなったのでいろいろ試してみました。

blog.keithyokoma.dev

結論、次の Stack Overflow の回答にあるとおり、自分で ParcelBundle を書き込んで byte 列を取得し、そのサイズを測るのがもっとも単純な方法になります。

stackoverflow.com

AOSP のコードを読むと toString でそれらしいサイズを文字列化しようとしていたり、Bundle を dump するメソッドがありそうに見えますが、実際にはどれもうまくいきませんでした。。

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 郡がなくコンパイラが動きません。