第051篇 Serializable 与 Parcelable:Android 为什么偏爱后者

简介: Serializable 是 Java 原生序列化机制,依赖反射与字段名对齐,兼容性好但性能差、体积大;Parcelable 是 Android 专用接口,手写定序读写,无反射、零元数据,高效紧凑,但顺序敏感、跨版本易错。Binder 传输必须用 Parcelable——因其面向共享内存、低开销、可控字节布局,而 JSON/Serializable 均无法满足跨进程实时性与缓冲区限制(如 TransactionTooLargeException)。

Serializable 与 Parcelable 这题在面试里的出场率很高,但真正能答闭环的并不多。多数人停在"Serializable 用反射、开销大;Parcelable 手写、高效"这一句,再问一句"那 Binder 传输为什么用 Parcelable 而不是 JSON",就答不上来了。 这题的门槛不在记忆,而在能不能把执行路径一步步推出来:数据从对象变成字节流再变回对象,中间经历了什么,哪一步在 Android 上是行不通的。

先把结论放在前面:Serializable 是 Java 原生的标记接口,序列化靠 ObjectOutputStream 反射遍历字段,兼容性最好但速度慢、体积大;Parcelable 是 Android 提供的接口,需要手写 writeToParcel 与静态 CREATOR,靠 Parcel 的定向读写完成,效率高、体积小,代价是实现繁琐且不具备跨版本兼容性。 两者服务的场景不同:跨进程、跨组件的数据传递用 Parcelable,本地持久化(文件、SP、数据库)用 Serializable 或更常见的 JSON。

机制拆解

Serializable 的流程是:ObjectOutputStream.writeObject 先检查对象是否实现了 Serializable,然后用 ObjectStreamClass 缓存类的字段描述(哪些字段参与序列化、它们的类型),再逐字段取值写出。 它写的不只是数据,还会写入类名、字段名、serialVersionUID,所以反序列化时能按名字把值塞回对应字段——这是它兼容性好的根本原因:字段增删时,只要 serialVersionUID 没变,老数据能被新类读出来。代价也明显:每个字段都要走反射 + 装箱 + 类名元数据,字节流里除了值还有大量描述信息,体积容易膨胀。

Parcelable 的流程完全不同:序列化方主动调用 writeToParcel(Parcel dest, int flags),自己决定写什么、什么顺序;CREATOR 里的 createFromParcel 是反向的读取过程,写什么就对应读什么,中间没有反射、没有字段名。 数据在 Parcel 里是一段连续的内存,writeInt 就是往偏移量后面塞四个字节,readInt 再从同一个偏移量读回来。顺序由两边的代码保证,也因此存在一个硬约束:写入顺序和读取顺序必须严格一致,错位就是数据错乱而不是报错。

有个细节值得单独拎出来:Android 从 API 33 起废弃了 Parcel.writeParcelable 与 readParcelable(ClassLoader),理由是它们内部仍会做类加载器推断,存在安全隐患。 推荐改用带类型的重载 writeParcelable(parcelable, parcelableFlags) 与 readParcelable(classLoader),显式传类加载器。这个变化在面试里能答出来,属于加分项。

这些坑的正确绕法

最常见的坑是 Intent 传超大对象,序列化成字节流后超过 Binder 缓冲区上限,抛 TransactionTooLargeException。 表现是日志里出现 TransactionTooLargeException 或 android.os.DeadObjectException,数据量大时必现,小数据时又"能跑",很难在开发阶段复现。 根因是 Intent 的 extras 走 Binder 传输,单次事务有 1MB 量级的缓冲区限制,而把一张图或一个列表序列化进去轻易就能超过。规矩是:跨进程只传 ID,大数据落在共享存储(文件、ContentProvider、数据库)里让对端按 ID 自己去读;确实需要小对象时,先量一下序列化后的字节数再定方案。

其次是静态内部类里的匿名对象忘记声明 serialVersionUID,改一个字段就导致反序列化失败。 这类异常的报错信息是 InvalidClassException: local class incompatible,报错时机往往是版本升级之后,而不是发布当时,排查成本高。 Android 上的对策有两个:一是给实体类显式写 private static final long serialVersionUID = 1L;,把版本锁住;二是持久化场景干脆不用 Serializable,换 JSON 或 protobuf,字段演进由显式的版本字段控制。

还有一个更隐蔽的坑:Parcelable 的写入顺序和读取顺序不一致,数据错乱却不报错。 典型场景是给已有实体加了一个字段,写的时候顺手加在末尾,读取时却插在了中间或读漏了。结果是后面所有字段集体偏移,实体里出现莫名其妙的值。 因为 Parcel 没有字段名对齐,这种错误只能靠"每次改结构都把两个方法放在一起改"来避免,团队里最好有一条约定:实体字段变更必须同时 review writeToParcel 与 createFromParcel,最好写个往返单测兜底。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// Parcelable:手写定序写入,无反射、无字段名,体积小
public class UserParcel implements Parcelable {
   
    public final int id;
    public final String name;
    public final byte[] avatar;              // 位图可走此字段,但要注意整体体积

