Android 开发中,Intent.putExtra() 用起来太方便,以至于很容易把它当成普通函数传参:入口页面已经拿到一个完整业务对象,启动下一个 Activity 时顺手塞进 Bundle,接收端再反序列化使用。

这种写法在数据较小时没有问题。一旦对象内部逐渐加入推荐特征、展示配置、嵌套列表或重复字段,某次 startActivity() 就可能失败。更棘手的是,线上不一定能拿到清晰的业务堆栈:有的设备只表现为点击后应用退出,异常发生在系统进程处理事务或 Activity 启动的后续阶段,常规 crash 聚合里看不到熟悉的调用点。

这类问题的根因并不是 Activity 本身,而是 Intent 最终要被序列化成 Parcel,通过 Binder 交给系统服务。Binder 有共享事务缓冲区和单次事务边界,大 payload 会与同进程其他在途事务竞争空间,最终触发 TransactionTooLargeException 或启动失败。

本文不只给一个“不要超过 1 MB”的结论,而是说明怎样从弱信号定位问题、怎样测量真正的 Parcel 大小,以及为什么正确修复通常是重新设计传参边界。

一、为什么同一个 App 内启动 Activity 也受 Binder 限制

即使两个 Activity 属于同一应用进程,启动流程仍要经过系统的 Activity 管理服务。应用把 Intent 交给 framework,framework 将其写入 Parcel,再通过 Binder 请求系统进程完成权限检查、任务栈管理和目标组件调度。

简化调用链如下:

App process
Activity.startActivity(intent)


Intent / Bundle -> Parcel
        │ Binder transaction

System process
Activity task management
        │ schedule launch

Target Activity

因此,Intent 不是一条可无限扩展的进程内参数通道。BundleParcelableSerializable 最终都会变成 Parcel 内容。

Android Binder 事务缓冲通常是进程级共享的一块有限空间,常被描述为约 1 MiB 量级。这个数字不能当作“每个 Intent 可以安全使用 1 MiB”:

  • 缓冲区由同一进程所有在途事务共享;
  • Parcel 还有字段、类型、对象表和对齐开销;
  • Activity 启动并非系统中唯一 Binder 调用;
  • 不同系统版本和厂商实现的失败表现可能不同;
  • 返回值和其他方向的事务也会占用空间。

所以不存在一个贴着理论上限发送就可靠的安全值。Intent 应只携带路由和轻量参数,而不是充当业务对象运输层。

二、为什么对象的“业务大小”不等于 Parcel 大小

日志里看到一个字符串 100 KB,不代表 Bundle 只增加 100 KB。真实大小还受到这些因素影响:

  • UTF-16/UTF-8 编码差异;
  • 字段名和类型标记;
  • 4 字节对齐;
  • 嵌套 Bundle/Parcelable 的结构开销;
  • 同一数据在多个字段中重复序列化;
  • Bitmap、数组和列表的额外描述;
  • Serializable 产生的对象流开销;
  • Intent selector、clip data、flags 等其他内容。

更危险的是,业务模型常把一份原始响应、解析后的展示对象和上报特征同时保留。内存中它们可能共享引用,写入 Parcel 后却会展开成多份数据。

因此排查时不能只打印 toString().length 或某个 Protobuf 的 serializedSize。要测整个 Intent/Bundle 被写入 Parcel 后的大小。

三、一次典型问题的证据链

某个页面入口偶发“点击后退出”,早期样本没有稳定的业务堆栈,只能看到几个弱信号:

  1. 问题集中在带特殊入口状态的用户;
  2. 同一用户清除入口状态后,可以正常打开页面;
  3. 页面 Activity 本身没有稳定复现的初始化 crash;
  4. 一部分系统日志显示 Activity 启动二次失败;
  5. 有一个样本明确出现 TransactionTooLargeException

进一步比较入口对象后发现,正常数据只有几 KB,异常数据超过 250 KB。异常对象里混入了两组与目标 Activity 无关的特征数据,分别达到 100 KB 量级。入口层把整个对象放进 Intent,导致这些嵌套字段也被完整序列化。

