KMM(Kotlin Multiplatform Mobile)入门(七)处理 HTTP 网络请求

背景

与 Server 的数据交互已经成为 App 必不可少的一个重要部分,常用的方式即 HTTP(S),当然也有 WebSocket、TCP、UDP 等等

在 KMM 模块中,为保证双端逻辑一致,且对 JVM、Native 进行统一兼容,可以使用官方推荐的 Ktor 进行网络通信,Kotlinx.Serialization 来进行数据解析

这篇文章就来介绍在 KMM 中如何发起并处理网络请求,后面的文章再详细介绍 kotlinx.serialization 的使用

Ktor 是什么?

Ktor 是由 JetBrains 开发的一套用于解决各类应用中网络连接的框架,不仅可以用在发起请求的各类客户端(不是所谓的 App),还可以构建微服务

针对客户端能力,通过一系列插件,可以支持 HTTP 的各类特性,如:Cookies、重定向、代理、UA、WebSocket 等,在一定程度上,还可以支持一些简单的 TCP 或 UDP 通信

另外,Ktor 还支持为不同的平台配置不同的 HTTP 引擎,如:为 Android 配置 OkHttp 或 HttpURLConnection,为 iOS 配置 NSURLSession,或者为 JVM 配置 Apache HttpClient、为 JavaScript (Node.js) 配置 node-fetch,以便使用同一套代码逻辑处理网络请求

由于现在的 RESTful API 通常会以 JSON 作为通信数据格式,在 JVM 平台上,Ktor 还支持与 Gson、Jackson 协同工作,而对于 Kotlin Multiplatform(当然包括 KMM)可以与 kotlinx.serialization 进行协作

由于 Ktor 适用的平台广泛,本文只对 KMM 平台上的使用进行说明

为 KMM 模块配置 Ktor

如果你使用的 IDE 是 IntelliJ IDEA Ultimate 版本,可以考虑安装 Ktor 插件,但基于 Community 版本的 Android Studio 等 IDE 并不支持该插件,当然它对实际使用影响不大

对于 KMM 模块,首先需要在 Common 的依赖中加入 Ktor 的核心依赖

由于 Ktor 底层依赖协程一些核心功能,同时 Ktor 需要使用基于 Kotlin Native 且实现多线程版本的协程库,所以还需要加入对协程的依赖

1
2
3
4
5
6
7
8
9
10
11
12
// build.gradle.kts

// 2022 年 4 月,Ktor 正式发布了 2.0.0 版本
val ktor_version = "2.0.2"

// ...

val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:$ktor_version")
}
}

Android 模块中加入 Ktor Android 端默认引擎(使用 HttpURLConnection)的依赖

1
2
3
4
5
6
7
// build.gradle.kts

androidMain {
dependencies {
implementation("io.ktor:ktor-client-android:$ktor_version")
}
}

如果需要使用 OkHttp 来作为 HTTP 能力的引擎,可以使用如下的依赖

1
2
3
4
5
6
7
// build.gradle.kts

androidMain {
dependencies {
implementation("io.ktor:ktor-client-okhttp:$ktor_version")
}
}

另外,Android 端也可以使用 CIO(Coroutine(协程) based I/O 实现)引擎,但 CIO 目前还不支持 HTTP/2

对于 iOS,则加入 iOS 的引擎依赖,由于 iOS 的 HTTP 网络请求都是使用 NSURLSession(包括著名的 AFNetworking,NSURLConnection 早已经不用了),所以也就不像 Android 上有多种选择

1
2
3
4
5
6
7
// build.gradle.kts

iosMain {
dependencies {
implementation("io.ktor:ktor-client-darwin:$ktor_version")
}
}

由于 Ktor 是 Kotlin 团队主要负责开发和维护,所以对 Kotlin 相关技术栈支持的比较友好,且部分技术应用的也比较激进,比如 Kotlin Native 的 New Memory Management,所以官方建议大家使用 Kotlin 协程,这就要求在宿主 App(Android 端)中添加协程相关的依赖

