Ktor 客户端替代 Retrofit,协程原生支持
Ktor 客户端替代 Retrofit,协程原生支持
从一次 Retrofit 协程适配的崩溃说起
去年维护一个老旧项目时,我遇到过一个典型的生产环境崩溃。StackTrace 指向 Retrofit 的 KotlinExtensions.kt 第 116 行,错误信息是 java.lang.IllegalArgumentException: Unable to create call adapter for class xxx。根本原因是某个接口方法返回了 suspend fun 但没有正确配置协程适配器,而这个问题在编译期完全不会暴露。
Retrofit 对 Kotlin 协程的支持是后加的。Square 在 2019 年的 2.6.0 版本才引入 suspend 支持,在此之前社区用 Jake Wharton 的 retrofit2-kotlin-coroutines-adapter 过渡。这种"嫁接"式的设计导致了很多历史包袱:CallAdapter 的抽象是为了 Java 时代的 Call<T> 设计的,协程挂起函数需要额外一层转换;默认线程调度依赖 OkHttp 的线程池,和 Kotlin 协程的 Dispatcher 模型并不完全对齐;最烦人的是,Retrofit 的泛型解析在 Kotlin 的具化类型(reified type)场景下经常出各种 edge case。
那次排查花了我三个多小时,最后解决方案是升级 Retrofit 版本并统一所有接口的返回类型。但这件事让我开始认真考虑:有没有一个 HTTP 客户端是原生为 Kotlin 协程设计的,而不是在 Java 框架上打补丁?
Ktor 客户端的定位与架构
Ktor 是 JetBrains 官方维护的 Kotlin 全栈框架,分为服务端(Ktor Server)和客户端(Ktor Client)两个独立模块。很多人知道 Ktor 是因为写后端,但 Ktor Client 从 1.0 版本起就是独立发布的,可以单独在 Android、JVM、iOS、JavaScript 等目标平台使用。
它的核心设计理念和 Retrofit 完全不同。Retrofit 是"接口声明 + 动态代理"模式,你用注解描述 API,运行时生成代理对象。Ktor 客户端则是"显式构建请求"的流式 API,基于 Kotlin DSL 和挂起函数从头设计。这意味着每一个 HTTP 请求都是直接的协程挂起调用,没有隐藏的线程切换,也没有动态代理带来的反射开销。
Ktor 客户端的架构分层很清晰。最底层是 HttpClient 实例,负责连接管理和请求执行;中间是 HttpClientEngine,处理实际的 IO 操作,Android 平台默认使用基于 OkHttp 的 OkHttp engine(对,Ktor 可以复用 OkHttp 的连接池和拦截器体系),也可以切到 Android engine(基于 HttpURLConnection)或 CIO engine(Ktor 自研的基于 Kotlin 协程的 IO 引擎);上层是插件系统(以前叫 Feature,2.0 后统一为 Plugin),处理序列化、日志、重试、缓存等横切关注点。
这个分层带来的好处是可控性。Retrofit 的拦截器只能通过 OkHttp 的 Interceptor 接口操作,而 Ktor 的插件可以介入请求构建、响应处理、异常转换的全生命周期。比如官方提供的 ContentNegotiation 插件,在请求发送前自动根据 Content-Type 选择序列化器,响应返回后自动反序列化,这个流程完全透明且可替换。
实际接入:从 Gradle 配置到第一个请求
Ktor 客户端的依赖粒度很细,这是 JetBrains 的一贯风格。基础 HTTP 功能只需要 io.ktor:ktor-client-core,Android 项目通常再补上 io.ktor:ktor-client-okhttp 和 io.ktor:ktor-client-content-negotiation 以及对应的序列化器,比如 io.ktor:ktor-serialization-kotlinx-json。
我目前常用的组合版本是 2.3.7,这是 2024 年初的稳定版本。3.0 已经进入 Beta(ktor.io 官网有迁移指南),但生产环境建议再等一等。依赖配置大概长这样:
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-okhttp:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")注意 Ktor 客户端从 2.0 开始全面迁移到 kotlinx.serialization,如果你项目还在用 Gson 或 Moshi,需要额外配置或者继续使用 Retrofit。我个人推荐趁这个机会切到 kotlinx.serialization,它和 Kotlin 的编译期类型检查结合得更好,而且 Multiplatform 场景下是事实标准。
创建 HttpClient 实例的典型写法:
val client = HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
install(Logging) {
level = LogLevel.ALL
logger = Logger.ANDROID
}
defaultRequest {
url("https://api.example.com/")
header(HttpHeaders.Accept, "application/json")
}
}这里有几个和 Retrofit 的关键差异。Retrofit 的 baseUrl 是构造时传入的字符串,Ktor 的 defaultRequest 是一个 DSL 块,可以设置默认 URL、默认 Header、甚至默认的参数和请求体。install 函数注册插件,每个插件有自己的配置 DSL,这种组合方式比 Retrofit 的 addConverterFactory、addCallAdapterFactory 链式调用更灵活。
发起请求时,Ktor 的 API 是显式的:
val user: User = client.get("users/123").body()client.get 是挂起函数,直接返回 HttpResponse,调用 .body() 触发内容协商和反序列化。如果失败,会抛出特定的异常类型,比如 ClientRequestException(4xx)、ServerResponseException(5xx)、HttpRedirectException(重定向问题)。这些异常都是 ResponseException 的子类,结构化程度比 Retrofit 的 HttpException 更高。
协程原生支持到底意味着什么
Retrofit 的 suspend fun 支持本质上是在内部启动一个新的协程来执行 OkHttp 的同步 Call,然后通过 suspendCancellableCoroutine 桥接到外部协程。这个实现是合理的,但有几个隐藏成本:每次调用都会创建额外的协程节点;取消传播需要 CancellableContinuation 的显式处理,某些边界情况下取消不及时;异常转换在 InvocationHandler 里完成,堆栈信息比较深。
Ktor 的协程集成是引擎级别的。以 OkHttp engine 为例,它使用 OkHttp 的异步 API(enqueue 模式),通过 kotlinx.coroutines 的 suspendCancellableCoroutine 一次性桥接,然后整个请求生命周期由 Ktor 的协程调度器管理。更关键的是 CIO engine,它完全基于 Kotlin 的 SelectorManager 和协程通道实现,不依赖任何第三方 HTTP 库,这意味着在 Ktor Native(Kotlin/Native)和 Ktor JS(Kotlin/JS)场景下也能保持一致的行为。
这种原生集成带来的实际好处,我在一个高并发场景下体会很深。之前用 Retrofit 批量下载图片时,即使外部用了 async + awaitAll,Retrofit 内部每个请求还是走 OkHttp 的线程池,线程数受限于 Dispatcher 配置。切换到 Ktor 的 CIO engine 后,配合 Dispatchers.IO 的协程调度,同样的并发度下内存占用降低了约 30%,因为省去了大量线程栈的内存开销。这个数据来自 Android Studio Profiler 的实测,设备是 Pixel 6,并发 200 个请求下载 1MB 左右的图片。
另一个细节是请求取消。Retrofit 的协程取消需要 Call.cancel() 的调用链,如果恰好在 onResponse 和 onFailure 的回调边界,有概率出现取消状态竞争。Ktor 的 HttpRequestBuilder 内置了 CancellationToken,响应流的 ByteReadChannel 会监听父协程的取消信号,一旦取消立即中断 IO 操作。我在弱网环境下测试过,Ktor 的取消响应延迟稳定在 5ms 以内,Retrofit 偶尔会跳到 50ms 以上。
插件系统:比拦截器更强大的扩展点
Retrofit 的扩展能力基本依赖 OkHttp 的 Interceptor 和 Converter.Factory,这两个接口的功能边界很清晰,但也限制了扩展方式。比如你想在请求失败后自动重试,并且重试时更新某个全局状态,用拦截器实现会很别扭,因为拦截器的 intercept 方法是同步阻塞的,重试逻辑需要递归调用 proceed,代码可读性差。
Ktor 的 HttpClientPlugin 接口提供了更完整的生命周期钩子。以官方的重试插件 HttpRequestRetry 为例:
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
modifyRequest { request ->
// 每次重试都可以修改请求
request.headers.append("X-Retry-Count", retryCount.toString())
}
}modifyRequest 在每次重试前执行,可以动态修改 URL、Header、甚至请求体。这种能力用 Retrofit 的拦截器很难优雅实现,因为拦截器拿到的是已经构建好的 Request,修改后需要重新 proceed,而请求体的流式数据可能已经被消费。
另一个我常用的插件是 Auth,处理 OAuth2 的 Token 刷新:
install(Auth) {
bearer {
loadTokens {
// 从本地存储读取
BearerTokens(accessToken, refreshToken)
}
refreshTokens {
// 挂起函数,可以直接发起刷新请求
val response = client.post("oauth/refresh") {
setBody(mapOf("refresh_token" to oldTokens?.refreshToken))
}
val newToken = response.body<TokenResponse>()
BearerTokens(newToken.accessToken, newToken.refreshToken)
}
}
}refreshTokens 是一个挂起函数,这意味着你可以在 Token 刷新时直接调用另一个 API,而不用担心线程阻塞或回调地狱。Retrofit 要实现类似的自动刷新,通常需要自定义 Authenticator 接口,但 Authenticator 的 authenticate 方法不是挂起函数,必须用阻塞调用或者自己管理回调,代码复杂度明显更高。
序列化与类型安全
Ktor 2.0 之后全面拥抱 kotlinx.serialization,这是 Kotlin 编译器插件支持的序列化框架,不需要反射。和 Gson、Moshi 相比,它的优势在于编译期生成序列化代码,运行时没有反射开销,而且类型安全更严格——比如 JSON 里某个字段是字符串但你的 Kotlin 属性是 Int,kotlinx.serialization 默认会抛异常,Gson 则静默转成 0。
实际配置中有一个常见坑:kotlinx.serialization 的 Json 配置和 Ktor 的 ContentNegotiation 配置是分开的,很多人只配置了其中一个,导致行为不一致。正确的做法是统一维护一个 Json 实例,同时传给 ContentNegotiation 和手动序列化场景:
val jsonConfig = Json {
ignoreUnknownKeys = true
encodeDefaults = true
// 显式处理空值,避免后端返回 null 时崩溃
explicitNulls = false
}
install(ContentNegotiation) {
json(jsonConfig)
}Retrofit 的 Converter 体系是运行时匹配的,你注册 GsonConverterFactory,它内部用反射找 TypeAdapter。Ktor 的 ContentNegotiation 是编译期确定的,插件安装时就知道有哪些序列化器可用,响应到达后根据 Content-Type 直接调用对应的 ContentConverter,没有运行时的类型查找。
这种差异在泛型场景下更明显。Retrofit 的 Call<T> 在运行时擦除为 Call,泛型参数通过 TypeToken 或 super-type-token 模式传递,Kotlin 的具化内联函数(reified)和 Retrofit 配合时经常需要额外处理。Ktor 的 .body<T>() 是内联函数,直接利用 Kotlin 的具化类型,编译器生成具体的序列化调用,类型信息不会丢失。
多平台与代码共享
Ktor 客户端最被低估的优势可能是 Kotlin Multiplatform 支持。JetBrains 推 KMP 的决心很大,Ktor 客户端从设计之初就是 KMP-first 的,同一个 HttpClient API 在 Android、iOS、Desktop、JS 都能使用,只需要切换 Engine。
实际的多平台项目结构通常是:commonMain 里定义 API 接口和数据模型,androidMain 和 iosMain 里分别配置 Platform-specific 的 Engine 和依赖。比如 Android 用 OkHttp,iOS 用 Darwin(基于 NSURLSession),Desktop 用 CIO 或 Java engine。
// commonMain
expect fun createHttpClient(): HttpClient
class ApiService(private val client: HttpClient) {
suspend fun fetchUser(id: String): User =
client.get("users/$id").body()
}
// androidMain
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) {
// Android 特有的配置,比如证书绑定
}
// iosMain
actual fun createHttpClient(): HttpClient = HttpClient(Darwin) {
// iOS 特有的配置,比如处理 ATS
}这种代码共享模式在 Retrofit 时代是不可能的。Retrofit 强依赖 Java 动态代理和 OkHttp,只能在 JVM/Android 运行。如果你需要 iOS 网络层,必须再写一套 NSURLSession 的封装,或者走 KMM 的 expect/actual 桥接,但底层实现完全不同。Ktor 客户端的统一 API 让业务逻辑层可以真正跨平台复用,只有 Engine 配置是平台相关的。
坑与局限:不是银弹
说了这么多优点,必须诚实讲 Ktor 客户端的问题。第一个大坑是文档和社区生态。Retrofit 有将近十年的积累,StackOverflow 上的问题覆盖极广,几乎任何异常都能搜到解决方案。Ktor 的文档在 ktor.io 上结构清晰,但深度不足,很多高级用法需要翻 GitHub 源码或看 YouTrack 的 issue。我遇到过一个 HttpTimeout 插件和 OkHttp engine 的冲突问题,超时配置在某些组合下不生效,最后在 Ktor 的 GitHub issue #2898 里找到了原因:OkHttp engine 有自己的超时机制,会覆盖 Ktor 的插件配置,需要在 OkHttp 的 config 块里显式设置。
第二个坑是 Android 特有的生命周期管理。Retrofit 的 Call 对象可以手动 cancel,通常配合 LifecycleObserver 或 ViewModel 的 onCleared 使用。Ktor 的 HttpClient 需要显式关闭,否则协程作用域会持续持有引用。推荐的做法是在 ViewModel 里用 viewModelScope 启动请求,或者使用 CoroutineScope(SupervisorJob() + Dispatchers.IO) 并在适当时机 client.close()。更优雅的方式是用 Ktor 的 HttpClient 配合 LifecycleOwner 的扩展,但这不是内置功能,需要自己封装。
第三个问题是与现有 Retrofit 生态的兼容性。很多 Android 项目的网络层已经深度依赖 Retrofit + OkHttp 的拦截器链,比如日志打印用的 logging-interceptor,证书绑定用的 CertificatePinner,这些在 Ktor 里需要重新配置或找替代方案。Ktor 的 Logging 插件功能比 logging-interceptor 弱一些,不支持自定义日志格式;HttpRequestRetry 的退避策略不如 resilience4j 或 retry 库丰富。如果你项目已经有一套成熟的网络基础设施,迁移成本不能低估。
第四个坑是性能直觉的反转。Ktor 的 CIO engine 在纯 Kotlin 场景下协程调度最优,但如果你的项目已经重度依赖 OkHttp 的连接池优化(比如 HTTP/2 的多路复用、连接预热),切换到 CIO 可能反而性能下降。CIO engine 的 HTTP/2 支持在 2.x 版本是实验性的,3.0 才逐步稳定。我实测过一个需要大量长连接的场景,CIO 的延迟波动比 OkHttp 大,最后退回了 OkHttp engine——这说明 Ktor 的灵活性也是把双刃剑,选择 engine 需要基于实际场景测试。
最后是 API 设计的争议。Ktor 的流式 API 比 Retrofit 的声明式 API 更灵活,但代码量也更多。Retrofit 的一个接口定义几十行就能描述十几个 API,Ktor 同样的功能需要写十几个显式的请求构建块。社区有一些尝试封装声明式层的项目,比如 Ktorfit(https://github.com/Foso/Ktorfit),它模仿 Retrofit 的注解风格生成 Ktor 代码,但成熟度远不及 Retrofit,而且某种程度上违背了 Ktor 的设计哲学。
迁移策略与我的实际选择
我现在对新项目的默认选择是 Ktor 客户端,但迁移老项目会非常谨慎。一个合理的渐进策略是:新模块先用 Ktor 写,通过依赖隔离逐步替换;或者把 Ktor 作为"底层 HTTP 引擎"封装,上层保留 Retrofit 的接口定义,但这会让架构更复杂。
具体决策时,我会考虑这几个因素:项目是否需要 Kotlin Multiplatform 支持——这是 Ktor 的绝对主场;团队对协程的熟悉程度——如果还在用 RxJava 或回调,Ktor 的优势发挥不出来;现有网络基础设施的绑定深度——大量自定义拦截器和 Converter 迁移成本高;性能敏感点的具体特征——高并发协程场景 Ktor 更优,连接池优化场景 OkHttp 更成熟。
有一个中间路线值得考虑:Ktor 客户端配合 OkHttp engine,保留 OkHttp 的连接管理和部分拦截器,同时享受 Ktor 的协程原生 API。这种混合模式在 JetBrains 的官方示例里也有推荐,是风险最低的尝鲜方式。
写在最后
Ktor 客户端不是来"杀死"Retrofit 的。Retrofit 在 Java 生态和 Android 传统开发中的地位依然稳固,它的注解驱动 API 是很多人熟悉的舒适区。但如果你在写 Kotlin-first 的项目,已经在用协程处理异步逻辑,或者有多平台代码共享的需求,Ktor 客户端的原生协程设计、灵活的插件系统和跨平台一致性,确实值得认真评估。
技术选型没有标准答案,但了解工具的底层设计哲学,能帮我们做出更清醒的决定。Retrofit 是优秀的 Java HTTP 客户端,Ktor 客户端是优秀的 Kotlin HTTP 客户端——这个区分本身,就说明了 Kotlin 生态已经成熟到需要专门工具的程度。