Fragment が SavedStateHandle で保存したオブジェクトを2重に保持してしまう問題の発見と解決までの道のり

こんにちは。@KeithYokoma です。今回はこれまでの投稿からは少し話題を変え、AndroidX Fragment ライブラリに関する問題の発見と解決にまつわることを解説しようと思います。

画面の状態を保存する時に TransactionTooLargeException が多発し始める

以前の記事でも紹介しましたが、ギフトモールアプリでは次のような構成で画面を実装しています。この構成で Unidirectional なデータフローを作ることが第一の目的ですが、SavedStateHandle を組み合わせることによって ViewModel の状態の保存と復帰も実現しています。ギフトモールアプリでは ViewStateViewModel の状態を表現しているため、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-2e3625da202bNavHostFragment で管理している現在表示中の Fragment のことを指します。そこでもう少し深堀りし、その Fragment が保存しようとしているデータの内訳を知りたいですが、このログからは見えてきません。

そこで Fragment の状態を保存する処理の実体である FragmentState#writeToParcel にブレークポイントを設定し、デバッガを使ってどのようなデータを書き込んでいるかを見てみました。 すると FragmentState が持つ mSavedFragmentStateSavedStateHandle で保存した 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) となるプロジェクトを作成しておき問題を再現しやすくしておくと解決までのやりとりが簡単になります。

issuetracker.google.com

この問題の修正は Fragment 1.5.5 および Fragment 1.6.0-alpha04 でリリースされています。

ViewState の構造の改善

一方でいくら簡単な List とはいえデータサイズを無限に大きくして良いわけではないため、List の要素数が多くなりがちな画面で ViewState で保持するリストのサイズを制限するような対策も並行して導入していきました。

具体的な対策方法には、メモリキャッシュが実現可能な場所では List をメモリキャッシュに乗せておき、ViewState の保存時には List を無視あるいは空にした上で保存する方法があります。 画面が復帰したときにメモリキャッシュから覚えておいた ListViewState に与えることで元通りの画面に戻すことになります。

ページングで表示を切り替えている画面では表示中のページ数を覚えておけばよく、メモリキャッシュなしでも元通り復元可能です(代わりに API 呼び出しやディスクキャッシュへのアクセスがあるはずなので、ネットワーク I/O やディスク I/O などが発生しますが)。

ただしいずれの方法でも、スクロール位置も覚えておかないと完全には画面を元通りにできなくなるため、追加で工夫が必要になります。またメモリキャッシュも無限大ではないため、メモリに乗せるには大きすぎる場合はディスクキャッシュなど別の方法を用いることになります。

おわりに

TransactionTooLargeException というと BundleBitmap のような巨大なオブジェクトを保存したときに起きるもの、というイメージがありますが、他のデータ構造でも TransactionTooLargeException は簡単に発生します。 FragmentViewModel などを用いてアーキテクチャを形作り役割を明確化していくなかでも、常に背後にある制約に気を配った設計をしましょう。


ギフトモールでは、一緒に働く仲間を募集しています!

まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com


  1. この制約は Binder のバッファサイズによるものなので、1つの Bundle ではなく1プロセス全体でみて 1MB のバッファサイズを超えてはいけません。500kB を超える Bundle を2個同時に扱うことでも制約にひっかかります。
  2. androidx.lifecycle.BundlableSavedStateRegistry.keyandroid:view_registry_state