本地把传入数据逐步放大到 1.6 MB 左右后,稳定复现系统日志:

Second failure launching target activity, giving up
android.os.TransactionTooLargeException:
data parcel size 1600000+ bytes

线上无堆栈样本与本地异常不能仅凭“现象相似”就直接画等号,但证据已经形成闭环:

  • 特定入口状态与失败强相关;
  • 该状态显著增大 Intent;
  • 移除状态后用户恢复;
  • 人工放大同一路径可复现相同行为;
  • 系统日志存在明确的超大事务样本。

最后还需要通过修复上线后的反馈和指标继续验证。对这类系统边界问题,“修复后同类样本消失”往往是证据链的一部分。

四、如何测量 Intent 和 Bundle 的真实大小

可以在 debug 或采样环境把对象写入 Parcel:

fun Intent.parcelSize(): Int {
    val parcel = Parcel.obtain()
    return try {
        writeToParcel(parcel, 0)
        parcel.dataSize()
    } finally {
        parcel.recycle()
    }
}

fun Bundle.parcelSize(): Int {
    val parcel = Parcel.obtain()
    return try {
        parcel.writeBundle(this)
        parcel.dataSize()
    } finally {
        parcel.recycle()
    }
}

注意:这段测量本身会执行序列化,不能无采样地放到高频线上路径。更合适的做法是:

  • debug 构建每次断言;
  • dogfood/灰度环境全量记录;
  • 线上按很低比例采样;
  • 接近阈值时只上报大小、字段分布和哈希,不上报原始数据;
  • 对大字段在构造 Bundle 前就记录其独立 serialized size。

一个简单的守卫可以避免问题悄悄增长:

private const val WARN_SIZE = 200 * 1024

fun Context.startActivityChecked(intent: Intent) {
    if (BuildConfig.DEBUG) {
        val size = intent.parcelSize()
        check(size < WARN_SIZE) {
            "Intent is unexpectedly large: $size bytes"
        }
    }
    startActivity(intent)
}

示例中的 200 KB 只是团队保护线,不是 Android 官方安全上限。保护线应明显低于理论极限,并结合应用并发 Binder 使用情况调整。

五、怎样找出 Bundle 里的大字段

只知道总大小还不够。可以对 extras 做增量测量:每次单独把一个键写入新 Bundle,估算它对 Parcel 的贡献。

fun estimateExtras(intent: Intent): List<Pair<String, Int>> {
    val extras = intent.extras ?: return emptyList()
    return extras.keySet().map { key ->
        val one = Bundle()
        @Suppress("DEPRECATION")
        one.putAll(Bundle().apply {
            // 实际实现应按类型安全复制;这里只表达测量思路。
            putString("key", extras.get(key)?.toString())
        })
        key to one.parcelSize()
    }.sortedByDescending { it.second }
}

上面用 toString() 只是伪代码,不能用于真实大小统计。生产实现需要按原类型写入,或更简单地在各业务字段进入 Bundle 前记录自身序列化大小。

推荐输出这种结构化摘要:

intent_total=278412
route_args=932
display_config=118044
feature_blob=107312
tracking_context=42310
other=9814

不要输出用户标识、原始文本或完整二进制。字段名也可以映射成稳定枚举,以免日志泄露业务细节。

六、为什么 try/catch 往往救不了

开发者可能尝试:

try {
    startActivity(intent)
} catch (e: TransactionTooLargeException) {
    // fallback
}

它不能作为可靠防线。事务失败可能在 framework 后续阶段暴露,调用点未必同步收到同一个异常;某些版本只在系统日志中记录 Activity 调度失败;即便 catch 成功,当前任务栈和生命周期也可能已经处于部分变化状态。

正确策略是在发起事务前控制大小。异常捕获只能作为额外兜底,不能替代数据边界设计。

七、修复不是压缩,而是缩小传参契约

最直接也最稳定的修复,是只把目标 Activity 真正需要的数据放进 Intent。

7.1 传 ID,不传完整对象