1
2
3
4
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1")
}

Ktor 已经适配了 New Memory 技术,如果还需要开启 New Memory,则需要根据 New Memory 官方的文档要求,在 gradle.properties 文件中,添加以下的配置项

1
kotlin.native.binary.memoryModel=experimental

创建 Ktor 的 HttpClient

Ktor 中的 HttpClient 与其他 HTTP 框架类似,都是对发送和接收网络请求的一系列资源、配置的封装,请求与响应的操作方法,以 Extension 的形式表现,调用也非常简洁

在 Common 代码中,首先需要创建一个 HttpClient 的实例

1
val httpClient by lazy { HttpClient() }

如果不需要对 HttpClient 默认的引擎(根据 Gradle 中的依赖自动设置)进行特殊配置,以上代码足矣

为保障多平台的一致,在 Common 中的 HttpClient,对 engine 的可配置项非常有限,只有下面的 Proxy 和线程数量可配,同时可以支持一些公共的请求配置,写在 defaultRequest 闭包中即可,具体内容见下面一节

1
2
3
4
5
6
7
8
9
HttpClient {
engine {
proxy = ProxyBuilder.http("http://127.0.0.1:8888")
threadsCount = 4
}
defaultRequest {
// 可配置公共的 Cookies、Headers、Params
}
}

如果需要针对不同的平台和不同的引擎的特性,进行一些自定义配置,则需要用到 expect/actual 的方式来实现 HttpClient

比如在 Android 代码中,针对 OkHttp 进行一些定制

1
2
3
4
5
6
7
8
9
10
11
12
13
actual val httpClient by lazy {
HttpClient(OkHttp) {
engine {
config {
// 禁止重定向
followRedirects(false)
}

// 加入 Stetho 方便 Debug
addNetworkInterceptor(StethoInterceptor())
}
}
}

或者对 iOS 的 NSURLSession 进行一些配置

1
2
3
4
5
6
7
8
9
10
11
12
13
actual val httpClient by lazy {
HttpClient(Ios) {
engine {
configureRequest {
// 如果 HttpClient 需要在后台进行上传、下载
NSURLSessionConfiguration.backgroundSessionConfiguration("xxx").apply {
// 添加统一的 Headers
HTTPAdditionalHeaders = mapOf("a" to "b")
}
}
}
}
}

完成 HttpClient 的创建和配置以后,我们就可以在 Common 目录中的 Kotlin 代码中发起网络请求了

发送一个简单的 HTTP 请求

代码非常简单,只需要一行,但因为 Ktor 中大量使用了协程的开发理念,所以需要符合 Kotlin 协程的基本思想和写法,可以参考:https://kotlinlang.org/docs/coroutines-basics.html

1
2
3
4
5
6
7
8
9
10
11
// 写法1:
fun sendGet() {
GlobalScope.launch(Dispatchers.Default) {
val res: HttpResponse = httpClient.get("https://www.baidu.com")
}
}

// 写法2:
suspend fun sendGetAsync() {
val res: HttpResponse = httpClient.get("https://www.baidu.com")
}

这里需要注意的是,由于 iOS 并不支持协程,所以在 iOS 代码中,如果不使用默认的 CoroutineContext,则需要使用 GCD 单独实现一个 CoroutineDispatcher 实例并作为 launch 方法的参数传入,如下面代码所示:

1
2
3
4
5
6
7
8
9
10
11
internal actual val ApplicationDispatcher: CoroutineDispatcher = NsQueueDispatcher(dispatch_get_main_queue())

internal class NsQueueDispatcher(
private val dispatchQueue: dispatch_queue_t
) : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
dispatch_async(dispatchQueue) {
block.run()
}
}
}

最后在实际的 Android 和 iOS 工程当中,调用 sendGet() 即可发送网络请求,完成请求之后,别忘记调用 close() 来关闭和释放 HttpClient 实例,以免造成内存泄露

