• 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")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

    // build.gradle.kts
    
    androidMain {
        dependencies {
            implementation("io.ktor:ktor-client-android:$ktor_version")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

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

    // build.gradle.kts
    
    androidMain {
        dependencies {
            implementation("io.ktor:ktor-client-okhttp:$ktor_version")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    另外,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")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    由于 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")
    }
    
    • 1
    • 2
    • 3
    • 4

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

    kotlin.native.binary.memoryModel=experimental
    
    • 1

    创建 Ktor 的 HttpClient

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

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

    val httpClient by lazy { HttpClient() }
    
    • 1

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

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

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

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

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

    actual val httpClient by lazy {
        HttpClient(OkHttp) {
            engine {
                config {
                    // 禁止重定向
                    followRedirects(false)
                }
    
                // 加入 Stetho 方便 Debug
                addNetworkInterceptor(StethoInterceptor())
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

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

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

    完成 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")
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    这里需要注意的是,由于 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()
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

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

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

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

    由于我们还没有处理网络请求的响应,所以需要使用 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")
            // ...
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    如果只是给某一个请求添加自定义的配置,只需要在 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))
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

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

    处理响应

    和常见的 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()
    
    • 1
    • 2
    • 获取 ByteArray 类型(二进制)的 Body
    val httpResponse: HttpResponse = client.get("https://ktor.io/")
    val byteArrayBody: ByteArray = httpResponse.body()
    
    • 1
    • 2

    进行类型自动转换

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

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

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

    其次需要添加 Kotlinx.serialization 依赖

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

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

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

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

    熟悉 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,
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    val httpResponse: HttpResponse = client.get("https://api.xxx.com/student?id=xxx")
    val xxx: Student = httpResponse.body()
    println(xxx.name)  // 张三
    
    • 1
    • 2
    • 3

    流式数据

    如果需要下载文件,择需要用到流式数据的形式,来处理 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}")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

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

    /**
     * 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
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    如上面的代码片段所示,可以在 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!")
                }
            }
        })
    }
    
    • 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

    使用 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
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    总结

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

  • 相关阅读:
    前端工作总结142-element上传组件时候的钩子--event里面有数据参数
    MCS:多元随机变量——多项式分布
    《docker高级篇(大厂进阶):3.Docker微服务实战》
    golang面试题:json包变量不加tag会怎么样?
    自己整理的前端开发面试题
    面对 HR 的空窗期提问,你会如何回答?
    【ARM 嵌入式 编译 Makefile 系列 15.1 -- arm gcc 与 Makefile 一块使用示例】
    图书推荐管理系统Python+Django网页界面+协同过滤推荐算法
    Linux企业应用——Docker(三)之Docker仓库、Docker hub官方仓库的使用
    ApeWorX: 新的基于 Python 语言的智能合约开发框架
  • 原文地址:https://blog.csdn.net/yuanguozhengjust/article/details/126005666