kotlinx.serialization 的稳定性,生产环境敢用吗
kotlinx.serialization 的稳定性,生产环境敢用吗
从 Gson 迁移说起:一个被低估的决策成本
三年前接手一个日活百万级别的 Android 项目时,团队正面临一个典型的技术债场景:Gson 的 TypeToken 在 Kotlin 泛型场景下频繁踩坑,@SerializedName 注解和 Kotlin 的 null-safety 语义打架,数据类里不得不写一堆防御性代码。当时 kotlinx.serialization 1.0 刚刚正式发布,JetBrains 在官方博客里把它定位为"Kotlin 多平台生态的标配序列化方案"。我们评估了两周,决定在新模块试点,半年后全量迁移。
这个决策回头看,有赌对的成分,也有踩坑的代价。kotlinx.serialization 1.0.0 发布于 2020 年 10 月,到现在(2024 年)已经迭代到 1.6.3 版本,中间经历了 1.4.x 对 Kotlin 1.8 的适配、1.5.x 对 K2 编译器的实验性支持、1.6.x 对 wasm 目标的扩展。版本号跳得不算慢,但生产环境关心的不是功能列表有多长,而是那些深夜报警的线上崩溃、那些难以复现的序列化异常、那些升级版本后编译突然挂掉的 CI 流水线。
我想把这几年实际用下来的观察摊开讲,不吹不黑。文章会涉及具体的版本行为差异、编译器插件的隐性约束、以及和 Moshi、Gson 在真实场景下的取舍。如果你正在评估要不要把 kotlinx.serialization 引入生产环境,这些细节可能比官方文档的"Getting Started"更有参考价值。
编译器插件:便利背后的绑定风险
kotlinx.serialization 的核心设计是把序列化逻辑交给 Kotlin 编译器插件生成,而不是运行时反射。这个思路和 Moshi 的 CodeGen 类似,但实现层级更深——它直接插在 Kotlin 编译管道的 IR(Intermediate Representation)阶段,为标记了 @Serializable 的数据类生成 Serializer 实现。
这个设计带来的好处很明显:没有反射开销,JSON 字段和 Kotlin 属性的映射在编译期就确定了,null-safety 检查也能前置。但问题恰恰在于"编译器插件"这个定位本身。
Kotlin 编译器插件的 API 稳定性,长期以来是个灰色地带。JetBrains 内部大量使用这类插件(比如 Compose 编译器插件),但对外承诺的兼容性边界一直很模糊。kotlinx.serialization 的编译器插件版本必须严格匹配 Kotlin 编译器版本,这个约束不是建议,是硬性要求。1.6.0 的 serialization 插件配 1.9.20 的 Kotlin 编译器?编译直接报错,报错信息还可能误导你到 Gradle 缓存问题上去。
我们踩过的一个具体坑:2023 年初 Kotlin 1.8.0 发布,我们升级后 kotlinx.serialization 1.4.1 的插件在编译时抛 IrLinkageError,堆栈指向生成的 serializer 代码访问了不存在的 IR 节点。GitHub issue #2210 里有人贴了同样的报错,维护者的回复是"1.4.x 不支持 Kotlin 1.8,请升级到 1.5.0-RC"。这个 RC 版本等了六周,期间我们被迫锁死 Kotlin 版本,其他依赖的安全补丁也连带滞后。
更深层的风险是 K2 编译器的迁移。Kotlin 2.0 把前端编译器完全重写为 K2,所有编译器插件都要适配新的 FIR(Frontend IR)和 K2 IR 后端。kotlinx.serialization 1.6.0 开始提供 K2 支持,但标注为实验性(-Xuse-k2 开关)。我们内部有个工具链在 Kotlin 2.0.0 正式版发布后尝试启用 K2,结果 @Serializable 的类在特定嵌套泛型场景下生成错误的 serializer,JSON 解析时直接 MissingFieldException。issue #2447 记录了类似案例,最终修复在 1.6.2。这意味着如果你追求最新的 Kotlin 版本红利,serialization 的编译器插件可能是最长的那块短板。
对比 Moshi 的 KSP(Kotlin Symbol Processing)方案:Moshi 的代码生成依赖 KSP 处理器,KSP 本身有独立的版本节奏(当前 1.9.21-1.0.15 对应 Kotlin 1.9.21),和 Kotlin 编译器版本是松耦合。升级 Kotlin 时,KSP 适配通常比编译器插件更快,因为 KSP 的 API 表面更窄,且 Google 有专门的团队在维护。这不是说 KSP 没有自己的问题(KSP 对 Kotlin/Native 的支持至今不完整),但在 JVM/Android 纯血场景下,版本解耦确实降低了升级的心智负担。
多平台承诺与 JVM reality 的落差
kotlinx.serialization 的招牌特性是多平台支持:同一套数据类,配 @Serializable,可以在 JVM、Android、Native、JS、Wasm 上共用 serializer。这个愿景对 Kotlin Multiplatform Mobile(KMM)项目有致命吸引力。我们 2021 年启动的一个跨平台 SDK 项目,核心数据层完全基于 kotlinx.serialization,意图是 iOS 和 Android 共享模型定义。
实际推进中,JVM 和 Native 的 behavior divergence 比预期严重。一个具体例子:1.5.0 之前,JVM 的 JSON 解析器对浮点数的 Infinity 和 NaN 默认拒绝(符合 JSON 规范),但 Kotlin/Native 的底层实现(基于 C 库的 cJSON)在某些目标平台上居然允许解析,只是序列化时又会抛异常。这种不一致在单元测试里完全测不出来,因为 JVM 测试覆盖不到 Native 的行为,直到 iOS 集成测试时才暴露。issue #1904 跟踪了这个问题,修复拖了两个 minor 版本。
另一个更隐蔽的差异是 Char 类型的处理。JSON 标准没有 char 类型,kotlinx.serialization 在 JVM 上把 Char 序列化为单字符字符串("a"),在 Native 上某些早期版本居然序列化为 Unicode 码点整数(97)。这个行为在 1.3.x 时代被修正,但修正本身又是 breaking change——如果你之前依赖了 Native 上的整数行为,升级后数据格式就变了。多平台的一致性承诺,在实现层面被底层平台的差异不断侵蚀。
我们最终的折中方案是:共享数据类定义,但平台相关的序列化配置(JSON 的 isLenient、allowSpecialFloatingPointValues 等)各自维护。这实际上削弱了"一套代码跑多端"的叙事,但避免了运行时惊喜。对于纯 Android 项目,这个多平台优势完全是 overhead,你引入的编译器插件、运行时库体积、构建复杂度,换不来任何实际收益。
JSON 解析器的性能与功能边界
kotlinx.serialization 的 JSON 解析器是纯 Kotlin 实现,没有依赖 Jackson、Gson 等外部库。这个实现的质量如何,直接决定了生产环境的性能和可靠性。
性能层面,我们 2022 年做过一次内部基准测试,对比对象包括 Gson 2.9.1、Moshi 1.14.0、Jackson 2.13.3(with Kotlin module),以及 kotlinx.serialization 1.4.1。测试数据是真实的用户画像 JSON,平均 12KB,嵌套三层,包含数组和 polymorphic 类型。在 JVM HotSpot 上,kotlinx.serialization 的解析速度介于 Gson 和 Moshi 之间,比 Jackson 慢约 30%,比 Gson 快约 20%。序列化速度则更接近 Moshi,比 Gson 快 40% 左右。
但这个基准有个陷阱:kotlinx.serialization 的 Json 配置默认关闭了宽松模式(lenient),对字段缺失、类型不匹配、未知字段的处理更严格。打开 isLenient = true 和 ignoreUnknownKeys = true 后,性能会下降 15% 左右,因为额外的分支检查和解码回退路径被激活。而 Gson 默认就是 lenient 的,它的"快"有一部分来自对异常场景的粗处理。如果你的后端 API 不够规范(字段时有时无、类型偶尔漂移),为了兼容性打开 lenient 选项,kotlinx.serialization 的性能优势会缩水。
功能边界方面,有几个具体限制在生产环境里很扎手。
Polymorphic 序列化的注册机制。@Serializable 配合 sealed class 可以自动处理多态,但非密封类的多态需要显式注册 SerializersModule。这个注册是静态的,运行时不能动态扩展。我们有个场景是插件化架构,每个插件可能带来新的数据类型,需要序列化支持。Jackson 的 ObjectMapper 可以运行时注册 subtype,Gson 的 RuntimeTypeAdapterFactory 也能动态处理,但 kotlinx.serialization 的 SerializersModule 必须在 Json 实例构建时确定。1.6.0 引入了 SerializersModule { polymorphic(...) } 的 builder,但核心限制没变。我们最终的 workaround 是维护一个全局可变的 SerializersModule 引用,插件加载时重建 Json 实例——这引入了线程安全问题,而且 Json 实例的重建成本不低。
Streaming 解析的缺失。kotlinx.serialization 的 JSON 解析器目前只支持把整个 JSON 字符串加载到内存后解析,没有基于 InputStream 或 Reader 的流式 API。对于大图元数据、日志批量上传等场景,JSON 可能达到 MB 级别,全量加载有 OOM 风险。Jackson 的 JsonParser 和 Moshi 的 JsonReader 都支持流式解析,可以边读边处理。kotlinx.serialization 的 issue #1128 从 2020 年就开始讨论 streaming 支持,2023 年有个实验性 PR,但至今未合入主线。我们现在的做法是超大 JSON 场景回退到 Jackson,数据类层面做转换适配,这破坏了技术栈的统一性。
自定义 serializer 的编写复杂度。官方文档里的 @Serializer(forClass = ...) 和 KSerializer<T> 实现,对于简单场景(比如把 Instant 序列化为 ISO 字符串)还算直观。但一旦涉及泛型容器、上下文相关的序列化逻辑,API 的陡峭程度陡增。Decoder 和 Encoder 的接口设计暴露了 JSON 的底层 token 流,但不同格式(JSON、Proto、CBOR)的 Decoder 实现行为不一致,写跨格式的 custom serializer 几乎不可能。我们有个需求是把特定字段的 JSON 序列化为压缩的 Base64 字符串,自定义 serializer 在 JSON 格式下工作正常,切换到 Protobuf 格式时直接崩溃,因为 Protobuf 的 Encoder 不支持字符串的逐字符写入。这种格式耦合在 Jackson 的 JsonSerializer 里是不存在的,因为 Jackson 的 API 假设你就是在处理 JSON。
版本升级的血泪:1.5.x 的 breaking change 风暴
如果说编译器插件的版本绑定是慢性毒药,那么库本身的 breaking change 就是急性发作。kotlinx.serialization 在 1.5.0 版本引入了一系列不兼容改动,我们的升级过程堪称灾难。
最痛的是 JsonElement 的层级重构。1.5.0 之前,JsonObject 继承自 JsonElement,API 是 val element: JsonElement = JsonObject(map)。1.5.0 把 JsonObject 改为 JsonElement 的 inline value class,构造函数从 JsonObject(content: Map<String, JsonElement>) 变成 JsonObject(content: Map<String, JsonElement>) 但内部存储结构完全不同。这导致所有直接操作 JsonElement 树的老代码编译失败,而且错误信息极其晦涩:Type mismatch: inferred type is JsonObject but JsonElement was expected,实际上两者还是继承关系,但 value class 的装箱/拆箱在特定上下文失效。
更隐蔽的是 JsonElement 的 equals 行为变化。value class 的 equals 默认比较底层存储,而 JsonObject 的底层是 Map<String, JsonElement>。1.5.0 之前,JsonObject(mapOf("a" to JsonPrimitive(1))) == JsonObject(mapOf("a" to JsonPrimitive(1))) 返回 true,因为重写了 equals。1.5.0 的某个中间版本里,value class 的 equals 委托出了问题,同样的表达式返回 false,导致我们的缓存键匹配逻辑大面积失效。这个 bug 在 1.5.1 被修复,但 1.5.0 的 release note 完全没有提及这个回归。
SerialDescriptor 的 API 调整是另一个深坑。我们在几个模块里自定义了 AbstractPolymorphicSerializer,依赖 SerialDescriptor 的 getElementName(index) 和 getElementIndex(name) 做字段映射。1.5.0 把这些方法标记为 ExperimentalSerializationApi,同时引入了新的 SerialDescriptor.getElementDescriptors() 流式 API。老 API 没有立即删除,但行为有微妙变化:getElementIndex 在未知字段时返回 UNKNOWN_NAME(即 -1),但 1.5.0 的某个场景下会抛 IndexOutOfBoundsException 而不是返回 -1。这个变化没有出现在 migration guide 里,我们是在线上崩溃日志里发现的。
对比 Moshi 的版本策略:Moshi 1.x 到 1.14.0 的演进极其保守,公开的 breaking change 屈指可数,主要是 JsonAdapter 的 null 处理语义在 1.9.0 收紧过一次。Jackson 的 2.x 系列更是以稳定性著称,2.10 到 2.15 的升级通常只需要改依赖版本号。kotlinx.serialization 作为 JetBrains 官方库,版本策略却更接近快速迭代的开源项目,minor 版本携带 breaking change 的频率让人难以建立信任。
生态位与替代方案:什么时候不该用它
经过这几年的磨合,我们内部对 kotlinx.serialization 的定位逐渐清晰:它是 Kotlin 多平台项目的"默认选项",但不是所有场景的最优解。
纯 Android 项目,如果团队已经在用 Moshi 且没有 KMM 迁移计划,迁移到 kotlinx.serialization 的收益有限。Moshi 的 KSP 代码生成在编译速度上优于 serialization 的编译器插件(不需要额外的 plugin 应用和配置),JSON 的 lenient 模式默认开启对遗留 API 更友好,而且 Square 的维护节奏相对稳定。Moshi 的局限是 Kotlin/Native 支持不完整(KSP 对 Native 的支持在 1.9.x 才逐步完善),以及没有内置的 Protobuf、CBOR 等格式支持。如果你的数据交换格式锁定 JSON,且不需要跨平台共享,Moshi 是更省心的选择。
需要极致性能或超大 JSON 处理的场景,Jackson 仍然是 JVM 生态的标杆。Jackson 的 ObjectMapper 配置繁复是事实,但 Afterburner 模块的 JIT 优化、 JsonParser 的流式 API、以及成熟的 KotlinModule(由 JetBrains 团队维护, irony),在性能敏感场景下没有替代品。我们现在的日志上报链路,JSON 平均 2-5MB,用的就是 Jackson + KotlinModule,kotlinx.serialization 在这个体积下直接 OutOfMemoryError。
已经深度依赖 Gson 的遗留项目,迁移成本被严重低估。Gson 的 TypeToken 和 JsonDeserializer 生态有大量第三方扩展,kotlinx.serialization 的 custom serializer API 不兼容这些扩展。我们估算过一个中等规模项目的迁移成本:约 200 个数据类,80 个自定义 adapter,测试覆盖 60%,迁移加回归测试需要 3-4 人周。而 Gson 在 Android 上的性能问题(反射导致的 ProGuard/R8 规则复杂度、Kotlin 数据类的默认值丢失)可以通过增量修补缓解,不一定值得全量迁移。
那 kotlinx.serialization 的真正优势场景是什么?
第一,Kotlin Multiplatform 项目,尤其是涉及 Native 目标(iOS、桌面、嵌入式)的。这是它的设计原点,其他库无法替代。即使 Native 的实现有 platform-specific 的 bug,共享数据类定义的价值通常超过这些摩擦成本。
第二,需要统一多格式序列化的项目。kotlinx.serialization 的 Json、ProtoBuf、Cbor 等格式共享同一套 KSerializer 和 SerialDescriptor 抽象,切换格式不需要重写数据类。我们在一个物联网项目里用这套抽象同时支持 JSON(云端通信)和 CBOR(设备蓝牙传输),代码复用度很高。但注意,前面提到的 custom serializer 格式耦合问题在这个场景下会被放大。
第三,对 Kotlin 语言特性(value class、inline class、sealed class、context receivers)有深度依赖的项目。kotlinx.serialization 对这些特性的支持通常领先于其他库,因为 JetBrains 可以协调编译器和序列化库的同步演进。1.6.0 对 value class 的序列化支持就是个例子,Moshi 的 KSP 处理器在同等时间点还处理不好 value class 的自动解箱。
生产环境的 checklist:如果你决定用
假设评估后决定引入 kotlinx.serialization,有几个实操层面的建议来自我们的踩坑经验。
版本锁定策略。在根目录的 gradle/libs.versions.toml 里显式声明 Kotlin 版本和 serialization 版本的绑定关系,用注释标注兼容矩阵。我们的 current 配置是:
kotlin = "1.9.21"
kotlinx-serialization = "1.6.2"
# 升级 Kotlin 前,先查 https://github.com/Kotlin/kotlinx.serialization/releases 的兼容性声明CI 里加一条版本一致性检查任务,扫描 build.gradle.kts 里是否有直接引用 serialization 插件版本而绕过 catalog 的情况。
编译器插件的缓存问题。kotlinx.serialization 的编译器插件在 Gradle 增量编译时有已知的不稳定性,表现为修改数据类后 serializer 未重新生成,导致运行时 MissingFieldException 或类型不匹配。我们的 workaround 是在 CI 的 release build 里强制 --rerun-tasks,本地开发时遇到可疑行为执行 ./gradlew clean。这个问题在 1.6.x 有所改善,但没有完全根治。
@Serializable 类的演进规则。JSON 的向后兼容(老数据类读新 JSON、新数据类读老 JSON)需要显式设计。kotlinx.serialization 的 @SerialName 可以稳定字段键名,@Required 标记字段必须存在(默认是 optional),@Transient 排除字段。但默认值的处理要小心:val count: Int = 0 在 JSON 缺失该字段时反序列化为 0,这个 0 是 Kotlin 默认值还是 JSON 的 null 语义,在复杂场景下容易混淆。建议所有可选字段用 nullable 类型 val count: Int? = null,在业务层再做默认值填充,避免序列化层和业务层的默认值逻辑打架。
Polymorphic 的安全配置。Json { classDiscriminator = "type" } 的默认行为是把类型标识符写入 JSON 字段,这有安全风险(暴露内部类结构)且容易被后端误用。建议显式配置 classDiscriminator 为不冲突的字段名,或者更好的是用 JsonClassDiscriminator 注解在每个 sealed class 上单独控制。对于不信任的 JSON 输入(比如第三方 webhook),务必关闭 allowStructuredMapKeys 和 allowSpecialFloatingPointValues,减少攻击面。
测试策略。单元测试覆盖不了编译器插件的生成逻辑,需要补充编译测试(Compile Testing 或 Kotlin Compile Testing)验证 @Serializable 类的 serializer 生成是否成功。我们有一套自动化测试扫描所有数据类,确保没有遗漏 @Serializable 注解或 SerializersModule 注册。对于多平台项目,必须在每个目标平台(至少 JVM 和 Native)上运行序列化测试,不能假设行为一致。
现在的判断:1.6.x 能上了吗?
2024 年的当下,kotlinx.serialization 1.6.2 配合 Kotlin 1.9.21,在我们的生产环境里已经稳定运行超过六个月。K2 编译器没有启用,因为 Compose 编译器插件的 K2 支持也还在追赶,serialization 不是唯一的瓶颈。JSON 的 streaming 解析仍然缺失,超大 JSON 场景继续用 Jackson 兜底。
我的个人判断是:对于新项目、KMM 项目、或者对 Kotlin 语言特性有强依赖的项目,kotlinx.serialization 1.6.x 的生产可用性已经达到"谨慎推荐"级别。前提是你要接受它的版本绑定策略、breaking change 历史、以及特定功能的缺失(streaming、动态 polymorphic 注册)。对于纯 Android 的遗留项目,没有 KMM 计划,迁移的性价比仍然不高。
一个观察:JetBrains 对 kotlinx.serialization 的投入在加大。1.6.0 引入了 kotlinx.serialization.json.io 模块(实验性的流式 JSON 写入,读取仍不支持),1.6.2 修复了 K2 下的多个生成错误。GitHub 仓库的 issue 响应速度在 2023 年有明显提升,critical bug 的修复周期从数月缩短到数周。这些信号表明它正在从"JetBrains 内部工具"向"生态基础设施"转变,但转变尚未完成。
最终的技术选型,还是要回到具体的问题域。没有银弹,也没有必须跟的风。kotlinx.serialization 的多平台叙事很动人,但 JVM/Android 的 reality 是复杂的,性能、稳定性、迁移成本、团队心智模型,都是比"官方推荐"更沉重的砝码。我们用了三年,还在和调整它的 sharp edge 共存,这个状态本身或许就是最诚实的评价。