如果 HttpClient 的实例只做一次网络请求,也可以使用 use 语法,在结束时自动进行 close 操作

1
2
3
val status = HttpClient().use { client ->
// ...
}

由于我们还没有处理网络请求的响应,所以需要使用 Charles 或 Fiddler 抓包才能看到发送的网络请求

自定义请求

众所周知,一条 HTTP 请求报文,包含几个重要部分:Method、Host、Path 及 Query、HTTP 版本、Headers、Body(主要是 POST、PUT)

这些内容,Ktor 也都支持定义,封装在 HttpRequestBuilder 当中,并在 HttpClient 的初始化闭包中的 defaultResult 子闭包,以及 HttpClient 的各个扩展方法中,作为最后一个参数的 Block 参数返回,即:可在 HttpClient.request 或 get、post 等扩展方法调用的后的闭包中操作

如果需要添加统一的公共参数,或者 Headers(包括 Cookies、User-Agent),可以在 HttpClient 初始化时,添加 defaultRequest 闭包,并利用其 HttpRequestBuilder 类型的参数进行配置,这样就是可以使所有使用当前 HttpClient 实例的发送的网络请求,保持统一配置

1
2
3
4
5
6
7
8
HttpClient {
defaultRequest {
header("CommonHeader", "KMM")
parameter("CommonParam", "666")
cookie("USER_ID", "123456")
// ...
}
}

如果只是给某一个请求添加自定义的配置,只需要在 request 方法调用后的闭包中处理即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun sendGet() {
GlobalScope.launch(Dispatchers.Default) {
val res: HttpResponse = httpClient.request ("https://www.baidu.com") {
method = HttpMethod.Get
header("TestHeader", "1")
header("MyHeader", "2")
userAgent("KMM Http Client")
cookie("USER_ID", "123456")

formData {
// 示例写法,实际需要处理字节流
append("image", ByteArray(256))
}
}
}
}

通过 Charles 抓包,就可以看到经过自定义配置后,通过 Ktor 发出的 HTTP 请求

处理响应

和常见的 HTTP 请求框架(如:OkHttp、AFNetworking)类似,Ktor 也支持获取多种类型的返回数据,具体为以下三种:

  • 原始响应 Body:

    获取原始的 HTTP 响应体内容,比如 HTML、纯文本字符串、二进制数据等

  • JSON 对象:

    如果响应内容为纯 JSON 字符串,Ktor 可以在返回响应之前直接解析成你需要的对象,但是需要配置 JSON 插件,并结合 kotlinx.serialization 进行使用

  • 流式数据:

    如文件下载这种数据量比较大,或是异步、非阻塞式返回形式的数据,可能会用到流式的 HTTP 响应接收模式

下面使用几段示例代码,来实现以上几种响应类型的处理

获取原始类型

  • 获取 String 类型(纯文本)的 Body
1
2
val httpResponse: HttpResponse = client.get("https://ktor.io/")
val stringBody: String = httpResponse.body()
  • 获取 ByteArray 类型(二进制)的 Body
1
2
val httpResponse: HttpResponse = client.get("https://ktor.io/")
val byteArrayBody: ByteArray = httpResponse.body()

进行类型自动转换

如果你配置了 Kotlinx.serialization 插件,并且声明了对应数据结构的实体类,则 Ktor 可以自动进行 JSON 解析

首先需要添加 Ktor 用于进行类型转换的依赖,也被称为 ContentNegotiation

1
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")

其次需要添加 Kotlinx.serialization 依赖

1
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")

注意:这里添加以后,会将 Kotlinx.Serialization 相关的依赖传递进来,建议显式指定版本

然后在 HttpClient 初始化的时候,install 这个类型插件 ContentNegotiation,并把 JSON 插件配置在里面

1
2
3
4
5
val client = HttpClient() {
install(ContentNegotiation) {
json()
    }
}

熟悉 Kotlinx.Serialization 的同学可以使用 Json {} 语法来和直接使用 Kotlin.Serialization 一样进行全局解析配置