    protected UserParcel(Parcel in) {
          // 反序列化:顺序必须与写入严格一致
        id = in.readInt();
        name = in.readString();
        avatar = in.createByteArray();
    }

    public void writeToParcel(Parcel dest, int flags) {
      // 序列化
        dest.writeInt(id);
        dest.writeString(name);
        dest.writeByteArray(avatar);
    }

    @Override public int describeContents() {
    return 0; }

    public static final Creator<UserParcel> CREATOR = new Creator<>() {
   
        @Override public UserParcel createFromParcel(Parcel in) {
    return new UserParcel(in); }
        @Override public UserParcel[] newArray(int size) {
    return new UserParcel[size]; }
    };
}

// 用法:跨组件传小对象
Intent it = new Intent(ctx, DetailActivity.class);
it.putExtra("user", new UserParcel(1, "amy", null));
startActivity(it);
// 取回
UserParcel u = getIntent().getParcelableExtra("user");

这段代码值得盯三处:第一处,构造函数里 readInt/readString/createByteArray 的顺序与 writeToParcel 的写入顺序逐条对应,改一处必须改另一处;第二处,CREATOR 用 newArray 返回标准数组,让 Parcel.createTypedArray 能正确重建;第三处,跨进程取回时 API 33 以上要显式传类加载器:getParcelableExtra("user", UserParcel.class.getClassLoader()),否则类型会被抹平成 LinkedHashMap。

这题在面试里怎么问、怎么答

"Serializable 和 Parcelable 有什么区别,Android 场景下你怎么选?"分四层答:机制(反射 vs 手写定序)、性能(序列化耗时与体积的量级差异)、兼容性(serialVersionUID 与字段名对齐 vs 顺序对齐)、适用场景(Binder 传输与 Intent extras 用 Parcelable,本地持久化用 JSON/数据库)。 落点必须是选择标准,不能停在并列比较。

"Parcelable 底层是怎么实现的?和序列化有什么区别?"从内存布局答:Parcel 内部是一块共享内存 + 读写偏移量,writeXxx 就是往偏移写定长或带长度前缀的数据,readXxx 从同一偏移读。因为要跨进程,底层实际用的是内核的共享内存页并在事务结束时同步,写入超过缓冲区就需要申请新页。 Serializable 则是把每个字段的元信息也写进流里,靠类名反射重建对象,所以慢且大。

"使用中遇到过什么问题?"拿两个真实案例。案例一:给列表页加详情跳转时把整个列表塞进 Intent,压测时 40 条数据能跑、200 条就抛 TransactionTooLargeException;定位到 extras 里的序列化字节数;修复为只传 userId,详情页异步查 Room。 案例二:给实体加字段后线上出现 InvalidClassException;定位到静态内部类缺 serialVersionUID,反序列化时类版本不匹配;修复为显式声明并补上版本迁移逻辑。

"和 JSON、protobuf 相比呢?"结论落在取舍:JSON 跨语言、可读、调试友好,代价是体积与解析耗时;protobuf 体积与解析速度都占优,代价是不可读、编解码层要维护 schema,且 Android 上对反射序列化不友好。Parcelable 处在"进程内快速传递"这个特定位置上,跨语言与持久化都不在它的职责范围。

再补一个工程上值得讲清的点:Parcelable 也不是"快到可以随便用"。它不省 new 与内存拷贝,跨进程场景下数据要经历一次拷贝到共享内存。真正的高频数据传递在 Android 上有更合适的方式——Binder 本身、AIDL 定义的接口回调、或者直接用 ContentProvider + Cursor(游标是惰性的,不一次性把全表读进内存)。 面试里能主动说出"Parcelable 也有它的边界",比只夸它快更显功底。

给正在准备面试的你

把这两种序列化画成一张对照图:左边 Serializable 的流程——writeObject → ObjectStreamClass 反射取字段 → 写入"类名+字段名+值" → 反序列化按名回填;右边 Parcelable 的流程——writeToParcel → 按序写定长/带长度数据 → CREATOR.createFromParcel 按序读回。 在两条路径旁边各标一句代价:左边"慢、体积大、兼容性好",右边"快、体积小、顺序敏感"。面试中关于序列化的追问基本都在这张图上,答的时候记得把"顺序对齐 vs 字段名对齐"这个本质区别说透。

再补工程案例与踩坑——应用落点是把项目里所有跨组件传大对象的 Intent 改为传 ID,把需要持久化的实体从 Serializable 切到 JSON(显式版本字段),并补一个"序列化往返后字段一致"的单测。

复习时别孤立刷题:IO 流体系——两者最终都落到字节的读写,Parcel 可以理解为面向共享内存的定向字节流。

划两句重点:Serializable 靠反射与字段名对齐,兼容好但慢且大;Parcelable 手写定序,快且小但顺序敏感、跨版本易错;Intent 传大对象必踩 TransactionTooLargeException,跨进程只传 ID。

下一篇聊 Socket 与 HTTP:网络编程的两层视角——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:IO-流体系:字节流与字符流的分层设计

下一篇预告:Socket-与-HTTP:网络编程的两层视角

