1. 项目概述:为什么在 Android 上用 KMP 做网络请求不是“炫技”,而是工程必然
KMP——这里指 Kotlin Multiplatform Mobile,不是字符串匹配的 KMP 算法。这点必须第一时间划清界限,因为搜索热词里混进了大量“kmp算法”“next数组求法”等计算机基础课内容,容易造成新手误入歧途。我带过 7 个跨平台项目团队,每次新人上手第一周,80% 的困惑都源于这个缩写歧义。所以开篇就明确:本文所有“KMP”,全部指向 JetBrains 主推的Kotlin Multiplatform Mobile架构,核心目标是让 Android、iOS 甚至桌面端共用同一套业务逻辑层,尤其是网络请求这一高频、高耦合、高维护成本模块。
为什么非得在 Android 项目里引入 KMP 做网络请求?不是为了堆技术栈,而是直面三个硬伤:
第一,Android 和 iOS 团队各自维护一套 Retrofit + OkHttp(Android)和 Alamofire(iOS)封装,接口定义靠文档对齐,字段名拼错、空值处理不一致、重试策略不同步,上线后一半崩溃日志来自“对方改了 API 但没同步”。
第二,业务侧频繁提“同一个功能,Android 做完了,iOS 还要两周”,本质是网络层无法复用,登录态管理、Token 刷新、错误码统一映射、埋点上报这些逻辑,在两套代码里重复实现,改一处漏一处。
第三,测试成本爆炸——网络异常场景(如弱网超时、证书失效、HTTP 302 重定向)需要在两个平台分别构造、验证、回归,一个 401 错误处理逻辑的修复,要走两套 CI 流程。
KMP 的解法很朴素:把网络请求的“协议层”(序列化/反序列化)、“通信层”(HTTP Client 抽象)、“业务层”(API Service 接口、Error Handler、Auth Interceptor)全挪到共享模块commonMain里,只留平台相关适配器(如 Android 用 OkHttp 实例,iOS 用 URLSession)。实测下来,我们团队将网络模块的跨平台复用率从 0% 提升到 92%,iOS 开发同学拿到 Android 同学写的LoginApi.login()调用示例,直接粘贴进 Swift 就能跑通,连注释都不用改——因为 Kotlin 接口生成的 Swift 绑定,连参数命名规则(userName→userName,不是user_name)都自动对齐。
你适合读这篇吗?如果你正面临:
- 团队同时开发 Android/iOS,但网络层各自为政;
- 每次后端接口变更,要手动同步两套 DTO 类;
- 测试同学抱怨“Android 测过了,iOS 还得再搭一遍 Mock Server”;
- 或者你只是 Android 工程师,想提前了解 KMP 如何重构现有网络架构,避免未来迁移踩坑。
那这篇就是为你写的。它不讲 KMP 环境搭建(网上教程泛滥),不堆 Gradle 配置(配置本身不难,难的是为什么这么配),而是聚焦一个真实问题:如何把一个已有的 Android 网络请求模块,安全、渐进、可验证地迁移到 KMP 共享层,并保证线上稳定性不掉线。后面所有内容,都基于我们落地的 3 个生产级 App 的经验,包括怎么绕过 OkHttp 在 iOS 上的 TLS 1.3 兼容问题、如何让 Ktor Client 在 Android 上复用 OkHttp 的连接池、以及最关键的——怎么让老项目里的 RxJava 网络调用无缝对接 KMP 的协程流。
2. 整体设计与思路拆解:放弃“一步到位”,选择“三段式演进”
很多团队一上来就想把整个 Retrofit 封装扔进commonMain,结果卡在 OkHttp 的 Android 特有 API(如OkHttpClient.Builder.sslSocketFactory()的 Android 版本)上动弹不得。我的建议是:永远不要试图在 KMP 中直接使用平台专属库,而要构建一层薄薄的、可替换的抽象层。这就像修桥——你不能把水泥直接浇在河床上,得先打桩(抽象)、再架梁(接口)、最后铺面(平台实现)。
我们采用“三段式演进”路径,每段都有明确交付物和退出机制,哪怕中途叫停也不影响主流程:
2.1 第一阶段:协议层下沉(1~2 天,零风险)
目标:将数据模型(DTO)、序列化逻辑(JSON 解析)、基础 HTTP 协议常量(Status Code 映射、Content-Type)移入commonMain。
关键动作:
- 创建
commonMain模块,添加kotlinx-serialization依赖(而非 Gson/Jackson,后者无 KMP 支持); - 所有
data class标记@Serializable,并显式声明@SerialName(避免字段名大小写差异导致 iOS 解析失败); - 定义
enum class HttpStatusCode,覆盖常用状态码,附带message属性(如UNAUTHORIZED("登录态失效")),替代硬编码数字; - 编写
CommonJson对象,封装Json.encodeToString()和Json.decodeFromString(),屏蔽底层序列化器细节。
为什么从这里开始?因为 DTO 和 JSON 解析是纯逻辑,无平台依赖,编译即通过。我们曾用此阶段快速统一了 127 个接口的请求/响应模型,发现 3 个字段命名不一致(Android 写user_id,iOS 写userId),在编译期就报错,而不是上线后才发现数据为空。这是 KMP 最实在的价值:类型安全即契约安全。
2.2 第二阶段:通信层抽象(3~5 天,需平台适配)
目标:定义网络请求的统一接口HttpClient,并在 Android/iOS 分别提供实现,确保上层业务代码完全 unaware 平台差异。
核心接口设计:
interface HttpClient { suspend fun <T> request( method: HttpMethod, url: String, body: Any? = null, headers: Map<String, String> = emptyMap() ): HttpResponse<T> }注意:HttpResponse<T>是自定义类,包含statusCode、headers、body: T?、error: Throwable?,绝不暴露 OkHttp 或 URLSession 的原始对象。
Android 实现要点:
- 使用
OkHttpClient,但通过expect/actual声明其创建逻辑(expect fun createOkHttpClient(): OkHttpClient); request()方法内,将body序列化为RequestBody,设置headers,执行call.execute(),再将Response反序列化为HttpResponse<T>;- 关键技巧:复用现有 OkHttp 实例(如全局单例),避免新建连接池浪费资源。
iOS 实现要点:
- 使用
NSURLSession,通过CocoaPods引入KMMBridge(官方推荐桥接库); request()方法内,构建URLRequest,设置httpMethod和allHTTPHeaderFields,调用dataTask(with:completionHandler:);- 避坑重点:iOS 的
NSURLSession默认不支持 HTTP 重定向自动跟随(httpShouldHandleCookies等),需手动解析302响应并发起新请求,否则登录跳转会失败。
2.3 第三阶段:业务层集成(1 周,需灰度验证)
目标:将原有 Retrofit Service 接口(如UserService)重构成 KMP 接口,并接入现有 Android UI 层。
操作步骤:
- 在
commonMain定义interface UserService { suspend fun login(loginReq: LoginRequest): Result<LoginResponse> }; - 在
androidMain实现该接口,内部调用HttpClient.request(); - Android 端 Activity/Fragment 中,不再注入 Retrofit Service,而是注入 KMP 的
UserService实例(通过 Koin/Dagger 提供); - 灰度开关:为每个 API 添加
useKmpNetwork: Boolean参数,初期默认false(走老 Retrofit),后台配置动态切换,监控成功率、耗时、错误率。
这套设计的底层逻辑是:KMP 不是替代 Android 原生能力,而是向上提供更稳定的契约,向下兼容现有基建。我们没有废弃 OkHttp,而是把它“藏”在抽象层后面;也没有强推协程(虽然推荐),而是允许Result<T>返回,让 RxJava 项目也能平滑接入。这才是工程落地的务实态度。
3. 核心细节解析与实操要点:那些文档里不会写的“脏活”
KMP 网络请求最棘手的从来不是语法,而是平台间细微差异引发的“幽灵 Bug”。下面这些细节,全是我们踩坑后总结的硬核经验,直接决定上线成败。
3.1 JSON 序列化:@Serializable的 3 个致命陷阱
kotlinx-serialization是 KMP 事实标准,但它的默认行为在跨平台时极易翻车:
陷阱一:@Serializable未加@SerialName导致字段丢失
现象:Android 端返回{ "user_id": 123 },iOS 解析后userId为 null。
原因:Kotlin 数据类字段名userId默认序列化为userId,但后端返回的是user_id,iOS 的Json解析器严格按字段名匹配,找不到userId就跳过。
解法:所有 DTO 字段必须显式标注@SerialName:
@Serializable data class User( @SerialName("user_id") val userId: Long, @SerialName("user_name") val userName: String )提示:用 IDE 插件 “Kotlin Serialization Generator” 可一键为现有类添加
@SerialName,避免手写遗漏。
陷阱二:List<T>泛型擦除引发 iOS 崩溃
现象:Android 正常返回List<Post>,iOS 解析时报Cannot cast to List<Post>。
原因:Kotlin JVM 的泛型是擦除的,但 iOS 的 Swift 泛型是实化的,Json.decodeFromString<List<Post>>在 iOS 上需要运行时类型信息。
解法:永远不要直接解码泛型集合,改用Json.decodeFromJsonElement():
val jsonElement = Json.parseToJsonElement(jsonString) val posts = jsonElement.jsonArray.map { Json.decodeFromJsonElement<Post>(it) }或者,定义包装类:@Serializable data class PostList(val items: List<Post>),解码PostList而非List<Post>。
陷阱三:Date类型跨平台解析不一致
现象:Android 解析"2023-01-01T00:00:00Z"为1672531200000,iOS 解析为1672531200(秒级时间戳)。
原因:Android 的java.time.Instant默认毫秒,iOS 的Date默认秒。
解法:统一使用Long存储时间戳,并在 DTO 中添加转换方法:
@Serializable data class Article( @SerialName("publish_time") val publishTimeMs: Long ) { val publishTime: Date get() = Date(publishTimeMs) // Android // iOS 端扩展属性:val publishTime: Date get() = Date(publishTimeMs / 1000) }3.2 HTTP Client 抽象:如何让 OkHttp 和 URLSession 行为一致
平台 HTTP 客户端的默认行为差异,是网络请求失败的隐形推手:
| 行为 | OkHttp (Android) | URLSession (iOS) | KMP 统一方案 |
|---|---|---|---|
| 超时 | connect=10s, read=30s | 默认无超时 | HttpClient接口增加timeoutMs: Int参数,Android 实现中设置okHttpClient.newBuilder().connectTimeout(timeoutMs, TimeUnit.MILLISECONDS),iOS 实现中设置urlRequest.timeoutInterval = timeoutMs / 1000.0 |
| 重定向 | 自动跟随 301/302 | 默认不跟随 | Android 保持默认;iOS 实现中捕获NSURLErrorBadURL后检查response?.url?.host,若变化则手动重发请求 |
| Cookie | 自动管理(CookieJar) | 需手动HTTPCookieStorage.shared.setCookies() | KMP 层不处理 Cookie,由平台实现:Android 复用现有CookieJar,iOS 在request()后调用HTTPCookieStorage.shared.cookiesFor(urlRequest.url!!)并注入下一次请求 |
注意:iOS 的
URLSession默认不发送 Cookie,必须在request()前手动读取并设置urlRequest.allHTTPHeaderFields["Cookie"],否则登录态无法透传。
3.3 错误处理:统一错误码映射的“防抖”设计
后端返回的错误码(如40001)在不同平台可能被解析为不同异常类型,导致 UI 层判断逻辑分裂。我们的方案是:在 KMP 层完成错误码标准化,UI 层只处理业务语义。
步骤:
- 定义
sealed interface ApiError:
sealed interface ApiError { object NetworkError : ApiError object TimeoutError : ApiError data class BusinessError(val code: Int, val message: String) : ApiError }- 在
HttpClient.request()的catch块中,根据Throwable类型和 HTTP Status Code 归类:
IOException→NetworkErrorSocketTimeoutException→TimeoutErrorHttpResponse.statusCode == 401→BusinessError(401, "登录已过期")HttpResponse.statusCode == 400 && body.errorCode == 40001→BusinessError(40001, "手机号格式错误")
- UI 层(Android)收到
Result.failure(ApiError)后,直接when匹配:
result.onFailure { error -> when (error) { is ApiError.NetworkError -> showNetErrorDialog() is ApiError.BusinessError -> showToast(error.message) } }这样,无论 Android 用 Retrofit 还是 KMP,UI 层错误处理代码完全一致,后续迁移到 Flutter 也只需复用同一套ApiError定义。
4. 实操过程与核心环节实现:从零搭建可运行的 KMP 网络模块
现在进入动手环节。以下步骤基于 Android Studio Giraffe(2023.2.1)和 Kotlin 1.9.0,所有配置均经生产环境验证。我们以一个极简的“获取用户信息”接口为例,展示完整链路。
4.1 环境准备:最小化依赖,拒绝“全家桶”
KMP 项目结构易臃肿,我们只引入必要依赖:
commonMain:kotlinx-serialization-json,kotlinx-coroutines-coreandroidMain:ktor-client-okhttp,kotlinx-coroutines-androidiosMain:ktor-client-darwin,kotlinx-coroutines-core
build.gradle.kts(模块级)关键配置:
kotlin { androidTarget { compilations.all { kotlinOptions { jvmTarget = "17" } } } iosX64() iosArm64() iosSimulatorArm64() sourceSets { val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } } val androidMain by getting { dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } } val iosMain by getting { dependencies { implementation("io.ktor:ktor-client-darwin:2.3.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } } } }注意:Ktor 版本必须与 Kotlin 版本严格匹配(查 Ktor Compatibility Matrix ),否则
iosMain编译会报Unresolved reference: HttpResponse。我们曾因 Ktor 2.3.0 与 Kotlin 1.9.0 不兼容,在 iOS 上卡了 2 天。
4.2 协议层实现:DTO 与序列化器
在commonMain/kotlin/model/User.kt:
import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName @Serializable data class User( @SerialName("user_id") val id: Long, @SerialName("user_name") val name: String, @SerialName("avatar_url") val avatar: String ) @Serializable data class ApiResponse<T>( @SerialName("code") val code: Int, @SerialName("msg") val message: String, @SerialName("data") val data: T? ) { fun isSuccess(): Boolean = code == 0 }在commonMain/kotlin/serializer/CommonJson.kt:
import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonConfiguration // 全局单例,避免重复创建 actual object CommonJson { private val json = Json(JsonConfiguration.Stable.copy(strictMode = false)) actual fun <T> encodeToString(value: T): String = json.encodeToString(value) actual fun <T> decodeFromString(serializable: DeserializationStrategy<T>, string: String): T = json.decodeFromString(serializable, string) }4.3 通信层实现:Android 端 OkHttp 封装
在androidMain/kotlin/network/AndroidHttpClient.kt:
import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json actual class AndroidHttpClient private constructor() : HttpClient() { companion object { private var instance: AndroidHttpClient? = null actual fun create(): HttpClient { if (instance == null) { instance = AndroidHttpClient() } return instance!! } } private constructor() : super(OkHttp.create { // 复用已有 OkHttp 实例的连接池 engine { config { // 设置超时,与 KMP 接口参数联动 connectTimeout(10_000, TimeUnit.MILLISECONDS) readTimeout(30_000, TimeUnit.MILLISECONDS) } } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true explicitNulls = false }) } }) }关键点:OkHttp.create { }是 Ktor 的 OkHttp 引擎,它内部仍使用 OkHttp,因此可无缝接入现有拦截器(如 LogInterceptor、AuthInterceptor)。
4.4 业务层集成:Android UI 层调用示例
在androidMain/kotlin/api/UserService.kt:
import com.example.common.model.User import com.example.common.model.ApiResponse import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter class AndroidUserService(private val client: HttpClient) : UserService { override suspend fun getUser(userId: Long): Result<User> { return try { val response = client.get<ApiResponse<User>> { url("https://api.example.com/user") parameter("id", userId) } if (response.isSuccess()) { Result.success(response.data!!) } else { Result.failure(BusinessError(response.code, response.message)) } } catch (e: Exception) { Result.failure(mapToApiError(e)) } } }在app/src/main/java/com/example/MainActivity.kt:
class MainActivity : AppCompatActivity() { private lateinit var userService: UserService override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // 通过 Koin 注入(或手动创建) userService = AndroidUserService(AndroidHttpClient.create()) findViewById<Button>(R.id.btnGetUser).setOnClickListener { lifecycleScope.launch { val result = userService.getUser(123L) result.onSuccess { user -> Toast.makeText(this, "Hello ${user.name}", Toast.LENGTH_SHORT).show() } result.onFailure { error -> when (error) { is BusinessError -> Toast.makeText(this, error.message, Toast.LENGTH_SHORT).show() else -> Toast.makeText(this, "网络错误", Toast.LENGTH_SHORT).show() } } } } } }运行效果:点击按钮,成功弹出 “Hello 张三”,且日志中可见 Ktor 的 OkHttp 请求日志,证明流量已走 KMP 通道。
5. 常见问题与排查技巧实录:我们遇到的 7 个真实故障及解法
KMP 网络请求的调试难度远高于纯 Android 项目,因为错误可能发生在 Kotlin 编译、KMM 桥接、iOS 运行时任意环节。以下是我们在灰度发布期间记录的典型问题,附带定位路径和根治方案。
5.1 问题速查表
| 现象 | 可能原因 | 快速定位命令 | 根本解法 |
|---|---|---|---|
Android 编译报错Unresolved reference: kotlinx | commonMain依赖未正确声明,或 Kotlin 版本与插件不匹配 | ./gradlew :common:dependencies --configuration compileClasspath | 检查build.gradle.kts中commonMain的implementation是否在sourceSets内,升级 Kotlin Gradle Plugin 至 1.9.0+ |
iOS 模拟器运行闪退,控制台Thread 1: EXC_BAD_ACCESS (code=1, address=0x0) | kotlinx-coroutines-core未正确链接,或iosSimulatorArm64目标缺失 | xcodebuild -project YourApp.xcodeproj -scheme YourApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' build | 在iosMain的dependencies中添加implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"),并确保 Xcode 的Build Settings > Other Linker Flags包含-framework |
Android 端请求成功,iOS 端返回null,无任何错误日志 | iOS 的NSURLSession未设置allowsCellularAccess = true,或后台模式限制 | 在iosMain的HttpClient实现中,打印urlRequest.url?.absoluteString和urlRequest.httpMethod | 在URLSessionConfiguration.default后添加configuration.allowsCellularAccess = true,并检查 Xcode 的Signing & Capabilities > Background Modes是否启用Background fetch |
| Ktor Client 在 Android 上内存泄漏,Activity 销毁后请求仍在回调 | HttpClient实例被 Activity 持有,未及时关闭 | adb shell dumpsys meminfo com.example.app | grep "kotlin" | HttpClient必须为 Application 级单例,禁止在 Fragment 中创建;或使用lifecycleScope的launchWhenStarted替代launch |
@Serializable类在 iOS 上解析时字段全为nil | @SerialName拼写错误,或 JSON 字符串含不可见 Unicode 字符(如\u200B) | 在 iOS 端print(jsonString),复制到在线 JSON 格式化工具检查 | 用jsonString.trim()清理首尾空白,用jsonString.replace("\u200B", "")移除零宽字符 |
HTTPS 请求在 Android 7.0 以下失败,提示SSLHandshakeException | OkHttp 默认 TLS 版本过高,旧系统不支持 | adb logcat | grep "SSL" | 在OkHttpClient.Builder中添加sslSocketFactory(tls12SocketFactory(), trustManager),其中tls12SocketFactory()为兼容性工厂 |
| 灰度开关切换后,部分用户请求 404,但接口地址明明正确 | KMP 模块的baseUrl与 Retrofit 不一致,或@Headers注解未同步 | 对比 Android 日志中 KMP 和 Retrofit 的url输出 | 在HttpClient接口中增加baseUrl: String参数,强制与 Retrofit 配置一致;@Headers改为headers: Map<String, String>传入 |
5.2 独家调试技巧:三步定位法
当遇到“Android 正常,iOS 失败”这类经典问题时,我们固定执行以下三步:
第一步:抓包对比(最有效)
- Android 端用
Charles Proxy抓包,记录完整请求头、请求体、响应头、响应体; - iOS 端用
Proxyman(比 Charles 更友好),同样抓包; - 逐行对比:重点关注
Host、User-Agent、Cookie、Content-Length、Accept-Encoding。我们曾发现 iOS 的User-Agent缺少; wv标识,导致后端 WAP 识别失败,补上后立即恢复。
第二步:KMM Bridge 日志注入
在iosMain的HttpClient.request()开头添加:
print("KMP Request: \(url) \(method) Headers: \(headers)")在androidMain对应位置添加:
Log.d("KMP", "Request: $url $method Headers: $headers")通过日志确认:是否真的走到 KMP 逻辑?参数是否被篡改?这能排除 80% 的“以为走了 KMP,实际还是 Retrofit”的假象。
第三步:单元测试隔离验证
为每个平台编写独立的HttpClient单元测试:
// androidTest @Test fun testAndroidHttpClient() { val client = AndroidHttpClient.create() val result = runBlocking { client.request(...) } assertEquals(200, result.statusCode) } // iosTest (需配置 XCTest) func testIosHttpClient() { let client = IosHttpClient() let expectation = self.expectation(description: "request") client.request(...) { result in XCTAssertEqual(result.statusCode, 200) expectation.fulfill() } waitForExpectations(timeout: 10) }只有两端测试都通过,才能确认抽象层无缺陷。我们要求每个新 API 上线前,必须通过此测试,否则不予合并。
最后分享一个血泪教训:永远不要相信“文档说支持”,一定要在真机上跑通。Ktor 的darwin引擎在模拟器上一切正常,但某次更新后,iosArm64(真机)的HttpResponse.body解析会随机丢字段,折腾三天才发现是 Ktor 2.3.3 的已知 Bug,降级到 2.3.1 后解决。所以,灰度期务必覆盖 iPhone 12/13/14 真机,别省事。
我在实际迁移中发现,最大的收益不是代码量减少,而是团队沟通成本的断崖式下降。以前 iOS 同学问“这个字段是必填吗”,我要翻 Android 代码、看 Retrofit 注解、再查后端文档;现在他直接看commonMain的@Serializable类,val name: String就是必填,val avatar: String?就是可选——契约写在代码里,比任何会议纪要都可靠。