这里定义一个和请求结果 JSON 结构一致的 data class,并配置好解析规则

1
2
3
4
5
6
7
8
9
@Serializable
data class Student(
@SerialName("user_id")
val id: String,
@SerialName("user_name")
val name: String,
@SerialName("age")
val age: Int,
)
1
2
3
val httpResponse: HttpResponse = client.get("https://api.xxx.com/student?id=xxx")
val xxx: Student = httpResponse.body()
println(xxx.name) // 张三

流式数据

如果需要下载文件,择需要用到流式数据的形式,来处理 HTTP 响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val client = HttpClient(CIO)
val file = File.createTempFile("files", "index")

runBlocking {
client.prepareGet("https://ktor.io/").execute { httpResponse ->
val channel: ByteReadChannel = httpResponse.body()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) {
val bytes = packet.readBytes()
file.appendBytes(bytes)
println("Received ${file.length()} bytes from ${httpResponse.contentLength()}")
}
}
println("A file saved to ${file.path}")
}
}

Ktor 的其他功能

Server 能力

Ktor 是个很强大的网络库,不但提供了 HTTP 客户端所需要的各种常见功能,也提供了 HTTP Server 的能力,虽不能与 Nginx 这种专业的 HTTP Server 相提并论,但用作测试还是不错的

文档:https://ktor.io/docs/intellij-idea.html

WebSocket

除了常见的 HTTP API,Ktor 对 WebSocket 的支持相当友好,Chat ServerChat Client

以上关于 Ktor 的介绍就不再详细展开了,有需要的话,可以参考 Ktor 官网的文档:https://ktor.io/docs/welcome.html,内容也十分详细!

KMM 网络能力建设

直接使用 Ktor 建设网络能力,所带来的影响

  • 主要优点
    • 整体性好,API 统一
    • 可借助 Ktor 的所有新增能力
    • 友好支持协程、Kotlinx.Serialization 等 Kotlin 工具链
    • 没有历史包袱
  • 部分缺点
    • 无法再利用 App 已有网络组件的能力
    • 公共参数、Headers 等需要从 0 开始重新建设
    • 在一定程度上导致包体积增大(尤其是 iOS)
    • 存在一些不稳定因素(New Memory、协程等)

综合 Ktor 在 KMM 项目中集成的一些优点和缺点,个人认为如果你需要使用 KMM 从零开始开发一个 App,且不太过分在意 iOS 平台的包体积影响,可以优先考虑使用 Ktor,这样 API 和各种网络请求流程会更加统一,也能够结合 Ktor 的各类插件,在一定程度上提升开发效率。

但是如果你需要在原有已经非常成熟的 App 中应用 KMM 技术,重构或新开发某些功能,使用 Ktor 往往不会带来更多的收益。这些 App 大多已经拥有非常完善的网络库了,无论是业务上的公参、统计、异常处理、免流量,还是 HTTP/3、IP 直通、HTTP DNS、SSL 等技术迭代,可谓是遍地开花。所以在这种情况下,个人认为应当尽可能充分地利用现有网络库的能力,在 KMM 层进行 API 和流程的抹平!

推荐的网络能力建设方式

结合实际开发过程中的情况,个人更推荐使用 expect/actual 模式来桥接双端真正的 API。

且由于 HTTP 请求这种业务逻辑,各平台都比较接近,也不存在直接操作 UI 的需求,所以也非常适合使用 KMM 去做逻辑统一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* HTTP 请求公共接口
*
* @param method 请求方法 [HttpMethod]
* @param url URL
* @param headers 请求 Header,Key-Value
* @param params 参数,Key-Value
* @param bodyType POST 请求的 Body 类型,可能为 JSON 或 URLParams
* @param succeedCallback 成功回调,在状态码为 200 时回调 Header 和 Body
* @param failedCallback 失败回调,回调错误码和信息
*/
expect fun commonHttpRequest(
method: HttpMethod,
url: String,
headers: Map<String, Any?>?,
params: Map<String, Any>?,
bodyType: HttpPostBodyTypes = HttpPostBodyTypes.URL_PARAMS,
succeedCallback: (resHeaders: Map<String, Any>?, body: String?) -> Unit,
failedCallback: (errCode: Int, errMsg: String?) -> Unit
)

