kotlinx.serialization 的稳定性,生产环境敢用吗
kotlinx.serialization 的稳定性,生产环境敢用吗
去年把项目里的 Gson 全面替换为 kotlinx.serialization 时,我遇到的最直观问题不是运行时 crash,而是 CI 直接编译不过。Kotlin 版本从 1.9.10 升到 1.9.20 后,Gradle 报了一个 java.lang.NoSuchMethodError: kotlinx.serialization.json.JsonImpl.<init> 的链接错误。排查了整整一个下午,发现是 kotlin-serialization-compiler-plugin 的版本没同步跟上 Kotlin 本体版本。这个插曲让我开始重新评估:这个库在生产环境的稳定性,到底建立在什么之上?
从一次编译失败说起:Kotlin 1.9.20 与 serialization 插件的兼容陷阱
kotlinx.serialization 不是纯运行时依赖。它的核心机制是通过 Kotlin 编译器插件在编译期生成 serializer() 代码。这意味着你的 Gradle 文件里必须同时声明插件和运行时库,且两者的版本必须严格对齐。Kotlin 1.9.20 对应的 serialization 插件版本应该是 1.6.2 或更高,而我当时只升级了 Kotlin Gradle Plugin,遗漏了 kotlin("plugin.serialization") 的版本号,导致编译器生成的字节码与运行时库不匹配。
这种错误在本地开发时可能表现为 IDE 里一切正常,但执行 ./gradlew assembleRelease 时直接失败。更麻烦的是,错误日志通常不会直接告诉你“版本不匹配”,而是抛出一个链接阶段的 NoSuchMethodError 或 NoClassDefFoundError,指向 kotlinx.serialization.json.Json 或 kotlinx.serialization.internal.Platform_commonKt 等内部类。对于不熟悉编译器插件机制的人来说,这种错误极具误导性,很容易误以为是 R8 混淆或依赖冲突导致。
在 GitHub 的 Kotlin/kotlinx.serialization 仓库中,这类构建问题常年占据 issue 列表的前列。比如 issue #2799 里讨论的构建缓存失效,以及 Kotlin 1.9.x 系列中增量编译偶发失效的问题。当你在一个拥有几百个模块的大型项目里使用,任何一次 Kotlin 版本升级都意味着必须全量重新编译所有序列化模块,且没有平滑回退的余地。相比之下,Moshi 或 Gson 只是 jar 包升级,不介入编译器 IR(Intermediate Representation)转换,版本兼容性要宽松得多。
默认行为差异:JSON 解析中的 null 和默认值
kotlinx.serialization 的 JSON 解析默认行为与 Android 开发者熟悉的 Gson 存在根本性差异。Gson 的默认策略是“尽量宽容”:遇到未知字段自动忽略,null 可以映射到非 null Kotlin 属性(尽管会触发空安全警告),默认值由反射注入。而 kotlinx.serialization-json 的默认配置是严格模式:
kotlinx.serialization.json.internal.JsonDecodingException:
Unexpected JSON token at offset 64: Encountered unknown key 'newField'
Use 'ignoreUnknownKeys = true' in 'Json {}' builder to ignore unknown keys或者当后端缺少某个字段时:
kotlinx.serialization.MissingFieldException:
Field 'userName' is required for type 'User', but it was missing这些异常信息本身很清晰,但默认行为对 Android 生产环境很不友好。因为后端接口迭代往往先于客户端发版,如果后端新增了一个字段,而旧版客户端没有对应的模型属性,Gson 时代是静默忽略,现在直接抛异常终止解析。你必须显式配置一个全局的 Json 实例:
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = false
}explicitNulls 这个属性是在 1.6.0 版本引入的,默认值为 true。它的作用是即使一个字段为 null,也会在 JSON 输出中显式写入 "field": null。在 1.6.0 之前,省略未设置的 null 字段是默认行为;升级后,如果你的后端对 null 值的处理逻辑不一致(比如把显式 null 当作“清除数据”而非“未提供”),就会引发业务逻辑错误。这种 breaking change 在 release note 里虽然被标记,但很容易被团队忽略。
更隐蔽的是默认值处理。encodeDefaults = false 意味着如果一个字段等于 Kotlin 数据类的默认值,序列化时会直接省略该字段。这在本地缓存或构建请求签名时可能会出问题:服务端期望收到一个显式的 0 或 false,但客户端因为默认值优化而省略了它。你必须逐个字段审视业务语义,这种心智负担在 Gson 里几乎不存在。
ProGuard 与 R8:Keep 规则比想象中更复杂
Android 生产环境必开 R8。kotlinx.serialization 生成的 serializer() 和 Companion 对象中的合成方法很容易被 R8 当作无用代码移除。与 Moshi 的 KSP codegen 不同,serialization 的编译器插件生成的是 Kotlin 编译器内部约定格式的代码,这些 synthetic 方法对 R8 来说并不直观。
你需要保留带有 @kotlinx.serialization.Serializable 注解的类本身,以及它们的 Companion 对象。如果使用了多态序列化(sealed class 或 @Polymorphic),还需要保留 classDiscriminator 相关的合成字段。R8 full mode 下问题更严重,因为 SerializationConstructorMarker 这个内部接口如果被混淆或裁剪,运行时实例化序列化对象会直接崩溃。
具体的 Keep 规则大致如下:
-keepattributes *Annotation*, InnerClasses, EnclosingMethod
-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers @kotlinx.serialization.Serializable class * {
static ** Companion;
}
-keepclassmembers @kotlinx.serialization.Serializable class *$Companion {
kotlinx.serialization.KSerializer serializer(...);
}实际上规则远比这复杂,而且随着 serialization 版本迭代,生成的代码结构会发生变化。比如 Kotlin 1.8.x 到 1.9.x 之间,编译器插件生成 serializer 的位置从外部类调整到了 Companion 内部,导致旧版 Keep 规则部分失效。这种对 R8 规则的持续维护成本,在 Gson 这种纯反射方案里几乎不存在。
更麻烦的是多态序列化。如果你使用了 SerializersModule 注册子类:
val module = SerializersModule {
polymorphic(BaseResponse::class) {
subclass(SuccessResponse::class)
subclass(ErrorResponse::class)
}
}R8 可能会移除你未直接引用的 SuccessResponse 或 ErrorResponse 类,导致运行时抛出 Class discriminator was not found 或 Polymorphic serializer was not found for 'class ...' 的异常。Moshi 的 codegen 通过生成显式的 Adapter 类,让 R8 的引用分析更容易追踪,而 serialization 的编译器插件生成逻辑对 R8 来说更像黑盒。
KMP 与 Native:跨平台一致性背后的代价
Kotlin Multiplatform 是 kotlinx.serialization 的主场优势。kotlinx-serialization-json 支持 JVM、Android、JS、Native(iOS、macOS、Linux)。但“一致性”只是 API 层面的一致性,运行时表现差异很大。
在 Native 目标(特别是 iOS)上,历史版本曾受 Kotlin/Native 旧内存模型(Old MM)限制,Freeze 机制会导致序列化上下文(Json 实例)出现线程隔离问题。虽然 Kotlin 1.7.20 后新内存模型成为默认,但 Native 上的性能仍与 JVM 有明显差距。Json.encodeToString 在 Native 上的字符串拼接缺乏 JVM 层面的 StringBuilder 优化,对于大型列表的序列化,耗时可能是 JVM 的数倍。
另一个坑是 Protobuf 支持。kotlinx-serialization-protobuf 在 1.6.x 之前对 oneof 语义的支持有限,且生成的二进制格式与官方 protoc 编译器不完全对齐。如果你的后端是 Go 或 Java,使用 protoc 生成的标准二进制协议,kotlinx.serialization 的 protobuf 实现可能会出现字段编号(field number)或 wire type 兼容性问题。此外,它的 Properties 格式(kotlinx-serialization-properties)用于读取 .properties 或环境变量时,对嵌套对象的支持非常简陋,基本只能处理扁平结构。
在 Android 和 JVM 共享的模块里,你通常不会感受到这些 Native 侧的问题。但一旦项目走向 KMP,你就会发现同样的 @Serializable data class 在 Android 上跑得好好的,在 iOS 上可能因为编译器后端差异生成不同的元数据,导致 SerializersModule 的构建结果不一致。这种跨平台的不对称性,需要你在单元测试之外,增加针对各 target 的集成测试。
版本锁定:Kotlin 编译器版本与 serialization 的耦合问题
把版本耦合单独拿出来讲,是因为这是 kotlinx.serialization 最大的架构风险。Kotlin 的编译器插件 API 不是稳定 ABI,每个 Kotlin 版本都可能破坏旧插件。JetBrains 的做法是让 serialization 版本与 Kotlin 版本强绑定。查看 Maven Central 或 Gradle Plugin Portal 会发现,kotlin-serialization 插件的版本号通常跟随 Kotlin 大版本,比如 Kotlin 1.9.22 对应 serialization 1.6.3。
这带来的实际问题是:如果公司主工程 Kotlin 版本升级节奏慢(比如卡在 1.7.20),而某个新库(比如某个内部 KMP SDK)依赖了 serialization 1.6.x(需要 Kotlin 1.9.x),就会出现依赖冲突。Gradle 的 resolutionStrategy 可以强制对齐版本,但那只是运行时的妥协,编译器插件的版本无法通过 Gradle 的依赖解析自动对齐,你必须手动指定:
plugins {
kotlin("jvm") version "1.9.22"
kotlin("plugin.serialization") version "1.9.22"
}相比之下,Moshi 基于 Java 注解和 KSP/KAPT,运行时只依赖 com.squareup.moshi:moshi 和 okio,版本完全独立于 Kotlin 编译器。哪怕你的 Kotlin 版本从 1.7 跳到 1.9,只要 KSP 处理器本身兼容,Moshi 的运行时库不需要任何改动。这种解耦在长期维护中意味着更低的风险。
更隐蔽的是 IDE 支持。Android Studio 的 Kotlin 插件版本若与项目 Kotlin 版本不一致,可能导致 @Serializable 类在 IDE 里报红名,提示 "Serializable serializer not found",但 Gradle 命令行编译却完全通过。这种“假阳性”在重构时非常恼人,会严重降低开发体验,让团队成员对工具链产生不信任感。
性能与包体积:数据来自一个真实模块的基准测试
在稳定性之外,生产环境必须考虑包体积和编译耗时。kotlinx.serialization 在运行时序列化性能上通常优于 Gson(反射驱动),与 Moshi(Kotlin codegen)接近。但它的代价主要体现在编译期和 DEX 体积。
每个 @Serializable 类会生成一个 $serializer 类(或等效的内联逻辑)。对于一个