ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android项目中KMP网络请求落地实践指南

Android项目中KMP网络请求落地实践指南 1. 项目概述为什么在 Android 上用 KMP 做网络请求不是“炫技”而是工程必然KMP——这里指 Kotlin Multiplatform Mobile不是字符串匹配的 KMP 算法。这点必须第一时间划清界限因为搜索热词里混进了大量“kmp算法”“next数组求法”等计算机基础课内容容易造成新手误入歧途。我带过 7 个跨平台项目团队每次新人上手第一周80% 的困惑都源于这个缩写歧义。所以开篇就明确本文所有“KMP”全部指向 JetBrains 主推的Kotlin Multiplatform Mobile架构核心目标是让 Android、iOS 甚至桌面端共用同一套业务逻辑层尤其是网络请求这一高频、高耦合、高维护成本模块。为什么非得在 Android 项目里引入 KMP 做网络请求不是为了堆技术栈而是直面三个硬伤第一Android 和 iOS 团队各自维护一套 Retrofit OkHttpAndroid和 AlamofireiOS封装接口定义靠文档对齐字段名拼错、空值处理不一致、重试策略不同步上线后一半崩溃日志来自“对方改了 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_idiOS 写userId在编译期就报错而不是上线后才发现数据为空。这是 KMP 最实在的价值类型安全即契约安全。2.2 第二阶段通信层抽象3~5 天需平台适配目标定义网络请求的统一接口HttpClient并在 Android/iOS 分别提供实现确保上层业务代码完全 unaware 平台差异。核心接口设计interface HttpClient { suspend fun T request( method: HttpMethod, url: String, body: Any? null, headers: MapString, String emptyMap() ): HttpResponseT }注意HttpResponseT是自定义类包含statusCode、headers、body: T?、error: Throwable?绝不暴露 OkHttp 或 URLSession 的原始对象。Android 实现要点使用OkHttpClient但通过expect/actual声明其创建逻辑expect fun createOkHttpClient(): OkHttpClientrequest()方法内将body序列化为RequestBody设置headers执行call.execute()再将Response反序列化为HttpResponseT关键技巧复用现有 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): ResultLoginResponse }在androidMain实现该接口内部调用HttpClient.request()Android 端 Activity/Fragment 中不再注入 Retrofit Service而是注入 KMP 的UserService实例通过 Koin/Dagger 提供灰度开关为每个 API 添加useKmpNetwork: Boolean参数初期默认false走老 Retrofit后台配置动态切换监控成功率、耗时、错误率。这套设计的底层逻辑是KMP 不是替代 Android 原生能力而是向上提供更稳定的契约向下兼容现有基建。我们没有废弃 OkHttp而是把它“藏”在抽象层后面也没有强推协程虽然推荐而是允许ResultT返回让 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_idiOS 的Json解析器严格按字段名匹配找不到userId就跳过。解法所有 DTO 字段必须显式标注SerialNameSerializable data class User( SerialName(user_id) val userId: Long, SerialName(user_name) val userName: String )提示用 IDE 插件 “Kotlin Serialization Generator” 可一键为现有类添加SerialName避免手写遗漏。陷阱二ListT泛型擦除引发 iOS 崩溃现象Android 正常返回ListPostiOS 解析时报Cannot cast to ListPost。原因Kotlin JVM 的泛型是擦除的但 iOS 的 Swift 泛型是实化的Json.decodeFromStringListPost在 iOS 上需要运行时类型信息。解法永远不要直接解码泛型集合改用Json.decodeFromJsonElement()val jsonElement Json.parseToJsonElement(jsonString) val posts jsonElement.jsonArray.map { Json.decodeFromJsonElementPost(it) }或者定义包装类Serializable data class PostList(val items: ListPost)解码PostList而非ListPost。陷阱三Date类型跨平台解析不一致现象Android 解析2023-01-01T00:00:00Z为1672531200000iOS 解析为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 统一方案超时connect10s, read30s默认无超时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 复用现有CookieJariOS 在request()后调用HTTPCookieStorage.shared.cookiesFor(urlRequest.url!!)并注入下一次请求注意iOS 的URLSession默认不发送 Cookie必须在request()前手动读取并设置urlRequest.allHTTPHeaderFields[Cookie]否则登录态无法透传。3.3 错误处理统一错误码映射的“防抖”设计后端返回的错误码如40001在不同平台可能被解析为不同异常类型导致 UI 层判断逻辑分裂。我们的方案是在 KMP 层完成错误码标准化UI 层只处理业务语义。步骤定义sealed interface ApiErrorsealed 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 还是 KMPUI 层错误处理代码完全一致后续迁移到 Flutter 也只需复用同一套ApiError定义。4. 实操过程与核心环节实现从零搭建可运行的 KMP 网络模块现在进入动手环节。以下步骤基于 Android Studio Giraffe2023.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-corebuild.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.ktimport 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 ApiResponseT( SerialName(code) val code: Int, SerialName(msg) val message: String, SerialName(data) val data: T? ) { fun isSuccess(): Boolean code 0 }在commonMain/kotlin/serializer/CommonJson.ktimport 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: DeserializationStrategyT, string: String): T json.decodeFromString(serializable, string) }4.3 通信层实现Android 端 OkHttp 封装在androidMain/kotlin/network/AndroidHttpClient.ktimport 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.ktimport 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): ResultUser { return try { val response client.getApiResponseUser { 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.ktclass 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()) findViewByIdButton(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: kotlinxcommonMain依赖未正确声明或 Kotlin 版本与插件不匹配./gradlew :common:dependencies --configuration compileClasspath检查build.gradle.kts中commonMain的implementation是否在sourceSets内升级 Kotlin Gradle Plugin 至 1.9.0iOS 模拟器运行闪退控制台Thread 1: EXC_BAD_ACCESS (code1, address0x0)kotlinx-coroutines-core未正确链接或iosSimulatorArm64目标缺失xcodebuild -project YourApp.xcodeproj -scheme YourApp -sdk iphonesimulator -destination platformiOS Simulator,nameiPhone 14 build在iosMain的dependencies中添加implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3)并确保 Xcode 的Build Settings Other Linker Flags包含-frameworkAndroid 端请求成功iOS 端返回null无任何错误日志iOS 的NSURLSession未设置allowsCellularAccess true或后台模式限制在iosMain的HttpClient实现中打印urlRequest.url?.absoluteString和urlRequest.httpMethod在URLSessionConfiguration.default后添加configuration.allowsCellularAccess true并检查 Xcode 的Signing Capabilities Background Modes是否启用Background fetchKtor Client 在 Android 上内存泄漏Activity 销毁后请求仍在回调HttpClient实例被 Activity 持有未及时关闭adb shell dumpsys meminfo com.example.app | grep kotlinHttpClient必须为 Application 级单例禁止在 Fragment 中创建或使用lifecycleScope的launchWhenStarted替代launchSerializable类在 iOS 上解析时字段全为nilSerialName拼写错误或 JSON 字符串含不可见 Unicode 字符如\u200B在 iOS 端print(jsonString)复制到在线 JSON 格式化工具检查用jsonString.trim()清理首尾空白用jsonString.replace(\u200B, )移除零宽字符HTTPS 请求在 Android 7.0 以下失败提示SSLHandshakeExceptionOkHttp 默认 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: MapString, 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?就是可选——契约写在代码里比任何会议纪要都可靠。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进