3. Ktor Client核心机制
在 KMP 的数据层里,Ktor Client 是最常用的 HTTP 客户端方案。它真正的价值不在于“能发请求”,而在于把引擎(Engine)与客户端(HttpClient)分离:HttpClient 负责统一的配置、插件、请求构建与响应处理,Engine 只负责把请求真正交给某个平台的网络栈。理解这条分界线,才能写出既跨平台又可控的数据层。
一、引擎抽象:HttpClient 与 Engine 的职责切分
Ktor Client 的抽象可以概括为“一个客户端,多个引擎”。HttpClient 是面向业务的门面,它持有 HttpClientEngine 实例,并维护插件管线(Pipeline)。当调用 client.get(...) 时,请求并不会直接进入 socket,而是先经过客户端侧的插件阶段,再交给引擎执行,最后回到客户端侧完成响应转换。
引擎的接口非常薄,核心是 execute(requestData): HttpResponseData。它只关心:用什么协议、连到哪个地址、如何把字节读回来。因此 OkHttp、Darwin、Curl、CIO、Android、Java 等引擎可以互换,而业务代码几乎不变。这正是 KMP 数据层“一次编写、多端运行”的基础。
需要特别区分两个概念:引擎决定底层网络实现,插件决定横切行为。日志、重试、序列化、认证、超时都属于插件,它们挂在 HttpClient 上,与具体引擎无关。把重试逻辑写进引擎是错误的分层,会导致多平台行为不一致。
二、HttpClient 配置:把横切关注点集中管理
HttpClient 的配置通常在数据层的网络模块中集中完成,而不是散落在各个 Repository。一个典型的配置包含引擎选择、基础地址、超时、内容协商、日志与默认请求头。下面是一个教学示例(参数为示例值,非实测结果):
val client = HttpClient {
// 引擎由平台源集提供,这里使用默认引擎
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 8_000
socketTimeoutMillis = 15_000
}
install(Logging) {
level = LogLevel.INFO
}
defaultRequest {
url("https://api.example.com/")
header(HttpHeaders.Accept, ContentType.Application.Json)
}
expectSuccess = true
}
几个关键点值得强调。expectSuccess = true 会让非 2xx 响应直接抛出 ResponseException,避免每个调用点都手写状态码判断;如果业务需要自行处理 4xx,则应保持默认并显式检查 response.status。defaultRequest 适合放基础 URL 和公共头,但不要放与用户会话强相关的 token,因为 token 会随登录状态变化,更适合用插件或每次请求动态注入。
超时参数要区分三类:requestTimeoutMillis 覆盖整个请求生命周期,connectTimeoutMillis 只约束建连,socketTimeoutMillis 约束两次数据包之间的等待。移动网络下建连慢、传输抖动大,通常把 connect 设得比 socket 短,让“连不上”快速失败,而“连上了但慢”给更长时间。
三、请求与响应生命周期
一次请求从调用到拿到结果,会经历清晰的阶段。下图展示了客户端侧与引擎侧的协作关系,以及插件在其中的插入位置。
从图中可以看到,引擎只接收不可变的 HttpRequestData,返回 HttpResponseData。这意味着引擎不感知业务语义,也不应该做重试或鉴权。所有与业务相关的判断都留在客户端侧,保证多平台行为一致。
响应处理时,response.body<T>() 会触发反序列化。如果服务端返回的 JSON 字段多于数据类字段,ignoreUnknownKeys = true 可以避免解析失败;如果字段缺失且数据类没有默认值,则会抛出 MissingFieldException。这类异常属于数据契约问题,应在数据层捕获并转换为业务可识别的错误类型,而不是直接抛给 UI。
四、多平台引擎适配
KMP 的源集结构决定了引擎的引入方式。通常把 HttpClient 的创建声明为 expect 函数,在各平台源集中用 actual 提供具体引擎。这样公共代码只依赖抽象,平台代码只负责选择引擎。
// commonMain
expect fun createHttpClient(): HttpClient
// androidMain
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) { /* 公共配置 */ }
// iosMain
actual fun createHttpClient(): HttpClient = HttpClient(Darwin) { /* 公共配置 */ }
// desktopMain
actual fun createHttpClient(): HttpClient = HttpClient(CIO) { /* 公共配置 */ }
为了避免公共配置重复,更推荐把配置抽成 HttpClientConfig<*>.() -> Unit 扩展函数,各平台只传入引擎:
fun HttpClientConfig<*>.applyCommon() {
install(ContentNegotiation) { json(...) }
install(HttpTimeout) { requestTimeoutMillis = 15_000 }
expectSuccess = true
}
// androidMain
actual fun createHttpClient() = HttpClient(OkHttp) { applyCommon() }
引擎选择需要结合平台特性。Android 上 OkHttp 成熟、支持连接池与拦截器,是常见选择;iOS 上 Darwin 直接使用 NSURLSession,能复用系统代理与证书策略;桌面端 CIO 是纯 Kotlin 实现,无需额外依赖;如果需要在多平台间保持完全一致的底层行为,也可以统一使用 CIO,但会失去部分平台原生能力(如系统代理自动配置)。
createHttpClient() 一处。五、实践中的判断依据
配置完成后,如何判断数据层是否健康?可以从三个观察点入手。第一,看超时是否分层:如果所有请求都在 15 秒整失败,说明 requestTimeout 生效但 connectTimeout 可能过长,移动网络下应缩短建连时间。第二,看异常类型:ConnectTimeoutException 指向网络不可达,HttpRequestTimeoutException 指向服务端处理慢,ResponseException 指向业务状态码,三者处理策略不同。第三,看日志中请求与响应的配对:如果只有请求没有响应,通常是引擎层阻塞或连接未释放。
把这些判断固化到数据层的错误映射中,UI 层就只需要面对“网络不可用、服务端错误、数据格式错误”这几类稳定语义,而不必关心底层是 OkHttp 还是 Darwin。这正是 Ktor Client 引擎抽象在 KMP 工程中最大的收益。