本番環境でのみ発生するクラッシュ
テストを難なく通過した機能で、これには不意をつかれました。ユーザーがリスト項目をタップすると、アプリはオブジェクトをシリアライズして詳細画面に渡す — そして、クラッシュ。私のテストデータは小さなものでした。実際のユーザーは本物のデータを持っていました。長い説明文、埋め込み画像のバイト配列、数百キロバイトに膨れ上がるネストされたリスト。IPCトランザクションバッファはデバイス上のすべてのアクティブなトランザクションで共有される1MBが上限です。QA中は一切の警告なしに、その上限を超えていたのです。
スタックトレースはこのようになります:
android.os.TransactionTooLargeException: data parcel size 1,048,576 bytes
at android.os.BinderProxy.transactNative(Native Method)
at android.os.BinderProxy.transact(Binder.java:804)
at android.app.IActivityManager$Stub$Proxy.startActivity(IActivityManager.java:3919)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1609)
at android.app.Activity.startActivityForResult(Activity.java:4586)
data parcel sizeの後ろの数値が手がかりです。BundleやIntentを通じて1MBに近い、あるいは超えるデータはこの例外を引き起こします。
問題の診断
実際に送信しているデータを確認する
startActivityを呼び出す前に、簡単なサイズチェックを追加しましょう。AndroidはBundleのサイズを直接公開していませんが、Parcelを使って計測できます:
fun Bundle.sizeInBytes(): Int {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
val size = parcel.dataSize()
parcel.recycle()
return size
}
// Before startActivity
val bundle = intent.extras
Log.d("BundleSize", "Intent extras: ${bundle?.sizeInBytes()} bytes")
ダミーのフィクスチャではなく、実際のユーザーデータでテストしてください。500KBを超えるものが見えたら、すでに危険ゾーンです — 1MBの制限は共有されており、あなたのトランザクション専用に確保されているわけではありません。
問題のあるフィールドを特定する
各フィールドを個別にシリアライズして原因を見つけます:
val parcel = Parcel.obtain()
parcel.writeParcelable(myLargeObject, 0)
Log.d("ParcelSize", "myLargeObject: ${parcel.dataSize()} bytes")
parcel.recycle()
多くの場合、バイト配列に詰め込まれたビットマップ、Parcelableオブジェクトのリスト、または見た目よりもはるかに大きくシリアライズされる深くネストされたデータクラスが原因です。1024×768の非圧縮ビットマップ1枚は、バイト配列として約3MBになります — これは即座にクラッシュします。
解決策
方法1: IDを渡して、受け取り側でデータを取得する
これが最もクリーンな修正方法です。オブジェクト全体をIPC境界越しに送る代わりに、識別子を渡して、遷移先の画面がデータを自分でロードするようにします。
// Sender
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("article_id", article.id) // just a Long or String
startActivity(intent)
// Receiver (DetailActivity)
val articleId = intent.getLongExtra("article_id", -1)
viewModel.loadArticle(articleId) // fetch from Room/Repository
データがRoom、Retrofitキャッシュ、またはローカルストアにある場合に有効です。ViewModelが非同期でデータを取得する間、UIはローディング状態を表示します。シンプルでテストしやすく、単一の信頼できる情報源の原則に従っています。
方法2: Fragment間のナビゲーションに共有ViewModelを使う
同じActivity内のFragment遷移では、Bundle引数を完全にスキップします。Activityスコープの共有ViewModelはオブジェクトをプロセスメモリ内に保持します — IPCなし、サイズ制限なし。
// Shared ViewModel
class SharedViewModel : ViewModel() {
val selectedItem = MutableLiveData<MyLargeObject>()
}
// Source Fragment
val sharedViewModel: SharedViewModel by activityViewModels()
sharedViewModel.selectedItem.value = largeObject
findNavController().navigate(R.id.action_to_detail)
// Destination Fragment
val sharedViewModel: SharedViewModel by activityViewModels()
sharedViewModel.selectedItem.observe(viewLifecycleOwner) { item ->
// use item
}
これはNavigation Componentを使用するシングルActivityアプリの推奨パターンです。シリアライズなし、サイズの心配なし。
方法3: 一時的なインメモリキャッシュ
複雑なデータをActivity間で渡す必要があるが、永続化が難しい場合は、軽量なシングルトンキャッシュが橋渡しをします:
object DataCache {
private val cache = mutableMapOf<String, Any?>()
fun put(key: String, value: Any?) { cache[key] = value }
fun get(key: String): Any? = cache[key]
fun remove(key: String) { cache.remove(key) }
}
// Sender
val cacheKey = UUID.randomUUID().toString()
DataCache.put(cacheKey, largeObject)
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("cache_key", cacheKey)
startActivity(intent)
// Receiver
val key = intent.getStringExtra("cache_key") ?: return
val largeObject = DataCache.get(key) as? MyObject
DataCache.remove(key) // clean up immediately
これはデータストアではなく、短命な橋渡しとして扱ってください。メモリリークを避けるため、消費された瞬間にキャッシュエントリを削除してください。
方法4: ディスクに書き込み、ファイルパスを渡す
ビットマップや大きなバイナリデータはBundleではなくディスクに保存すべきです。まずファイルを書き込み、そのパスを渡します:
// Sender — save bitmap to temp file
fun saveBitmapToCache(context: Context, bitmap: Bitmap): String {
val file = File(context.cacheDir, "temp_image_${System.currentTimeMillis()}.jpg")
file.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
return file.absolutePath
}
val path = saveBitmapToCache(this, largeBitmap)
val intent = Intent(this, ImageViewerActivity::class.java)
intent.putExtra("image_path", path)
startActivity(intent)
// Receiver
val path = intent.getStringExtra("image_path") ?: return
val bitmap = BitmapFactory.decodeFile(path)
一時ファイルはonDestroyで削除するか、定期的なキャッシュ掃除を実行してください。放置するとストレージを無駄遣いし、将来のデバッグを困難にします。
方法5: 送信前にペイロードを削減する
次の画面が数フィールドしか必要としないのに、フルモデルを渡していることがよくあります。軽量な射影クラスを作成しましょう:
data class ArticleFull(
val id: Long,
val title: String,
val body: String, // could be 50 KB
val author: Author,
val comments: List<Comment>, // could be 500 KB
val rawHtml: String // never put this in a Bundle
)
// Pass only what the detail screen actually needs
data class ArticlePreview(
val id: Long,
val title: String
) : Parcelable
val preview = ArticlePreview(article.id, article.title)
intent.putExtra("article", preview)
それぞれ10個のStringフィールドを持つコメントオブジェクトが200個のリストになると、静かに500KBを超えることがあります。アーキテクチャ全体の変更が現実的でない場合、ペイロードを削減することが最も手っ取り早い修正であることが多いです。
修正の確認
解決策を適用したら、再度計測します:
val size = intent.extras?.sizeInBytes() ?: 0
Log.d("BundleSize", "After fix: $size bytes")
check(size < 500_000) { "Bundle still too large: $size bytes" }
また、デバッグビルドでStrictModeを有効にしてください。本番クラッシュになる前に違反を表面化させます:
if (BuildConfig.DEBUG) {
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
}
StrictModeは開発中に対処できる十分早い段階で警告をログに記録します。それらの警告を真剣に扱ってください — 次の本番クラッシュがどこに潜んでいるかを正確に教えてくれています。
重要なポイント
- **オブジェクトではなくIDを渡す。**遷移先が単一の信頼できる情報源(Room、ViewModel、Repository)からデータをロードするようにしましょう。このパターンはスケールします。オブジェクト全体の受け渡しはスケールしません。
- **1MBの制限はすべてのトランザクションで共有されます。**ビジーなデバイスでは、あなたの800KBのBundleが別のアプリのトランザクションを限界まで追い込むかもしれません。あるいは、逆もあり得ます。どちらも好ましい状況ではありません。
- **Android 7.0で動作が変わりました。**Nougat以前は、サイズ超過のデータは静かに破棄されていました。API 24以降は例外がスローされます。モダンなAPIをターゲットにしていて、古い機能で新しいクラッシュが発生している場合は、これを確認する価値があります。
- **ビットマップが通常の原因です。**1024×768の非圧縮画像はバイト配列として約3MBになります。これはIPCバッファの合計の3倍です。決してBundleにビットマップを入れないでください。
- **ネストされたParcelableリストは急速に肥大化します。**それぞれ10個のStringフィールドを持つ200個のリスト項目は、静かに500KBを超えることがあります。

