第四篇:kotlinx.serialization 从零理解:KMP 为什么不再依赖 Gson?
上一篇我们讲了:
ContentNegotiation
↓
负责把 Ktor 的 Request / Response
接到具体的序列化系统上
而真正负责:
Kotlin Object
↕
JSON
转换的,是:
kotlinx.serialization
如果以前长期做 Android,最熟悉的 JSON 工具大概率是:
Gson
所以第一次接触:
@Serializable
以及:
Json.decodeFromString()
Json.encodeToString()
时,很容易觉得:
这不就是换了一个 Gson 吗?
从“JSON 和对象互相转换”这个使用层面来说,确实很像。
但从设计方式和 KMP 场景来看,两者有比较明显的差别。
这一篇就专门脱离 Ktor,单独把:
kotlinx.serialization
讲清楚。
一、先搞懂什么叫序列化
假设我们有一个 Kotlin 对象:
data class User(
val id: Long,
val name: String,
)
运行时可能有一个实例:
val user = User(
id = 1001,
name = "Tom",
)
这是内存中的 Kotlin Object。
但是网络传输不能直接把:
User 对象
塞进 HTTP。
通常要把它转换成:
{
"id": 1001,
"name": "Tom"
}
这个过程:
Kotlin Object
↓
JSON
就叫:
序列化
Serialization
反过来,服务器返回:
{
"id": 1001,
"name": "Tom"
}
客户端转换成:
User(
id = 1001,
name = "Tom",
)
就是:
JSON
↓
Kotlin Object
这个过程叫:
反序列化
Deserialization
所以以后看到:
Serialization
不要觉得神秘。
本质就是:
对象
↕
某种可存储 / 可传输格式
JSON 只是最常见的一种格式。
二、kotlinx.serialization 是什么?
可以先简单理解成:
Kotlin 官方生态中的跨平台序列化框架。
它可以处理:
Kotlin Object
↕
JSON
也可以配合其他格式。
在我们现在的 KMP 网络项目里,最关注的是:
JSON
所以接下来主要围绕:
kotlinx.serialization.json.Json
来讲。
三、最核心的 @Serializable
先定义:
@Serializable
data class User(
val id: Long,
val name: String,
)
这里最关键的是:
@Serializable
它表示:
这个类型需要支持 kotlinx.serialization 的序列化和反序列化。
可以先理解成:
User
↓
具备 Serializer
↓
可以 JSON ↔ User
注意:
@Serializable
不是 Ktor 的注解。
它属于:
kotlinx.serialization
所以即使项目完全不用 Ktor:
没有 HttpClient
没有 ContentNegotiation
你仍然可以单独使用 kotlinx.serialization。
四、它可以脱离 Ktor 单独工作
例如:
@Serializable
data class User(
val id: Long,
val name: String,
)
我们创建:
val user = User(
id = 1001,
name = "Tom",
)
然后:
val json = Json.encodeToString(user)
得到:
{
"id": 1001,
"name": "Tom"
}
这就是:
User
↓
encodeToString()
↓
JSON String
也就是:
序列化
五、反序列化怎么写?
反过来:
val json = """
{
"id": 1001,
"name": "Tom"
}
""".trimIndent()
然后:
val user = Json.decodeFromString<User>(json)
得到:
User(
id = 1001,
name = "Tom",
)
过程:
JSON String
↓
decodeFromString<User>()
↓
User
也就是:
反序列化
所以最基础的两个 API 可以先记住:
Json.encodeToString()
和:
Json.decodeFromString()
六、那 Ktor 中为什么平时看不到 encode / decode?
因为 Ktor 已经帮我们把这一步接起来了。
上一篇讲过:
install(ContentNegotiation) {
json()
}
然后:
setBody(user)
背后其实相当于:
User
↓
kotlinx.serialization
↓
JSON
↓
HTTP Request Body
响应:
response.body<User>()
背后:
JSON
↓
kotlinx.serialization
↓
User
所以:
直接使用 kotlinx.serialization
时,你自己调用:
encodeToString()
decodeFromString()
而到了 Ktor:
ContentNegotiation
帮你把这些转换步骤接入到了 HTTP 生命周期里。
这也是为什么我们前面一直强调:
Ktor 和 kotlinx.serialization 是两层。
七、为什么数据类必须写 @Serializable?
以前使用 Gson 时,我们经常可以直接:
data class User(
val id: Long,
val name: String,
)
然后 Gson 就解析了。
所以第一次看到:
@Serializable
会觉得:
为什么还要多写一个注解?
这是因为 kotlinx.serialization 的实现方式和传统基于反射的 JSON 框架思路不完全一样。
可以先把它理解成:
编译阶段
↓
根据 @Serializable
↓
为 User 生成对应序列化能力
于是运行时:
JSON
↓
User.serializer()
↓
User
这个特点对 KMP 很重要。
因为:
commonMain
不能依赖只存在于 JVM/Android 世界里的完整 Java 反射体系。
而 kotlinx.serialization 天然就是为 Kotlin 多平台设计的。
八、这也是它特别适合 KMP 的原因
如果我们只做 Android:
Android
↓
JVM
使用 Gson 通常没有什么问题。
但是进入 KMP:
commonMain
androidMain
iosMain
jsMain
wasmJsMain
这时候我们希望:
同一个 User DTO
同一套 JSON 解析
直接放在:
commonMain
例如:
@Serializable
data class User(
val id: Long,
val name: String,
)
然后:
Android
iOS
Web
都使用它。
所以:
kotlinx.serialization
↓
跨平台
和 KMP 的思路天然更加匹配。
九、从 Gson 迁移,可以先这样理解
以前:
JSON
↓
Gson
↓
User
现在:
JSON
↓
kotlinx.serialization
↓
User
所以从最表面的职责来看:
Gson
≈
kotlinx.serialization
都可以完成:
JSON ↔ Object
但是底层思想可以先粗略理解成:
Gson
↓
更依赖 JVM / Java 运行时能力
kotlinx.serialization
↓
Kotlin 编译器插件 + Serializer
↓
天然支持 Kotlin Multiplatform
所以进入 KMP 后,使用 kotlinx.serialization 往往更加自然。
十、Json 不是单纯一个工具类
我们经常看到:
Json.decodeFromString<User>(json)
这里:
Json
是 kotlinx.serialization 提供的 JSON 格式实现。
但是正式项目里我们经常不会直接使用默认:
Json
而会创建一套自己的配置:
val json = Json {
ignoreUnknownKeys = true
}
然后:
val user =
json.decodeFromString<User>(jsonString)
为什么?
因为实际后端 JSON 并不总是完全理想。
我们通常需要配置一些解析行为。
十一、ignoreUnknownKeys 非常常用
假设客户端模型:
@Serializable
data class User(
val id: Long,
val name: String,
)
后端返回:
{
"id": 1001,
"name": "Tom",
"avatar": "https://xxx.com/a.jpg",
"level": 10
}
客户端只关心:
id
name
不关心:
avatar
level
配置:
val json = Json {
ignoreUnknownKeys = true
}
那么额外字段:
avatar
level
就可以被忽略。
这在 App 开发中非常实用。
因为后端增加字段时:
后端新增字段
↓
旧客户端没有对应字段
↓
仍然可以正常解析
所以正式项目中:
ignoreUnknownKeys = true
非常常见。
十二、默认值非常重要
假设:
@Serializable
data class User(
val id: Long,
val name: String,
val avatar: String = "",
)
服务器返回:
{
"id": 1001,
"name": "Tom"
}
没有:
avatar
但是我们给了默认值:
avatar: String = ""
那么最终对象可以是:
User(
id = 1001,
name = "Tom",
avatar = "",
)
这其实是网络 DTO 设计中非常重要的能力。
如果某个字段:
后端可能不返回
你要考虑:
它应该 nullable?
还是应该提供默认值?
十三、缺字段和未知字段不是一回事
这个特别容易混。
假设客户端:
@Serializable
data class User(
val id: Long,
val name: String,
)
服务器返回:
{
"id": 1001,
"name": "Tom",
"age": 20
}
这里:
age
是:
未知字段
因为:
JSON 有
DTO 没有
可以通过:
ignoreUnknownKeys = true
忽略。
如果服务器返回:
{
"id": 1001
}
而 DTO:
val name: String
没有默认值,也不是 nullable。
这叫:
缺少必需字段
这是另一回事。
也就是:
未知字段:
JSON 有
DTO 没有
和:
缺失字段:
DTO 要
JSON 没有
不要混在一起。
十四、nullable 也不代表字段可以完全缺失
例如:
@Serializable
data class User(
val id: Long,
val avatar: String?,
)
这里:
avatar 可以是 null
例如:
{
"id": 1001,
"avatar": null
}
是可以的。
但实际项目里如果服务器连:
avatar
这个 Key 都可能不返回,那么更稳妥的写法通常是:
@Serializable
data class User(
val id: Long,
val avatar: String? = null,
)
这样:
{
"id": 1001
}
也可以处理。
所以:
nullable
解决的是:
值可以是 null
而:
默认值
解决的是:
字段缺失时怎么办
这两个概念也要区分。
十五、@SerialName 是干什么的?
假设服务器返回:
{
"user_id": 1001,
"user_name": "Tom"
}
但是 Kotlin 我们通常喜欢:
userId
userName
而不是:
user_id
user_name
这时候可以:
@Serializable
data class User(
@SerialName("user_id")
val userId: Long,
@SerialName("user_name")
val userName: String,
)
于是映射:
JSON Kotlin
user_id → userId
user_name → userName
这样就可以做到:
JSON 遵循后端字段命名,Kotlin 代码仍然使用自己的命名规范。
十六、嵌套对象怎么解析?
例如:
{
"id": 1001,
"name": "Tom",
"address": {
"city": "Suzhou",
"street": "xxx"
}
}
定义:
@Serializable
data class Address(
val city: String,
val street: String,
)
然后:
@Serializable
data class User(
val id: Long,
val name: String,
val address: Address,
)
kotlinx.serialization 会按照类型关系继续解析:
User
│
└── Address
也就是:
JSON Object
↓
User
↓
address
↓
Address
并不需要我们手动一层层调用解析。
十七、List 怎么解析?
服务器:
[
{
"id": 1001,
"name": "Tom"
},
{
"id": 1002,
"name": "Jack"
}
]
可以直接:
val users =
Json.decodeFromString<List<User>>(json)
结果:
List<User>
所以:
User
List<User>
Map<String, User>
这些组合类型都可以参与序列化。
十八、正式项目真正重要的是泛型
我们的后端通常返回:
{
"code": 0,
"msg": "success",
"data": {
"id": 1001,
"name": "Tom"
}
}
于是:
@Serializable
data class ApiResponse<T>(
val code: Int,
val msg: String,
val data: T,
)
然后:
Json.decodeFromString<ApiResponse<User>>(
json
)
解析关系:
ApiResponse<T>
↓
T = User
↓
ApiResponse<User>
服务器如果返回列表:
{
"code": 0,
"msg": "",
"data": [
{
"id": 1001,
"name": "Tom"
}
]
}
可以:
ApiResponse<List<User>>
所以统一网络响应结构才能真正成立。
十九、encodeDefaults 是什么?
来看:
@Serializable
data class UserRequest(
val name: String,
val age: Int = 18,
)
然后:
UserRequest(
name = "Tom"
)
如果进行序列化,有时我们会关心:
默认值 age = 18
到底要不要出现在 JSON 里。
例如:
{
"name": "Tom"
}
还是:
{
"name": "Tom",
"age": 18
}
可以通过 Json 的默认值编码策略控制。
正式项目里这类配置要根据后端协议来定。
有的接口:
不传字段
和:
明确传默认值
业务含义可能并不一样。
所以不要看到:
默认值
就觉得只是客户端方便。
它有时会影响最终 HTTP Body。
二十、explicitNulls 又是什么?
例如:
@Serializable
data class UpdateUserRequest(
val avatar: String? = null,
)
这里序列化时可能涉及:
{
"avatar": null
}
或者:
{}
这两个对后端来说,含义可能完全不同。
比如更新接口:
不传 avatar
↓
保持原值
而:
avatar = null
↓
清空头像
所以:
null 是否显式编码
不是单纯技术问题,也可能是业务协议问题。
这就是为什么我们在正式项目中不能机械复制一套 Json { ... } 配置。
需要根据接口协议确定。
二十一、isLenient 是什么?
有时我们还能看到:
Json {
isLenient = true
}
简单理解:
允许解析一些相对不那么严格的 JSON 输入。
但正常自己的后端 API,最好还是遵循标准 JSON。
所以:
isLenient
不是:
项目标配
而应该在确实需要兼容某些不规范数据时使用。
二十二、一个常见的正式项目配置
例如:
val networkJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
然后交给 Ktor:
install(ContentNegotiation) {
json(networkJson)
}
于是:
HttpClient
↓
ContentNegotiation
↓
networkJson
↓
kotlinx.serialization
这样:
Json 的行为
就被集中管理了。
而不是每个接口:
自己创建 Json
自己 encode
自己 decode
二十三、为什么最好把 Json 配置集中起来?
假设:
UserApi
使用:
Json {
ignoreUnknownKeys = true
}
但是:
OrderApi
又自己:
Json {
ignoreUnknownKeys = false
}
另外:
RepairApi
又来一套。
那么整个项目的 JSON 行为就不统一。
所以正式项目更推荐:
统一 Network Json
↓
统一 Json 配置
↓
交给 ContentNegotiation
↓
整个 Client 复用
例如:
internal val NetworkJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
然后:
install(ContentNegotiation) {
json(NetworkJson)
}
二十四、多 Client 项目是否一定共用同一个 Json?
不一定。
上一篇我们已经知道,正式项目可能有:
api
refresh
upload
download
weather
thirdParty
普通业务 API:
apiClient
可以使用:
Json Config A
而某个第三方服务:
weatherClient
如果它的数据规则很特殊,也可以:
Json Config B
所以:
统一
不是说:
整个 App 强制只能有一套配置。
更准确地说:
同一套网络协议域应该使用一致的序列化策略。
例如:
自家业务 API
↓
一套 Json
第三方 API
↓
另一套 Json
也是合理的。
二十五、Gson 和 kotlinx.serialization 最大的认知差异
从 Android 使用角度,我们以前可能觉得 JSON 框架就是:
拿到 Class
↓
运行时解析
例如:
gson.fromJson(
json,
User::class.java,
)
这里非常明显地出现:
Class
这是一种典型 JVM 世界的思维。
而 kotlinx.serialization 更接近:
Kotlin 类型
↓
对应 Serializer
↓
执行序列化
例如:
Json.decodeFromString<User>(json)
你已经不再显式传:
User::class.java
这也更符合 KMP 世界:
commonMain
↓
不能围绕 Java Class 体系设计
所以从 Android 转到 KMP,这个思维转变很重要。
二十六、不要把 @Serializable 理解成“反射标记”
这一点也值得单独说。
第一次接触时很容易觉得:
@Serializable
是不是类似:
给运行时反射看的标记
其实更适合这样理解:
编译器看到 @Serializable
↓
为这个类型生成序列化相关代码
↓
运行时使用对应 Serializer
因此它并不是:
运行时扫描所有字段
这种简单思路。
也正因为如此,它更容易跨:
JVM
Native
JS
Wasm
这些不同运行环境。
二十七、它和 KMP 的关系再串一次
现在整个架构已经可以连起来:
commonMain
@Serializable DTO
↓
kotlinx.serialization
↓
ContentNegotiation
↓
Ktor HttpClient
↓
Engine
↙ ↘
Android iOS
↓ ↓
OkHttp Darwin
也就是说:
数据模型
JSON 转换
网络业务
都可以大量共享。
平台差异主要沉到:
Engine
甚至某些更底层的平台能力。
这就是为什么:
Ktor + kotlinx.serialization
在 KMP 项目里经常一起出现。
二十八、它和我们后面的 NetworkClient 有什么关系?
后面我们可能会写:
suspend inline fun <reified T> get(
path: String,
): T {
return client
.get(path)
.body()
}
这里:
T
之所以最终能够变成:
User
Order
List<User>
ApiResponse<User>
背后依赖的就是:
body<T>()
↓
ContentNegotiation
↓
kotlinx.serialization
↓
T
所以:
泛型 NetworkClient
能够成立的一个基础,就是统一的序列化体系。
二十九、以后看到这段配置应该怎么理解?
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
}
)
}
不要整体背。
应该拆开:
install(ContentNegotiation)
↓
给 Ktor 安装内容转换能力
然后:
json(...)
↓
注册 JSON 转换器
然后:
Json { ... }
↓
配置 kotlinx.serialization 的 JSON 行为
最后:
ignoreUnknownKeys = true
↓
忽略 DTO 中不存在的额外字段
explicitNulls = false
↓
控制 null 字段的 JSON 表达策略
这样一层一层理解,就不会乱。
三十、常见误区
误区一
kotlinx.serialization
=
Ktor 的 JSON 库
不准确。
它可以完全脱离 Ktor 使用。
误区二
@Serializable
=
Ktor 注解
错误。
它属于 kotlinx.serialization。
误区三
nullable
=
字段可以不返回
不完全正确。
nullable
↓
允许值为 null
如果字段本身可能缺失,更应该考虑:
默认值
例如:
val avatar: String? = null
误区四
ignoreUnknownKeys
=
忽略缺失字段
错误。
它解决的是:
JSON 多字段
DTO 少字段
不是:
DTO 必需字段
JSON 没返回
误区五
所有 Client 必须使用同一个 Json
也不一定。
应该按:
协议域
统一,而不是机械追求全局唯一。
三十一、从 Gson 迁移,可以这样记
Android 时代:
Gson
↓
JSON ↔ Java/Kotlin Object
KMP 时代:
kotlinx.serialization
↓
JSON ↔ Kotlin Object
↓
天然适合 commonMain
如果再放进 Ktor:
Ktor
↓
ContentNegotiation
↓
kotlinx.serialization
↓
JSON ↔ Kotlin Object
所以最终关系:
Ktor
不是 JSON 框架
ContentNegotiation
不是具体 JSON 解析器
kotlinx.serialization
才负责真正的对象序列化
三十二、本篇总结
kotlinx.serialization 最核心解决的是:
Kotlin Object
↕
序列化格式
在我们的网络项目里主要就是:
Kotlin Object
↕
JSON
最基础:
@Serializable
data class User(
val id: Long,
val name: String,
)
序列化:
Json.encodeToString(user)
反序列化:
Json.decodeFromString<User>(json)
正式项目常见配置:
Json {
ignoreUnknownKeys = true
explicitNulls = false
}
字段名映射:
@SerialName("user_id")
val userId: Long
缺失字段可以通过:
默认值
处理。
允许 null:
nullable
处理。
而进入 Ktor 后:
kotlinx.serialization
不会自己直接操作 HTTP。
它会通过:
ContentNegotiation
接入 HttpClient。
完整链路:
Kotlin Object
↓
kotlinx.serialization
↓
JSON
↓
ContentNegotiation
↓
Ktor HttpClient
↓
HTTP
响应则反过来:
HTTP
↓
Ktor HttpClient
↓
ContentNegotiation
↓
kotlinx.serialization
↓
Kotlin Object
所以这一篇最重要的一句话是:
kotlinx.serialization 是 Kotlin 多平台环境中的序列化框架,负责真正完成 Kotlin 对象与 JSON 等格式之间的转换;Ktor 只是通过 ContentNegotiation 把它接入网络请求和响应生命周期。
下一篇
《Ktor DefaultRequest:BaseUrl、公共 Header 为什么不应该每个接口都写?》
下一篇开始正式进入网络工程化。
重点解决:
BaseUrl
App-Version
Platform
Device-Id
公共 Header
公共 Query
为什么不应该在:
client.get(...)
client.post(...)
里重复书写。
最终从:
每个接口自己拼配置
演进到:
HttpClient
↓
DefaultRequest
↓
统一公共请求配置
并结合前面已经讲过的:
单 Client
多 Client
NetworkConfigs
一起解释不同 Client 为什么可以拥有不同的 BaseUrl 和公共 Header。
更多推荐

所有评论(0)