こんにちは。@KeithYokoma です。今回はこれまでの投稿からは少し話題を変え、AndroidX Fragment ライブラリに関する問題の発見と解決にまつわることを解説しようと思います。
画面の状態を保存する時に TransactionTooLargeException が多発し始める
以前の記事でも紹介しましたが、ギフトモールアプリでは次のような構成で画面を実装しています。この構成で Unidirectional なデータフローを作ることが第一の目的ですが、SavedStateHandle
を組み合わせることによって ViewModel
の状態の保存と復帰も実現しています。ギフトモールアプリでは ViewState
で ViewModel
の状態を表現しているため、ViewState
の保存と復帰を共通化しています。
// 共通のベースとなるインタフェースと ViewModel の定義 interface ViewState : Parcelable abstract class StateSavingViewModel<S : ViewState>( defaultState: S, private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val stateMutation: MutableStateFlow<S> = MutableStateFlow(savedStateHandle[KEY_SAVE_VIEW_STATE] ?: defaultState) val states: StateFlow<S> = stateMutation.asStateFlow() val latestState: S get() = states.value companion object { private const val KEY_SAVE_VIEW_STATE = "view_state" } } // ViewState と ViewModel の実装例。 data class HomeViewState( val homeSections: List<HomeSection> = listOf(), ) : ViewState class HomeViewModel( private val fooDataSource: FooDataSource, savedStateHandle: SavedStateHandle, ) : StateSavingViewModel(defaultState = HomeViewState(), savedStateHandle = savedStateHandle) { fun load() { // fooDataSource からデータを読み込んで HomeViewState の homeSections を更新する処理 } }
SavedStateHandle
は一見すると汎用的なデータホルダーのように見えますが、実際には内部的に Bundle
を使ってデータを保持します。よって SavedStateHandle
に保存可能なデータは Bundle
に保存可能なデータと同じです。また Bundle
に保存可能なデータサイズにも制約1があり、SavedStateHandle
も同様の制約を受けます。この制約を超えてデータを保存しようとすると TransactionTooLargeException
が投げられアプリがクラッシュします。前述したコード例においては、HomeViewState
オブジェクトの保持するデータサイズに制約があることになります。
ギフトモールアプリでは一時期からこの TransactionTooLargeException
を原因とするクラッシュレポートが急増しました。どのクラッシュレポートも画面の状態を保存しようとしたときにデータサイズの制約を超えてしまったことを示していました。
しかし Bitmap
のような明らかに1MBの制約を簡単に超えそうなオブジェクトは保持しておらず、データサイズが大きくなると考えられるものは HomeViewState
の例にある List
くらいでしたが、実測値でも List
は最大 200kB ほどに収まっており TransactionTooLargeException
が起きるようには思えませんでした。
Fragment の内部処理を追って原因を特定する
HomeViewState
の保持するデータサイズは小さいにもかかわらず状態の保存に失敗しているということは、何らかの予期しないデータの書き込みを状態の保存時にしている可能性があります。
そこで HomeViewState
だけでなく Fragment
全体で Bundle
にどのようなデータを書き込んでいるかを見てみることにしました。
TransactionTooLargeException
でアプリがクラッシュするとシステムは次のようなログを出力します。
- AndroidX Fragment 1.5.x までのログ
ActivityStopInfo W Bundle stats: ActivityStopInfo W android:viewHierarchyState [size=6584] ActivityStopInfo W android:views [size=6536] ActivityStopInfo W androidx.lifecycle.BundlableSavedStateRegistry.key [size=582288] ActivityStopInfo W androidx.lifecycle.internal.SavedStateHandlesProvider [size=1092] ActivityStopInfo W android:support:activity-result [size=2252] ActivityStopInfo W KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS [size=1456] ActivityStopInfo W android:support:fragments [size=578576] ActivityStopInfo W fragment_9fc0b71a-7945-44ed-afa6-2e3625da202b [size=578084] ActivityStopInfo W state [size=578052]
このログから fragment_9fc0b71a-7945-44ed-afa6-2e3625da202b
のデータが一番大きいことがわかります。fragment_9fc0b71a-7945-44ed-afa6-2e3625da202b
は NavHostFragment
で管理している現在表示中の Fragment
のことを指します。そこでもう少し深堀りし、その Fragment
が保存しようとしているデータの内訳を知りたいですが、このログからは見えてきません。
そこで Fragment
の状態を保存する処理の実体である FragmentState#writeToParcel
にブレークポイントを設定し、デバッガを使ってどのようなデータを書き込んでいるかを見てみました。
すると FragmentState
が持つ mSavedFragmentState
に SavedStateHandle
で保存した HomeViewState
が保存されており、mSavedFragmentState
をまるっとそのまま保存していることがわかりました。
そしてこの mSavedFragmentState
の中をよくよく見てみると、HomeViewState
が異なるキーで2重に mSavedFragmentState
に存在することがわかりました2。自分たちで書いたコードとしては HomeViewState
オブジェクトをひとつ SavedStateHandle
を介して保存しているつもりでしたが、Fragment
の内部では HomeViewState
オブジェクトをふたつ分保存していたということで、どんなに HomeViewState
が十分に小さいと思っていても実際には倍の容量を必要としていました。
ちなみに TransactionTooLargeException
発生時のログ出力は AndroidX Fragment 1.6.0-alpha01 から改善し、より細かく Bundle
の内訳を確認できます。
- AndroidX Fragment 1.6.0-alpha01 以降のログ
ActivityStopInfo W Bundle stats: ActivityStopInfo W android:viewHierarchyState [size=6704] ActivityStopInfo W android:views [size=6656] ActivityStopInfo W androidx.lifecycle.BundlableSavedStateRegistry.key [size=546284] ActivityStopInfo W androidx.lifecycle.internal.SavedStateHandlesProvider [size=1092] ActivityStopInfo W android:support:activity-result [size=2252] ActivityStopInfo W KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS [size=1456] ActivityStopInfo W android:support:fragments [size=542572] ActivityStopInfo W fragment_02795c46-b71a-4ebf-b4d0-4f3e11676843 [size=542080] ActivityStopInfo W childFragmentManager [size=539668] ActivityStopInfo W fragment_bcb533f9-fd30-4807-8182-2b3510e88075 [size=539176] ActivityStopInfo W viewRegistryState [size=269804] ActivityStopInfo W androidx.lifecycle.BundlableSavedStateRegistry.key [size=269680] ActivityStopInfo W androidx.lifecycle.internal.SavedStateHandlesProvider [size=268720] ActivityStopInfo W androidx.lifecycle.ViewModelProvider.DefaultKey:com.github.keithyokoma.poc.fatbundle.ui.home.HomeViewModel [size=268520] ActivityStopInfo W values [size=268428] ActivityStopInfo W registryState [size=268972] ActivityStopInfo W androidx.lifecycle.BundlableSavedStateRegistry.key [size=268848] ActivityStopInfo W androidx.lifecycle.internal.SavedStateHandlesProvider [size=268720] ActivityStopInfo W androidx.lifecycle.ViewModelProvider.DefaultKey:com.github.keithyokoma.poc.fatbundle.ui.home.HomeViewModel [size=268520] ActivityStopInfo W values [size=268428] ActivityStopInfo W savedInstanceState [size=1284]
問題のレポートと対策
Google IssueTracker に起票する
原因をつきとめたので Google IssueTracker に起票しライブラリの問題をレポートしました。 レポートにあたっては PoC (Proof of Concept) となるプロジェクトを作成しておき問題を再現しやすくしておくと解決までのやりとりが簡単になります。
この問題の修正は Fragment 1.5.5 および Fragment 1.6.0-alpha04 でリリースされています。
ViewState の構造の改善
一方でいくら簡単な List
とはいえデータサイズを無限に大きくして良いわけではないため、List
の要素数が多くなりがちな画面で ViewState
で保持するリストのサイズを制限するような対策も並行して導入していきました。
具体的な対策方法には、メモリキャッシュが実現可能な場所では List
をメモリキャッシュに乗せておき、ViewState
の保存時には List
を無視あるいは空にした上で保存する方法があります。
画面が復帰したときにメモリキャッシュから覚えておいた List
を ViewState
に与えることで元通りの画面に戻すことになります。
ページングで表示を切り替えている画面では表示中のページ数を覚えておけばよく、メモリキャッシュなしでも元通り復元可能です(代わりに API 呼び出しやディスクキャッシュへのアクセスがあるはずなので、ネットワーク I/O やディスク I/O などが発生しますが)。
ただしいずれの方法でも、スクロール位置も覚えておかないと完全には画面を元通りにできなくなるため、追加で工夫が必要になります。またメモリキャッシュも無限大ではないため、メモリに乗せるには大きすぎる場合はディスクキャッシュなど別の方法を用いることになります。
おわりに
TransactionTooLargeException
というと Bundle
に Bitmap
のような巨大なオブジェクトを保存したときに起きるもの、というイメージがありますが、他のデータ構造でも TransactionTooLargeException
は簡単に発生します。
Fragment
や ViewModel
などを用いてアーキテクチャを形作り役割を明確化していくなかでも、常に背後にある制約に気を配った設計をしましょう。
ギフトモールでは、一緒に働く仲間を募集しています!
まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!