23. 密封类的序列化:与Gson/Moshi的兼容性处理

密封类在 Kotlin 里是个好东西。它帮我们限死了类型范围,让 when 表达式变得安全又优雅。但一碰到序列化,事情就没那么美了。

我最早在项目里用密封类做 UI 状态管理时,就踩过这个坑。当时用 Gson 直接序列化一个密封类对象,结果运行时直接崩了——Gson 根本不认识 sealed class 的子类型。嗯,今天我们就来聊聊怎么搞定这个问题。

为什么密封类序列化会出问题?

说白了,Gson 和 Moshi 默认都不知道你的密封类有哪些子类。它们只能看到父类,看不到子类的具体结构。

举个例子:

sealed class UiState {
    data class Loading(val progress: Int) : UiState()
    data class Success(val data: String) : UiState()
    data class Error(val message: String) : UiState()
}

你用 Gson 直接序列化一个 UiState.Success("hello"),Gson 会尝试序列化 UiState 这个抽象类。它不知道具体是哪个子类,也不知道该用哪个构造器反序列化。结果就是抛异常。

⚠️ 注意: 如果你用 Gson 默认配置序列化密封类,大概率会收到一个 IllegalArgumentException,提示无法实例化抽象类。这不是 bug,是 Gson 的设计如此。

解决方案一:自定义 TypeAdapter(Gson)

我个人习惯用自定义 TypeAdapter 来处理。思路很简单:在序列化时,把子类的类型信息也写进去。反序列化时,根据类型信息找到对应的子类来解析。

class UiStateAdapter : JsonSerializer<UiState>, JsonDeserializer<UiState> {
    override fun serialize(src: UiState, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
        val jsonObject = context.serialize(src, src.javaClass).asJsonObject
        jsonObject.addProperty("type", src.javaClass.simpleName)
        return jsonObject
    }

    override fun deserialize(json: JsonElement, typeOfType: T, context: JsonDeserializationContext): UiState {
        val jsonObject = json.asJsonObject
        val typeName = jsonObject.get("type").asString
        return when (typeName) {
            "Loading" -> context.deserialize(json, Loading::class.java)
            "Success" -> context.deserialize(json, Success::class.java)
            "Error" -> context.deserialize(json, Error::class.java)
            else -> throw JsonParseException("Unknown type: $typeName")
        }
    }
}

注册到 Gson 里:

val gson = GsonBuilder()
    .registerTypeAdapter(UiState::class.java, UiStateAdapter())
    .create()

这样序列化出来的 JSON 会多一个 type 字段,用来标记子类类型。反序列化时就能准确还原了。

💡 小技巧: 我建议把 type 字段名定义成常量,比如 __sealed_type__,避免和业务字段冲突。我在项目里就吃过字段名冲突的亏,排查了半天才发现是 type 字段被覆盖了。

解决方案二:Moshi 的 PolymorphicJsonAdapterFactory

如果你用 Moshi,事情就简单多了。Moshi 官方提供了 PolymorphicJsonAdapterFactory,专门处理这种多态序列化场景。

val moshi = Moshi.Builder()
    .add(PolymorphicJsonAdapterFactory.of(UiState::class.java, "type")
        .withSubtype(Loading::class.java, "loading")
        .withSubtype(Success::class.java, "success")
        .withSubtype(Error::class.java, "error"))
    .build()

你看,代码量少了很多。Moshi 会自动在 JSON 里加一个 type 字段,值就是你在 withSubtype 里指定的标签。

序列化结果大概长这样:

{
  "type": "success",
  "data": "hello"
}

反序列化时,Moshi 根据 type 字段找到对应的子类,然后解析剩下的字段。整个过程很丝滑。

方案对比:Gson vs Moshi

维度 Gson Moshi
配置复杂度 需要手写 TypeAdapter 一行工厂方法搞定
类型安全 弱(运行时反射) 强(编译期代码生成)
性能 中等(反射开销) 较好(无反射)
Kotlin 支持 需要额外适配 原生支持
推荐场景 遗留项目迁移 新项目首选

我个人更推荐 Moshi。尤其是新项目,直接用 Moshi 省心很多。但如果你在维护老项目,Gson 方案也能用,就是代码量多一些。

避坑指南:我曾经踩过的坑

我曾经在项目里用密封类做网络请求的状态管理,结果序列化时出了几个问题,分享出来给大家避避坑。

坑一:子类字段名冲突

如果两个子类有同名字段但类型不同,反序列化时会出问题。比如 Loading 有个 message: StringError 也有个 message: Int。Gson 和 Moshi 都会懵。我建议子类字段名尽量保持唯一,或者用 @Json(name = "...") 注解区分。

坑二:嵌套密封类

密封类里嵌套密封类,序列化复杂度会指数级上升。我遇到过三层嵌套的密封类,调试反序列化花了一整天。后来我重构了设计,把嵌套层级控制在两层以内。

坑三:默认值问题

密封类的 data class 子类如果有默认参数,反序列化时可能不会按预期工作。Moshi 对默认参数的支持比 Gson 好一些,但也不是完美的。我建议在子类里不要依赖默认参数,显式写出所有字段。

核心逻辑图

下面这张图展示了密封类序列化的核心流程:

密封类序列化核心流程 密封类 UiState Loading(progress: Int) Success(data: String) Error(message: String) {"type":"loading","progress":50} {"type":"success","data":"ok"} {"type":"error","message":"fail"} 根据 type 字段匹配子类,反序列化还原对象 核心思路:在 JSON 中嵌入类型标识,实现多态序列化

总结一下

密封类的序列化,说白了就是解决「父类不知道子类是谁」的问题。Gson 需要你手写 TypeAdapter,Moshi 提供了现成的工厂方法。我个人更倾向 Moshi,代码量少,类型安全,性能也好。

如果你还在用 Gson,也别急着换。手写 TypeAdapter 虽然麻烦,但胜在可控。只要注意我前面提到的几个坑,基本不会出大问题。

嗯,关于密封类序列化就聊这么多。记住一点:不管用哪个库,核心思路都是在 JSON 里加一个类型标识字段。这个思路搞清楚了,后面遇到再复杂的场景也能应对。