KMM 入门(七)处理 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 且实现多线程版本的协程库,所以还需要加入对协程的依赖

// 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)的依赖

// build.gradle.kts

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

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

// 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 上有多种选择

// build.gradle.kts

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

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

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 文件中,添加以下的配置项

kotlin.native.binary.memoryModel=experimental

创建 Ktor 的 HttpClient

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

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

val httpClient by lazy { HttpClient() }

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

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

HttpClient {
    engine {
        proxy = ProxyBuilder.http("http://127.0.0.1:8888")
        threadsCount = 4
    }
    defaultRequest {
        // 可配置公共的 Cookies、Headers、Params
    }
}

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

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

actual val httpClient by lazy {
    HttpClient(OkHttp) {
        engine {
            config {
                // 禁止重定向
                followRedirects(false)
            }

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

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

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:
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 方法的参数传入,如下面代码所示:

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 操作

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 实例的发送的网络请求,保持统一配置

HttpClient {
    defaultRequest {
        header("CommonHeader", "KMM")
        parameter("CommonParam", "666")
        cookie("USER_ID", "123456")
        // ...
    }
}

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

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 请求

KMM 入门(七)处理 HTTP 网络请求_第1张图片

处理响应

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

  • 原始响应 Body:

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

  • JSON 对象:

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

  • 流式数据:

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

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

获取原始类型

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

进行类型自动转换

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

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

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

其次需要添加 Kotlinx.serialization 依赖

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

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

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

val client = HttpClient() {
    install(ContentNegotiation) {
        json()
    }
}

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

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

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

流式数据

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

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 Server,Chat 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 去做逻辑统一。

/**
 * 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 实现可以参考下面的代码:

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 的示例代码:

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 中,建设一套适合项目使用的网络能力尤为重要,需要根据项目实际情况选择合理的实现方案,以便实现网络请求开发的效率最大化。

你可能感兴趣的:(Android,网络,http,android,kmm,kotlin)