有任何问题欢迎在评论区留言交流。

相关文章
|
15小时前
|
缓存 安全 Java
第012篇 static 关键字全景:静态变量、方法与内部类
本文深入解析 Android 面试高频考点 `static` 关键字:从类加载、内存布局到生命周期;详解静态成员共享性、线程安全边界、方法隐藏机制;剖析内存泄漏、OOM、初始化顺序等典型坑及规避方案;结合单例、弱引用、静态内部类等工程实践,助你结构化作答,展现系统性认知。
23 0
|
16小时前
|
SQL 安全 Java
第007篇 String、StringBuilder 与 StringBuffer:拼接性能三选一
Android面试中,String、StringBuilder与StringBuffer的选型本质是权衡:String不可变、线程安全但拼接低效;StringBuilder单线程高性能,扩容可控;StringBuffer加锁保障多线程安全,但有同步开销。真功夫在量化场景、预估容量、规避内存抖动。
19 0
|
13小时前
|
缓存 安全 Java
第066篇 lateinit 与 by lazy:延迟初始化的适用边界
`lateinit` 与 `by lazy` 均解决延迟初始化问题,但机制迥异:`lateinit` 是编译期修饰符,仅适用于非基本类型的 `var`,未赋值访问抛异常;`by lazy` 是线程安全的委托,支持所有类型,首次访问才执行初始化。二者适用场景不同,优先考虑 DI、View Binding 等更安全方案。
24 0
|
13小时前
|
监控 Java 测试技术
第041篇 线程池七参数:ThreadPoolExecutor 从配置到调优
Android面试高频题“线程池七参数”,实为三层能力筛选:背参数(入门)、讲流程(进阶)、析设计(高手)。核心在于理解`corePoolSize→workQueue→maximumPoolSize→RejectedExecutionHandler`的执行链与制约关系,避开无界队列、线程命名缺失、拒绝策略误用等典型坑。真懂者必知:参数非独立旋钮,而是协同约束。
18 0
|
14小时前
|
IDE Java 编译器
第036篇 注解与元注解:Override 背后的机制
注解是附着于程序元素的结构化元数据,本身不执行逻辑,其作用完全取决于`@Retention`(生命周期)与`@Target`(作用位置)。`RUNTIME`级可反射读取,`CLASS`级仅存于字节码,`SOURCE`级编译即弃。元注解如`@Repeatable`(需容器)、`@Inherited`(仅类继承链生效)常被误用。编译期APT处理(如Room、Dagger)比运行时反射更高效。关键:显式声明Retention,勿信默认值;接口注解不被实现类继承;注解仅为意图声明,非功能保证。
16 0
|
13小时前
|
缓存 Java API
第071篇 顶层函数与属性:为什么不再需要 Utils 类
Kotlin顶层函数/属性是“语法糖”,编译后归入以文件名命名的静态文件类(如`StringsKt`)。`@file:JvmName`可自定义Java调用类名,`@JvmField`使属性变为真正静态字段,`const val`为编译期常量。核心原则:顶层只放无状态纯函数;可变状态须收敛至`object`,确保修改点可控。
24 0
|
13小时前
|
安全 Java 编译器
第061篇 Kotlin 与 Java 的关系:同一 JVM 上的两门语言
Kotlin与Java互操作≠对称兼容!核心差异在混编边界:平台类型致空安全失效、`internal`编译为public、默认参数需`@JvmOverloads`、data class字段private final使Gson反序列化失配。真功夫在收尾——注解规范、ProGuard保留元数据、协程作用域管控。
23 0
|
14小时前
|
缓存 安全 Java
第029篇 HashMap 底层原理:数组、链表与红黑树的演进
HashMap底层是“数组+链表+红黑树”复合结构:键经扰动哈希后,用(n-1)&hash定位桶;链表≥8且容量≥64时树化;负载因子0.75平衡时空开销;线程不安全,自定义key须重写equals与hashCode。
17 0
|
15小时前
|
安全 Java 编译器
第022篇 try-catch-finally 与 try-with-resources:资源释放正确姿势
本文用“场景—决策—踩坑—效果”四步法,讲透try-catch-finally与try-with-resources的工程实践。重点解析finally中return吞异常、手写关闭漏资源、twr如何保留主异常并挂suppressed等高频面试坑点,附可运行代码对比,助你面试答出深度与记忆点。
17 0
|
13小时前
|
存储 设计模式 安全
第056篇 新时间 API:LocalDateTime 取代 Date 的理由
移动端时间处理易出线上事故:`SimpleDateFormat` 静态共享致线程不安全;跨时区“昨天”判断偏差;字符串截取本地化日期失效。Java 8 `java.time` 核心在于厘清**三类时间语义**:`Instant`(UTC瞬时点)、`LocalDateTime`(无时区墙上时间)、`ZonedDateTime`(含时区规则,支持夏令时)。存储用 `Instant`,展示才绑定时区;格式化必传 `Locale`;`YYYY`≠`yyyy`;`Period`(日历量)与 `Duration`(物理量)不可混用。
25 0