val intent = Intent(context, DetailActivity::class.java).apply {
    putExtra("item_id", item.id)
    putExtra("entry", entry.code)
}

目标页面从 repository、数据库或内存缓存读取对象。这样数据模型增长不会自动扩大 Intent。

需要考虑缓存 miss 和进程重启:如果页面可被系统恢复,repository 必须能根据 ID 重新加载,而不是只依赖一个进程内 map。

7.2 只定义路由 DTO

如果目标页面确实需要几个字段,创建专用 RouteArgs,不要复用网络响应或业务聚合对象:

@Parcelize
data class DetailRouteArgs(
    val itemId: String,
    val initialTab: Int,
    val source: String
) : Parcelable

路由 DTO 应稳定、小且有版本意识。代码评审时可以直接判断新增字段是否真的属于跨页面契约。

7.3 大数据使用外部存储或共享资源

图片、长列表、模型文件等不应该进 Bundle。可以传:

  • 文件路径或 content:// URI;
  • 数据库主键;
  • 有生命周期的缓存 token;
  • 服务端资源 ID;
  • 必要时使用共享内存/文件描述符等专门通道。

但 URI 和 token 方案也要处理权限、过期和进程重启,不能只是把内存泄漏换成缓存泄漏。

7.4 压缩只适合不得已的兼容期

压缩能降低某些文本或结构化数据的尺寸,却会增加 CPU、内存峰值和失败复杂度,且遇到已压缩图片或随机数据收益很小。更重要的是,它继续鼓励调用方把大对象当参数传递。

若为了兼容旧版本暂时压缩,应同时设置严格上限和迁移期限,最终仍回到 ID/URI 模型。

八、还有哪些地方会触发同类问题

Intent 不是唯一入口。以下场景同样通过 Binder 或状态保存写入 Parcel:

  • Fragment arguments;
  • onSaveInstanceState 保存的页面状态;
  • Service、BroadcastReceiver 的 Intent;
  • Messenger 和 AIDL 参数/返回值;
  • 通知和 RemoteViews;
  • Activity result;
  • 系统恢复时的导航栈状态。

特别是 onSaveInstanceState:开发阶段旋转屏幕正常,线上在页面状态丰富时退后台,系统保存状态才失败。不要把完整列表、Bitmap 或缓存对象放进 state;只保存恢复页面所需的最小 ID、位置和用户输入。

九、如何验证修复有效

修复上线前:

  1. 用历史最大 payload 回归;
  2. 生成更大的边界数据,确认路由大小仍稳定;
  3. 覆盖冷启动、进程重启和缓存 miss;
  4. 验证目标页面拿不到旧大对象时仍能正确加载;
  5. 检查其他入口是否复用同一危险 Bundle。

上线后:

  • 监控 Intent 总大小 P50/P95/P99;
  • 统计超过保护线的次数和入口;
  • 观察 Activity launch failure 与 TransactionTooLargeException
  • 对比相关用户反馈是否消失;
  • 检查修复是否引入加载失败或首屏变慢;
  • 保留版本维度,避免新老数据混在一起。

由于原问题可能没有完整堆栈,验证时不要只看 crash 平台的一条异常类型。用户反馈、系统日志、入口点击后无页面展示、进程退出和路由大小应共同构成指标。

十、设计检查表

  • Intent/Bundle 是否只携带路由所需的最小数据?
  • 是否误把网络响应、业务聚合对象或列表整体作为 Parcelable?
  • 是否测量过完整 Parcel 大小,而不是某个字段的字符串长度?
  • 大字段是否有分项统计和 debug 保护线?
  • 页面能否仅凭 ID 在进程重启后恢复?
  • savedInstanceState 是否也保存了同一份大对象?
  • 是否把 try/catch 误当成主要保护?
  • 修复后是否同时观察事务异常和用户侧失败信号?

Binder 的限制不是一个需要精确卡住的数字,而是在提醒我们:跨组件调用属于序列化边界。一个好的 Intent 像 URL,一眼能看清页面要去哪里、用哪个 ID、从什么入口进入;它不应该像内存快照,把调用方当前知道的一切都塞给下一个页面。