上一篇我们讲了:

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。

更多推荐