关于 kmp-ktor
面向 Kotlin Multiplatform 和 Android 项目的 Ktor 客户端配置参考技能。内容聚焦插件顺序、令牌刷新、序列化和测试等易出错细节,而非客户端基础设置。
主要功能
- 01Ktor 插件安装顺序
- 02序列化默认值配置
- 03Bearer 令牌刷新处理
- 04SSE 与 WebSocket 转换器
- 05错误映射与 MockEngine 测试
适用场景
- 01排查超时请求未重试的问题
- 02避免刷新令牌请求陷入循环
- 03测试共享的 Ktor 客户端配置
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill kmp-ktor安装后在 Claude Code 中输入/kmp-ktor确认已加载。
详细介绍
Ktor Client for KMP and Android
本文介绍 Ktor 客户端配置中容易踩的坑——插件安装顺序、序列化标志、身份验证刷新和错误映射;不涉及共享 HttpClient、引擎选择或 ContentNegotiation 设置的基础知识。相关内容: android-skills:android-data-layer(仓库模式及 DataError 错误模型——其规范归属位置)、android-skills:android-retrofit(仅适用于 Android 的对应方案)。
插件安装顺序——HttpRequestRetry 要在 HttpTimeout 之前
最常见的错误安装顺序是:先安装 HttpTimeout,再安装 HttpRequestRetry。对于发出的请求,插件会按安装顺序运行;重试必须能捕获超时错误,因此重试插件必须包裹超时插件。
val json = Json { ignoreUnknownKeys = true; coerceInputValues = true; encodeDefaults = true } // encodeDefaults: see the section below
HttpClient(engine) {
install(ContentNegotiation) { json(json) }
install(Auth) { bearer { /* loadTokens / refreshTokens */ } }
install(HttpRequestRetry) { // BEFORE HttpTimeout
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
install(HttpTimeout) { // AFTER HttpRequestRetry
requestTimeoutMillis = 30_000; connectTimeoutMillis = 15_000; socketTimeoutMillis = 15_000
}
}
如果顺序反过来,HttpTimeout 会在重试插件看到请求之前将其判定为失败,因此超时永远不会重试。(另外,Auth 插件会独立处理 401 刷新——让 HttpRequestRetry 处理瞬时错误和 5xx 错误;不要让两者对同一个状态码进行链式处理。)
encodeDefaults = true——否则协议常量字段会悄然消失
kotlinx.serialization 默认使用 encodeDefaults = false,会从序列化输出中剔除所有值等于其声明默认值的属性。jsonrpc: String = "2.0"(或 version = "1.0"、type = "...")会从负载中消失;服务端会以通用的“invalid request”拒绝每个请求,而修复方法只是添加一行标志——但往往要花数小时排查 HTTP 层的误导性线索后才能找到。客户端 API 始终应启用此选项——本文件顶部定义的 val json 就启用了它,同时还设置了 ignoreUnknownKeys 和 coerceInputValues。整个客户端共享的就是这个已配置实例:install(ContentNegotiation) { json(json) } 和 WebSocket 转换器都会使用它。
expectSuccess——始终一致地选择一种模式
expectSuccess = true 会使 Ktor 在收到非 2xx 响应时抛出 ClientRequestException(4xx)或 ServerResponseException(5xx)——而且该异常会在任何手动状态检查之前抛出,因此之后的 if (response.status == OK) 分支永远不会执行。应在整个项目中统一采用一种模式:expectSuccess = true + try/catch(符合仓库模式),或 expectSuccess = false + 显式检查 response.status.isSuccess()。切勿混用。
Bearer 刷新——使用 markAsRefreshTokenRequest(),否则会陷入循环
在 Auth 的 bearer { refreshTokens { … } } 代码块中,使用 markAsRefreshTokenRequest() 标记刷新 POST 请求,使其不会被同一个 Auth 插件拦截——如果不标记,刷新失败会触发另一次刷新,导致无限循环。它是一个 HttpRequestBuilder 扩展函数:要在请求构建器代码块内调用,不能直接放在 refreshTokens { } 中(否则无法编译)。
install(Auth) {
bearer {
loadTokens { tokenStorage.getTokens()?.let { BearerTokens(it.access, it.refresh) } }
refreshTokens {
val refresh = oldTokens?.refreshToken ?: return@refreshTokens null
val r = client.post("auth/refresh") {
markAsRefreshTokenRequest() // skip the Auth plugin for this call
setBody(RefreshRequestDto(refresh))
}.body<TokenResponseDto>()
tokenStorage.save(r.accessToken, r.refreshToken); BearerTokens(r.accessToken, r.refreshToken)
}
sendWithoutRequest { it.url.pathSegments.none { seg -> seg in listOf("login", "register") } }
}
}
在插件边界处使用 BearerTokens;应用其余部分使用自定义的令牌类型。TokenStorage 由项目定义(Android/JVM 上使用 DataStore,iOS 上使用 Keychain)。
WebSocket 和 SSE——使用序列化转换器
对于实时传输,安装 kotlinx-serialization 转换器,让类型化消息使用与 ContentNegotiation 相同的 Json 配置;否则就得手动对 Frame.Text 进行编码和解码。(SSE = 仅服务端到客户端,使用普通 HTTP,内置重连;WebSocket = 双向通信,需手动重连,支持二进制帧——如果客户端只负责接收,默认使用 SSE。)
val client = HttpClient(engine) {
install(WebSockets) {
pingIntervalMillis = 30_000
contentConverter = KotlinxWebsocketSerializationConverter(json) // the shared configured instance — bare `Json` reverts to encodeDefaults = false
}
install(SSE)
}
client.webSocket("wss://api.example.com/ws") {
sendSerialized(SubscribeMessage(topic = "items"))
while (true) { val msg = receiveDeserialized<ServerMessage>(); /* handle */ }
}
// SSE — incoming is a Flow<ServerSentEvent>
client.sse("https://api.example.com/events") { incoming.collect { event -> /* event.event / event.data / event.id */ } }
将 SSE/WebSocket 收集操作封装在 LaunchedEffect 或仓库协程中,这样消费者离开时,取消操作会关闭 HTTP 连接。
错误映射与测试
在仓库层捕获特定的 Ktor 类型(ClientRequestException / ServerResponseException / HttpRequestTimeoutException / IOException),并映射到 DataError——catch (e: Exception) 会吞掉 CancellationException。完整的仓库模式和 DataError 分类体系位于 android-skills:android-data-layer。对于更丰富的逐错误 UI 状态(Unauthorized、RateLimited、Forbidden 等),另一种方案是使用密封类型 ApiResult<T>,并配合 expectSuccess = false 的 safeRequest 包装器——每个项目选择一种即可。
注入 HttpClientEngine,让测试能够切换到 MockEngine,并复用生产环境的 createHttpClient 工厂,以确保插件配置一致:
val mockEngine = MockEngine { request ->
assertEquals("/users/42", request.url.encodedPath)
respond("""{"id":"42","name":"Ada","created_at":1700000000000}""",
HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
}
val repo = UserRepository(UserService(createHttpClient(mockEngine, baseUrl = "https://api.example.com/")))