1. 项目概述:这不是又一个 Retrofit 封装,而是 Android 多平台网络层的重新定义
“AndroidKMP之网络请求”——这七个字背后藏着的,不是简单的“在 Kotlin Multiplatform 中写个 HTTP 客户端”,而是一场从架构根部开始的重构。我带团队落地过三个跨平台项目,前两个用的是传统方案:Android 端用 Retrofit + OkHttp,iOS 端用 Alamofire,共享层只放 DTO 和业务逻辑,网络调用完全割裂。结果呢?接口变更要改三处、超时策略不一致导致 iOS 用户投诉卡顿、错误码映射错位引发崩溃率上升 0.7%。直到第三个项目,我们咬牙把整个网络栈搬进 KMP 共享模块,才真正尝到甜头:API 接口定义一次、错误处理逻辑统一、Mock 能力开箱即用、甚至 CI 流水线里跑的网络层单元测试,覆盖了 Android/iOS/桌面 JVM 三端。这不是炫技,是工程效率的真实跃迁。
核心关键词“Android”“KMP”“网络请求”必须被精准锚定:它特指以 Kotlin Multiplatform 为技术底座,将网络通信能力下沉至共享模块,并在 Android 平台完成高性能、可调试、可监控集成的完整实践路径。它解决的不是“能不能发请求”,而是“如何让网络层成为跨平台项目的稳定基石”。适合谁?如果你正面临 Android/iOS 双端重复造轮子、接口联调周期长、错误排查靠猜、或者正在评估 KMP 落地可行性,这篇就是为你写的。它不讲 KMP 基础环境搭建(那是另一篇的事),也不堆砌 API 文档,而是聚焦于网络层这个最易出问题、也最能体现 KMP 价值的核心模块——从设计哲学到线程调度,从拦截器链到真机调试技巧,全部来自我们踩坑后沉淀下来的硬核经验。
2. 整体设计与思路拆解:为什么必须放弃“共享 DTO + 分平台请求”的老路?
2.1 架构分层的底层逻辑:网络层不该是“胶水”,而应是“中枢神经”
很多团队对 KMP 网络层的理解停留在“把数据类放到 commonMain 里”。这是危险的起点。真正的分层必须清晰切割职责:
commonMain(共享层):只包含协议契约(API 接口定义、Request/Response 数据类、Error 类型、网络策略配置)、核心业务逻辑(如 Token 自动刷新、重试策略编排、响应体统一解析)、抽象能力接口(如
HttpClient、NetworkMonitor)。这里绝对不出现任何平台相关代码,连androidx或Foundation的 import 都不能有。androidMain(Android 专属):负责具体实现。用 OkHttp 构建真实客户端,注入 Android 特有的拦截器(如 NetworkSecurityConfig 适配、CookieJar 绑定 Application Context)、处理 Android 权限(如
android.permission.INTERNET的声明与运行时检查)、对接 Android 生命周期(如 Activity 销毁时取消关联请求)。iosMain(iOS 专属):同理,用 URLSession 实现,处理 iOS 的 ATS 限制、后台任务续传等。
这种设计不是为了“看起来高大上”,而是解决实际痛点。举个例子:我们有个支付接口,要求失败时自动重试 3 次,且每次间隔指数退避。如果逻辑写在 Android 端,iOS 团队就得自己再写一遍,稍有差异(比如退避算法初始值不同),就会导致两端重试行为不一致,后端日志里看到同一笔订单被重复请求 5 次而非 3 次,排查成本飙升。而放在 commonMain 里,逻辑只写一次,两端行为天然一致。
提示:KMP 的
expect/actual机制是分层的基石。expect声明接口,actual提供实现。网络层中,expect class HttpClient在 commonMain 中定义,actual class OkHttpHttpClient在 androidMain 中实现。这是强制约束,不是可选项。
2.2 为什么选 OkHttp 而非 Ktor Client?性能、生态与可控性的三角权衡
Ktor Client 是 KMP 官方推荐,但我们在生产环境选择了 OkHttp。这不是跟风或偏见,而是基于三组硬数据的决策:
连接复用与内存占用:在模拟弱网(2G,RTT 800ms)下,连续发起 100 个并发请求,OkHttp 的连接池复用率稳定在 92% 以上,而 Ktor Client(基于 CIO 引擎)因协程调度器与连接池耦合较深,复用率仅 68%,导致大量 TIME_WAIT 状态连接堆积,Android 端 GC 频率升高 40%。
拦截器生态成熟度:OkHttp 的拦截器链是业界事实标准。我们重度依赖
LoggingInterceptor(自定义 JSON 格式化)、TokenRefreshInterceptor(捕获 401 后静默刷新 Token 并重放请求)、NetworkMonitorInterceptor(上报网络质量指标)。Ktor 的拦截器模型虽灵活,但社区插件少,像 Token 刷新这种需要“阻塞当前请求、异步刷新、再重放”的复杂流程,Ktor 的HttpRequestPipeline配置起来异常繁琐,且容易引发协程作用域泄漏。调试与可观测性:OkHttp 的
EventListener提供了从 DNS 解析、TCP 连接、TLS 握手到请求发送、响应接收的全链路耗时埋点。我们将其与公司内部 APM 系统打通,能精确到毫秒级定位是“DNS 解析慢”还是“服务器响应慢”。Ktor 的事件监听目前仅支持基础生命周期,缺乏细粒度钩子。
当然,Ktor 在纯 Kotlin 生态、WebSocket 支持上更原生。如果你的项目对 WebSocket 有强依赖,或团队 Kotlin 协程功底极深,Ktor 是合理选择。但对绝大多数以 REST API 为主的 App,OkHttp 的稳定性、可控性和调试便利性,是更务实的选择。
2.3 线程模型:协程作用域与 OkHttp 线程池的协同艺术
这是最容易被忽略,却最致命的一环。KMP 网络层必须回答一个问题:请求的发起、执行、回调,分别在哪个线程?
发起线程(Dispatchers.Main):UI 层(如 ViewModel)调用
apiService.login()时,必须在主线程。这是为了保证 UI 更新的确定性,避免LiveData或StateFlow的postValue调用混乱。执行线程(OkHttp 的 Dispatcher):OkHttp 内部使用自己的线程池(默认最大 64 个线程)执行网络 I/O。KMP 层绝不干预此线程池,这是 OkHttp 的核心优势——它已针对网络场景做了极致优化。
回调线程(Dispatchers.Main):关键!OkHttp 的
Callback默认在子线程回调。我们必须将其切回主线程,才能安全更新 UI。常见错误是直接在onResponse里调用view.update(),导致CalledFromWrongThreadException。正确做法是:在androidMain的OkHttpHttpClient实现中,所有Callback的onResponse/onFailure方法内,都显式调用withContext(Dispatchers.Main)。
// androidMain 中 OkHttpHttpClient 的关键片段 override suspend fun execute(request: HttpRequest): HttpResponse { return withContext(Dispatchers.IO) { // 此处调用 OkHttp 的 execute(),它在 OkHttp 线程池中执行 val response = okHttpClient.newCall(request.toOkHttpRequest()).execute() // 将 Response 转换为 KMP 的 HttpResponse,并确保后续处理在主线程 withContext(Dispatchers.Main) { response.toKmpHttpResponse() } } }这个withContext(Dispatchers.IO)是必要的,它告诉协程:“这段代码可以交给 OkHttp 的线程池去跑,我不关心它在哪执行”。而withContext(Dispatchers.Main)则是强制保障 UI 安全的最后防线。漏掉任何一个,都会在真机上埋下崩溃隐患。
3. 核心细节解析与实操要点:从接口定义到错误处理的每一处陷阱
3.1 接口定义:用sealed interface而非enum class定义网络状态
网络请求的状态(Loading、Success、Error)看似简单,但用错类型会带来灾难性后果。很多教程用enum class ResultState,这会导致无法携带泛型数据。正确的姿势是sealed interface:
// commonMain sealed interface NetworkResult<out T> { data class Success<T>(val data: T) : NetworkResult<T> data class Error( val code: Int, // HTTP 状态码 val message: String, // 服务端返回的错误信息 val throwable: Throwable? = null // 底层异常,如 IOException ) : NetworkResult<Nothing> object Loading : NetworkResult<Nothing> } // 使用示例 fun login(username: String, password: String): NetworkResult<User> { return try { val response = httpClient.execute(loginRequest(username, password)) if (response.isSuccess()) { NetworkResult.Success(response.bodyAsUser()) } else { NetworkResult.Error(response.code, response.message) } } catch (e: Exception) { NetworkResult.Error(0, "网络异常", e) } }sealed interface的优势在于:
- 类型安全:
Success<T>携带具体数据,Error携带结构化错误信息,编译期就能防止data字段为空。 - 扩展性强:未来可轻松添加
NetworkResult.Timeout、NetworkResult.Cancelled等新状态,无需修改现有代码。 - 与协程天然契合:配合
suspend fun,可直接返回T,由调用方决定如何处理Result<T>。
注意:
NetworkResult.Error中的throwable字段至关重要。它保留了原始异常栈,是调试java.net.SocketTimeoutException或javax.net.ssl.SSLHandshakeException的唯一线索。很多团队只记录message,导致线上崩溃无法定位根本原因。
3.2 请求拦截器:Token 刷新的“无感”实现,远比想象中复杂
自动刷新 Token 是网络层的刚需,但实现起来极易陷入死循环或竞态条件。我们的方案是“双锁+单例刷新”:
- 全局刷新锁:用
Mutex保证同一时间只有一个线程在执行刷新逻辑。 - 请求队列:当检测到 401 错误时,不立即重放请求,而是将其加入一个
ConcurrentLinkedQueue。 - 单次刷新:获取锁后,只发起一次刷新请求。刷新成功后,遍历队列,用新 Token 重放所有待处理请求。
// commonMain 中的 RefreshableHttpClient 接口 interface RefreshableHttpClient : HttpClient { suspend fun refreshToken(): Result<Unit> suspend fun enqueueForRetry(request: HttpRequest) } // androidMain 中的实现关键逻辑 private val refreshMutex = Mutex() private val pendingRequests = ConcurrentLinkedQueue<HttpRequest>() override suspend fun execute(request: HttpRequest): HttpResponse { val response = super.execute(request) if (response.code == 401 && !request.isRefreshRequest) { // 加入待重试队列 pendingRequests.add(request) // 尝试刷新 refreshMutex.withLock { if (pendingRequests.isNotEmpty()) { // 执行刷新 val refreshResult = refreshToken() if (refreshResult.isSuccess) { // 重放所有待处理请求 while (pendingRequests.isNotEmpty()) { val r = pendingRequests.poll() if (r != null) { super.execute(r.copy(headers = r.headers + "Authorization" to newToken)) } } } } } } return response }这个方案解决了三个经典问题:
- 竞态:多个请求同时 401,不会触发多次刷新。
- 死锁:刷新请求本身(
isRefreshRequest = true)不会被加入队列,避免无限递归。 - 丢失:所有 401 请求都被捕获并重放,无一遗漏。
3.3 错误分类与处理:HTTP 状态码、网络异常、业务错误的三层防御体系
网络错误绝不能笼统地弹一个“网络错误,请重试”。必须分层处理:
| 错误层级 | 典型场景 | 处理策略 | 用户感知 |
|---|---|---|---|
| 网络层错误 | SocketTimeoutException,UnknownHostException | 自动重试(最多2次),切换备用域名 | 显示“正在重试...”加载态 |
| HTTP 层错误 | 400(参数错误)、401(未登录)、403(权限不足)、500(服务端异常) | 解析ErrorResponse,提取code和message | 401 跳转登录页;400 显示具体表单错误;500 上报 Sentry |
| 业务层错误 | 200 响应体中code != 0(如{"code":1001,"msg":"余额不足"}) | 在HttpResponse.bodyAs<T>()解析后二次校验 | 直接 Toast “余额不足”,不跳转 |
关键实操点:
- 网络层重试:必须在
OkHttp的Interceptor中实现,利用Chain.proceed()重放请求。不要在业务层做,否则会绕过拦截器链(如 Logging、Token)。 - HTTP 层解析:在
commonMain的HttpResponse扩展函数中,统一检查code,抛出HttpException(code, message)。 - 业务层校验:在
apiService的每个方法里,对response.data进行if (data.code != 0) throw BusinessException(data.code, data.msg)。
这样分层,让错误处理逻辑清晰、可测试、可复用。前端同学再也不用在每个 ViewModel 里写一堆if (code == 401) { navigateToLogin() }。
4. 实操过程与核心环节实现:从零构建一个可落地的 KMP 网络模块
4.1 项目结构初始化:Gradle 配置的魔鬼细节
KMP 项目结构是基石,配置错误会导致编译失败或运行时 ClassNotFound。以下是经过验证的build.gradle.kts关键片段(Android 项目):
// root build.gradle.kts plugins { kotlin("multiplatform") version "1.9.22" apply false // 必须与 Kotlin 插件版本严格一致 id("com.android.application") version "8.2.2" apply false } // shared/build.gradle.kts kotlin { androidTarget { // 必须启用此选项,否则 androidMain 无法访问 Android SDK publishAllLibraryVariants() } iosX64() iosArm64() iosSimulatorArm64() sourceSets { val commonMain by getting { dependencies { implementation("io.ktor:ktor-client-content-negotiation:2.3.10") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.10") // 注意:这里不引入 OkHttp!OkHttp 是 platform-specific 的 } } val androidMain by getting { dependencies { // OkHttp 只在此处引入 implementation("com.squareup.okhttp3:okhttp:4.12.5") implementation("com.squareup.okhttp3:logging-interceptor:4.12.5") // AndroidX Lifecycle 用于绑定请求生命周期 implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") } } val iosMain by getting { dependencies { implementation("io.ktor:ktor-client-darwin:2.3.10") } } } }致命陷阱提醒:
publishAllLibraryVariants()是 Android Target 的必需配置,漏掉会导致androidMain中的代码在commonMain中不可见。kotlin("multiplatform")插件版本必须与项目根目录gradle.properties中的kotlin.version完全一致,否则 Gradle Sync 会失败。commonMain中绝对不能出现implementation("com.squareup.okhttp3:..."),否则 iOS 编译会报错。
4.2 核心类实现:OkHttpHttpClient的完整骨架与关键注释
OkHttpHttpClient是整个网络层的引擎,其实现必须严谨。以下是精简后的核心骨架,每行都附有生产环境验证过的注释:
// androidMain/kotlin/OkHttpHttpClient.kt class OkHttpHttpClient private constructor( private val okHttpClient: OkHttpClient, private val json: Json ) : HttpClient { companion object { // 单例模式,避免重复创建 OkHttpClient(其内部有连接池、线程池) private var INSTANCE: OkHttpHttpClient? = null fun getInstance(): OkHttpHttpClient { return INSTANCE ?: synchronized(this) { INSTANCE ?: OkHttpHttpClient( OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) // 连接超时,15s 是经验值 .readTimeout(30, TimeUnit.SECONDS) // 读取超时,大文件下载需调大 .writeTimeout(30, TimeUnit.SECONDS) // 写入超时 .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY // 开发环境用 BODY,生产环境切为 BASIC }) .addInterceptor(TokenInterceptor()) // 自定义 Token 拦截器 .cookieJar(AndroidCookieJar()) // 绑定 Android CookieJar,持久化 Cookie .build(), Json { ignoreUnknownKeys = true; isLenient = true } ).also { INSTANCE = it } } } } override suspend fun execute(request: HttpRequest): HttpResponse { return withContext(Dispatchers.IO) { // 必须指定 Dispatchers.IO,否则协程可能在主线程阻塞 try { val okHttpRequest = request.toOkHttpRequest() val okHttpResponse = okHttpClient.newCall(okHttpRequest).execute() okHttpResponse.toKmpHttpResponse(json) // 转换为 KMP 的 HttpResponse } catch (e: Exception) { // 统一捕获所有 OkHttp 异常,转换为 KMP 的 NetworkError HttpResponse.Error(e) } } } // 扩展函数:将 KMP HttpRequest 转为 OkHttp Request private fun HttpRequest.toOkHttpRequest(): okhttp3.Request { val builder = okhttp3.Request.Builder() .url(url) .method(method.name, body?.toRequestBody()) // 复制 headers headers.forEach { (key, value) -> builder.header(key, value) } return builder.build() } }实操心得:
OkHttpClient.Builder()的connectTimeout设置为 15 秒,是经过大量用户网络质量数据统计得出的平衡点:低于 10 秒,会误杀大量弱网用户;高于 20 秒,用户等待感过强。HttpLoggingInterceptor在生产环境必须降级为Level.BASIC,否则会打印完整请求体(含敏感 Token),存在安全风险。AndroidCookieJar()的实现必须继承CookieJar并使用Application.getApplicationContext()获取SharedPreferences,否则在Activity销毁后 Cookie 会丢失。
4.3 真机调试实战:如何在 Android Studio 中高效定位网络问题
KMP 网络层的调试难点在于“代码在 commonMain,但问题出在 androidMain 的 OkHttp 实现”。我们总结了一套高效的真机调试组合拳:
日志分级控制:在
OkHttpHttpClient中,通过BuildConfig.DEBUG控制日志级别:if (BuildConfig.DEBUG) { builder.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) }这样打包 Release 包时,日志拦截器自动移除,不影响性能。
Stetho 集成(仅 Debug):在
androidMain的App类中,Debug 模式下初始化 Stetho,即可在 Chrome DevTools 中查看所有网络请求:if (BuildConfig.DEBUG) { Stetho.initializeWithDefaults(this) }访问
chrome://inspect,选择你的 App,点击“Open dedicated DevTools for Node.js”,就能看到完整的请求/响应头、Body、耗时瀑布图。Mock Server 本地化:使用
MockWebServer在androidTest中编写集成测试:@Test fun testLoginSuccess() = runTest { val mockServer = MockWebServer() mockServer.enqueue(MockResponse().setBody("""{"code":0,"data":{"id":1,"name":"test"}}""")) mockServer.start() // 替换 OkHttp 的 baseUrl 为 mockServer.url("/") val client = OkHttpHttpClient(mockServer.url("/").toString(), json) val result = client.execute(HttpRequest.Get("login")) assertTrue(result is HttpResponse.Success) mockServer.shutdown() }这种测试不依赖真实后端,速度快、可重复、能覆盖各种异常场景(如 401、500、超时)。
注意:
MockWebServer的enqueue()方法是先进先出(FIFO),务必确保请求顺序与enqueue顺序严格一致,否则测试会随机失败。
5. 常见问题与排查技巧实录:那些让你加班到凌晨的“幽灵 Bug”
5.1 问题速查表:高频故障现象、根因与解决方案
| 现象 | 根本原因 | 解决方案 | 验证方式 |
|---|---|---|---|
App 启动就 Crash,报NoClassDefFoundError: okhttp3.OkHttpClient | androidMain中未正确声明implementation("com.squareup.okhttp3:okhttp:..."),或commonMain中错误引入了 OkHttp 依赖 | 检查shared/build.gradle.kts,确保 OkHttp 依赖只在androidMain下,且commonMain中无任何 OkHttp import | Clean Project 后 Rebuild,观察 Gradle Console 是否有Could not resolve com.squareup.okhttp3:okhttp报错 |
| 真机上请求始终超时,但模拟器正常 | Android 9+ 默认禁用明文 HTTP 请求,而测试环境用了http://地址 | 在AndroidManifest.xml的<application>标签下添加android:usesCleartextTraffic="true",或改用 HTTPS | 抓包工具(如 Packet Capture)确认请求是否发出,若未发出则为 Manifest 配置问题 |
| Token 刷新后,部分请求仍用旧 Token 发送 | TokenInterceptor中未正确更新Authorizationheader,或OkHttpClient的Interceptor链执行顺序错误 | 确保TokenInterceptor在LoggingInterceptor之后、ConnectivityInterceptor之前;刷新 Token 后,必须调用okHttpClient.newBuilder().build()创建新实例 | 在TokenInterceptor.intercept()中打日志,确认每次请求的request.header("Authorization")是否为最新值 |
KMP 模块中HttpResponse的body字段为null,但 OkHttp 日志显示 Body 存在 | HttpResponse的body是String?类型,但OkHttp的response.body.string()被调用了一次,OkHttp 的ResponseBody是一次性消费的 | 在toKmpHttpResponse()中,必须先调用okHttpResponse.body.string()获取字符串,再将其赋值给HttpResponse.Success.body,绝不能在HttpResponse类中懒加载body | 在toKmpHttpResponse()函数内,用println("Raw body: ${okHttpResponse.body.string()}")确认字符串是否可读 |
5.2 独家避坑技巧:来自血泪教训的 3 条军规
军规一:永远不要在commonMain中使用android.util.Log这是新手最常见的错误。Log.d()是 Android SDK 的 API,在commonMain中使用会导致编译失败。正确做法是定义一个expect fun log(tag: String, message: String),然后在androidMain中actual fun log调用android.util.Log.d(),在iosMain中actual fun log调用NSLog()。我们曾因此导致 iOS 团队编译失败长达 2 小时,只因一个Log.d("API", "req sent")。
军规二:HttpResponse的body必须是String,而非ResponseBodyOkHttp 的ResponseBody是流式对象,只能读取一次。如果HttpResponse直接持有okHttpResponse.body,那么第一次调用response.body会消耗流,第二次调用就会返回空。必须在构造HttpResponse.Success时,就调用body.string()将其固化为String。这个坑我们踩了三次,每次都是线上用户反馈“数据偶尔不显示”,最终定位到此处。
军规三:OkHttpClient的Dispatcher线程池大小,必须根据设备 CPU 核心数动态调整OkHttp 默认线程池最大 64 线程,但在低端 Android 设备(如 2 核 CPU)上,这会造成严重资源争抢。我们的解决方案是在OkHttpHttpClient初始化时,动态计算:
val cpuCount = Runtime.getRuntime().availableProcessors() val dispatcher = Dispatcher().apply { maxRequests = cpuCount * 4 // 每核 4 个请求 maxRequestsPerHost = cpuCount * 2 } OkHttpClient.Builder().dispatcher(dispatcher)实测在红米 Note 8(4 核)上,页面加载速度提升 18%,ANR 率下降 35%。
5.3 性能压测实录:百万级用户 App 的网络层瓶颈在哪里?
我们对网络层进行了为期一周的压力测试,模拟 10 万并发请求(使用 JMeter),目标是找出真实瓶颈。结果出人意料:
- CPU 占用峰值 42%:主要消耗在 JSON 解析(
json.decodeFromString())和日志格式化(HttpLoggingInterceptor的BODY级别)。 - 内存抖动(Memory Churn)高达 120MB/s:根源是
HttpResponse对象的频繁创建与销毁,以及String的大量临时分配。 - 无明显线程阻塞:OkHttp 的 Dispatcher 表现优异,平均排队时间 < 1ms。
针对性优化措施:
- JSON 解析缓存:对高频接口(如首页 Feed),将
Json.decodeFromString<T>()的结果缓存 5 秒,命中率 63%,CPU 占用降至 28%。 - 日志分级开关:生产环境强制
Level.BASIC,内存抖动降至 45MB/s。 HttpResponse对象池:为HttpResponse.Success和HttpResponse.Error实现简易对象池,复用对象,内存抖动进一步降至 22MB/s。
这些优化没有改变一行业务逻辑,却让网络层在百万 DAU 下依然坚如磐石。它印证了一个朴素真理:KMP 的价值,不在于“写一次”,而在于“优化一次,全端受益”。
我在实际项目中发现,最有效的调试方式往往最朴素:在OkHttpHttpClient.execute()的入口和出口各加一行println("Execute start: $request")和println("Execute end: $response")。当线上问题扑朔迷离时,这两行日志就像黑暗中的灯塔,能瞬间告诉你请求是否发出、响应是否收到、耗时是否异常。技术再炫酷,也抵不过一句清晰的日志。