Infinito Nirone 7

白羽の矢を刺すスタイル

チームで育てるAndroidアプリ設計の一般販売が始まりました。

告知記事です。

Peaks のクラウドファンディングで執筆し先日電子版を先行リリースした「チームで育てるAndroidアプリ設計」の一般販売が始まりました。 これまではクラウドファンディングで出資いただいた方にのみ電子版・書籍の配送手続きをしていましたが、本日からクラウドファンディングに参加していない一般の方々にも電子版・物理本の購入をいただけるようになりました。

peaks.cc

チームで育てるAndroidアプリ設計とは

改めてこの書籍が何なのかを紹介すると、大小様々な規模のチームで継続的にAndroidアプリの開発をすすめていく中で直面するアーキテクチャの成長痛を乗り越えるためのノウハウを詰め込んだ本です。

アーキテクチャは一度整えればそれで終わるものではなく、プロダクトの成長やチームの成長とともに少しずつ形を変えていくものであるという考えのもと、最初の一歩としてどのようにアーキテクチャを選定しチームに根付かせていくか、またアプリの成長にともなって徐々に現れるひずみをどのように解消していくのか、実際の方法論を交えつつも根本にある思想や考え方、行動指針を示すことで、特定のチームにおける実例を他のチームにも活かせるプラクティスとしてまとめています。

新規事業の立ち上げから運用にいたるまでの比較的小規模なチームにおける事例を @kgmyshin さんが担当し、すでに成長を続けてきたサービスをさらに拡大していく比較的大規模なチームにおける事例を自分が担当しました。それぞれ4章分の内容があります。そして最後の章では大小それぞれの事例を振り返り、規模によらない共通点や規模によって異なるポイントをまとめています。

クラウドファンディング開始当初の意気込みなんかは次の記事に書いてありますのであわせてどうぞ。

blog.keithyokoma.dev

オンライン輪読会

実はこのプロジェクトはまだ続いていて、出版後にオンラインで輪読会を開催します。

techbookfest.connpass.com

全9回、各回で書籍の1章分の内容を輪読します。初回は早速明日4/27の22時からで、1週間ごとに読みすすめる予定です。 YouTube で配信予定で、アーカイブもあるので当日参加できない方もあとからご視聴いただけます。

DroidKaigi: Weekend Chat の配信を支える構成

Mac を利用した Discord と OBS による YouTube Live 配信環境

DroidKaigi: Weekend Chat は Mac 上で Discord をつないで @mhidaka さんと話しているのを OBS に流して YouTube Live 配信にのせています。

Loopback

Windows であれば音声キャプチャはそれほど難しくありませんが、Mac でやるとなるとソフトウェアないしハードウェアを揃えていかないと配信環境が整いません。 特に Discord からの音声を配信にのせたり、BGM として使う音楽を配信にのせたりする場合はこれらのソフトからのオーディオ出力をキャプチャする手段が Mac OS 標準にも OBS にもないため、別途キャプチャ用のものが必要です。

そこで DroidKaigi: Weekend Chat 開始当初から使っているソフトとして Loopback が役立ちます。 Loopback は仮想オーディオ出力デバイスを作成し、その出力に対してどのソフトからのオーディオを入力するかをビジュアルで分かりやすく設定できます。

自分の場合、初回から長らくは Discord の音・Chrome の音(BGM 用・YouTube の BGM 素材動画から音を取り込んでいた)をソースとして OBS に流す仮想オーディオ出力デバイスを作成し、Discord から聞こえてくる @mhidaka さんの音と BGM を OBS に流しつつ、自分のマイク入力はそのまま Discord と OBS に流すようにしていました。

ただしこの設定だと、話し相手の mhidaka さんには自分の声しか聞こえないので BGM が入らず、mhidaka さん側にも BGM を流すためにわざわざ mhidaka さんに同じ BGM の再生ページを開いてもらって再生タイミングをせーので合わせる、という超絶運用でカバーをすることになってしまいました。

Loopback では、仮想オーディオ出力デバイスは複数作成可能です。 そこで、次のステップとして、Discord に流すための仮想オーディオ出力デバイスと OBS に流すための仮想オーディオ出力デバイスの 2 つを作成し、BGM の再生タイミングをせーので合わせることなく mhidaka さん側にも流せるようにしました。

まず OBS 用の仮想オーディオ出力デバイスは、Discord の音、BGM の音、追加で SE を鳴らすソフトの音を出力するように設定します。

次に Discord 用の仮想オーディオ出力デバイスでは、Discord のオーディオ入力をこの仮想オーディオ出力デバイスとするため、マイクの入力、BGM の音、SE を鳴らすソフトの音を出力するようにします。

これで OBS と Discord の両方に BGM を出力できるようになります。 ここで注意すべき点としては、Discord は独自にノイズキャンセルする仕組みを持っていて、ラジオ配信などで使う BGM の音量が小さいとノイズ判定されて mhidaka さん側では消えてしまうことに気をつけます。 ノイズ判定を回避して BGM を届けるには Discord の設定で Input Sensitivity を手動で設定し、閾値を下げておく必要があります。この閾値を下げても YouTube の配信には影響はありません。

このあとさらに YAMAHA AG06MK2 を購入しましたがその話はまた別記事で書こうと思います。

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