如上面的代码片段所示,可以在 KMM 的 commonMain 目录中定义类似的 HTTP 请求接口,后续在 KMM 代码中即可使用该方法发送并处理 HTTP 请求。

但其 actual 的实现应当考虑的相对周全一些,例如:Android 端可以桥接 [OkHttp](square/okhttp: Square’s meticulous HTTP client for the JVM, Android, and GraalVM. (github.com)),iOS 端可以桥接 [AFNetworking](AFNetworking/AFNetworking: A delightful networking framework for iOS, macOS, watchOS, and tvOS. (github.com))。当然,如果项目中有基于系统或第三方库 API 进行二次开发的网络能力,应当桥接二次开发后的 API。

例如,淘宝客户端内部的 ANetwork 网络框架等等……

以 OkHttp(4.0 以上版本)的基本使用为例,Android 端的 actual 实现可以参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
actual fun commonHttpRequest(
method: HttpMethod,
url: String,
headers: Map<String, Any?>?,
params: Map<String, Any>?,
bodyType: HttpPostBodyTypes = HttpPostBodyTypes.URL_PARAMS,
succeedCallback: (resHeaders: Map<String, Any>?, body: String?) -> Unit,
failedCallback: (errCode: Int, errMsg: String?) -> Unit
) {
val request = Request.Builder().apply {
val httpUrl = url.toHttpUrlOrNull() ?: return@apply
headers?.keys?.forEach { key ->
val value = headers[key] ?: return@forEach
header(key, value.toString())
}
if (method == HttpMethod.POST) {
val reqBodyBuilder = FormBody.Builder()
params?.keys?.forEach { key ->
val value = params[key] ?: return@forEach
reqBodyBuilder.addEncoded(key, value)
}
method("POST", reqBodyBuilder.build())
url(httpUrl)
} else {
val urlBuilder = httpUrl.newBuilder().apply {
params?.keys?.forEach { key ->
val value = params[key] ?: return@forEach
addEncodedQueryParameter(key, value)
}
}
url(urlBuilder.build())
}
}.build()
val call = okHttpClient.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
failedCallback(e.message)
}

override fun onResponse(call: Call, response: Response) {
try {
if (response.code == 200) {
succeedCallback(response.headers.toMap(), response.body?.string() ?: "")
response.body?.closeQuietly()
} else {
failedCallback.invoke(ERR_CODE_FAILED, "Response is not 200!")
}
} catch (e: Exception) {
e.printStackTrace()
failedCallback.invoke(ERR_CODE_FAILED, "Response is not 200!")
}
}
})
}

使用 URLSession 的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
actual fun commonHttpRequest(
method: HttpMethod,
url: String,
headers: Map<String, Any?>?,
params: Map<String, Any>?,
bodyType: HttpPostBodyTypes,
succeedCallback: (resHeaders: Map<String, Any>?, body: String?) -> Unit,
failedCallback: (errCode: Int, errMsg: String?) -> Unit
) {
// 伪代码,不保证能运行
val req = NSMutableURLRequest.requestWithURL(NSURL.URLWithString(url)!!)
req.setHTTPMethod(if (method == HttpMethod.GET) "GET" else "POST")
req.setAllHTTPHeaderFields(headers as Map<Any?, *>)
val session = NSURLSession.sharedSession
session.dataTaskWithRequest(req) { data, res, err ->
// handle response
}
}

总结

由于网络请求是业务逻辑代码中使用非常频繁的功能,所以在 KMM 中,建设一套适合项目使用的网络能力尤为重要,需要根据项目实际情况选择合理的实现方案,以便实现网络请求开发的效率最大化。