《KMP 共享网络层:Ktor client 双端复用》
要点
KMP 项目里最值得共享的一层就是网络:请求构造、序列化、错误翻译,在两个平台完全是同一件事。Ktor client 3.x 是当前 Kotlin 多平台的标准选择——纯 Kotlin、官方维护、引擎可插拔。这篇走一条完整路线:commonMain 里配好 client,双端各注入自己的引擎,序列化统一走 kotlinx-serialization,错误与超时收敛成一套领域错误,最后给待办应用把远程同步补上。
本文不做:Ktor server(那是另一套 API)、iOS 侧 Swift 调用共享层的桥接细节、token 自动刷新的完整方案(只在错误处理里留了挂点)。共享层测试的骨架在KMP 共享层测试篇已经搭好,本文的网络测试直接往 commonTest 里放。工程与待办应用的来历看KMP 学习路线的实战阶段。
依赖:core 与引擎分开放
Ktor 的依赖结构有个关键设计:client core 放 commonMain,引擎依赖放各平台 source set,运行时 HttpClient 自动选用 classpath 上可用的引擎:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:3.2.0")
implementation("io.ktor:ktor-client-content-negotiation:3.2.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.2.0")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.2.0")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.2.0")
}
}
}
引擎选型没有悬念:Android 用 OkHttp(生态成熟,与 Android 端既有网络栈一致),iOS 用 Darwin(底层是 NSURLSession,天然继承系统的代理与 TLS 行为)。两个引擎对上层 API 完全透明,commonMain 里感知不到差异。但引擎层不是完全镜像,两个差异提前知道省排查时间:Darwin 引擎走 NSURLSession,iOS 的 ATS 策略照样生效,http 明文请求默认被拦——测试环境要么上 https,要么在 Info.plist 里加例外;OkHttp 的拦截器生态(日志、重试、DNS 定制)只在 Android 端存在,别试图把它写进共享层。排查也分层:引擎层的问题用各平台工具看,共享层的问题用 MockEngine 复现。
版本号用 version catalog 收口更好,上面为了直白写死。这里有个值得记住的运行时错误:哪一端忘加引擎依赖,编译照常通过,运行时创建 HttpClient 才抛”找不到引擎”——编译期帮不了你,两端都要真跑一次才算数。
expect/actual:平台差异的收口点
client 本身不需要 expect/actual——引擎自动选择已经吃掉了主要差异。真正需要它的是两件外围事:地址与日志。BASE_URL 用 expect/actual 注入:
// commonMain
expect val BASE_URL: String
// androidMain
actual val BASE_URL =
if (BuildConfig.DEBUG) "https://dev.example.com" else "https://api.example.com"
// iosMain
actual val BASE_URL = "https://api.example.com" // Debug 开关由 iOS 侧配置注入
日志同理:commonMain 里 expect 一个 log 函数,Android 端 actual 到 Logcat,iOS 端 actual 到 print。共享层只声明”我需要打日志”,输出到哪是平台的事——这是 KMP 的基本纪律:差异留给 actual,共性留在 common。
commonMain 里装配 client
整个网络栈在 commonMain 建一份,所有平台共用同一个实例:
val AppJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
val httpClient = HttpClient {
expectSuccess = true
install(ContentNegotiation) {
json(AppJson)
}
install(HttpTimeout) {
connectTimeoutMillis = 5_000
requestTimeoutMillis = 15_000
}
}
三个配置各有用意。ignoreUnknownKeys 让服务端加字段不至于让客户端崩——线上接口演进必然比你发版快,这条是默认就该开的。expectSuccess = true 改变 Ktor 的错误模型:默认状态下 4xx/5xx 不抛异常、照常返回 response,你得手动检查 status;打开之后非 2xx 直接抛类型化异常,错误处理收敛到 catch 一侧。超时配两层:connect 管建连,request 管整个请求生命周期,两层都要有——只配 connect 超时,服务端”收了请求慢慢响应”的场景就漏了。
client 要当模块级单例用:它内部带着连接池与线程资源,每个请求新建 HttpClient 等于每次重建连接池,双端都是同样的坑。整个应用一份实例,配成 object 或注入容器的单例都行;测试里另建实例是正常做法,但要记得测试实例也装一遍 ContentNegotiation。
DTO 与 client 同层放置。时间字段一律用 Long 时间戳,别用平台日期类型——kotlinx-serialization 对 Instant 的支持要额外模块,commonMain 里能省的依赖就省;字段名对不上服务端时用 @SerialName 映射,别为了迁就接口改 Kotlin 的命名风格:
@Serializable
data class TodoDto(
@SerialName("todo_id") val id: Long,
val title: String,
val done: Boolean = false,
val updatedAt: Long = 0,
)
错误处理:翻译成领域错误
异常不该穿透共享层漏到 UI。收口成一个 sealed 类型,双端 UI 拿到的是同一个语义——翻译层的价值在交互决策:拿到 Timeout 可以自动重试一次,拿到 Http(401) 跳登录页,拿到 Unreachable 才提示”检查网络”。错误分类直接决定交互,这也是它值得单独一节的原因:
sealed interface NetError {
data class Http(val code: Int) : NetError
data object Timeout : NetError
data object Unreachable : NetError
data object BadPayload : NetError
}
suspend fun fetchTodos(): List<TodoDto> = try {
httpClient.get("$BASE_URL/todos").body()
} catch (e: ClientRequestException) { // 4xx
throw NetError.Http(e.response.status.value)
} catch (e: ServerResponseException) { // 5xx
throw NetError.Http(e.response.status.value)
} catch (e: HttpRequestTimeoutException) {
throw NetError.Timeout
} catch (e: ConnectTimeoutException) {
throw NetError.Unreachable
} catch (e: SerializationException) {
throw NetError.BadPayload
}
catch 的顺序有讲究:Ktor 的异常有继承关系,父类(IOException 族那些连接类异常)放后面,否则子类永远轮不到。这段写完值得人工核对一遍顺序——编译器不会提醒你 catch 了父类挡住子类,那两个分支会变成死代码。
要不要再加一层拦截器做认证头注入?可以,ClientRequestException 的挂点已经够业务层区分”该重新登录”还是”这单操作非法”,token 刷新的方案留给各自项目的鉴权设计,本文不展开。
错误还有第二层:业务语义。HTTP 200 也可能是”业务失败”(响应体里带非成功码),所以在 fetchTodos 之上再包一层解析:拿到 body 后检查业务码,非成功码翻译成领域里的 BizError。网络层管”通不通”,业务层管”对不对”,两层各翻译各的,UI 拿到统一的 NetError 与 BizError 两个通道,谁的错谁负责。
取消不用专门设计:suspend 函数的取消会传播到引擎层,OkHttp 与 Darwin 都支持中途断开,页面销毁时取消正在进行的同步协程即可,网络层不需要额外的取消接口。别套 withContext(Dispatchers.IO) 包 Ktor 调用——suspend 调用本身已经是异步的,多套一层只是多两次线程切换。
给待办应用补远程同步
实战篇的待办应用目前是纯本地的,现在把同步补上。策略用最朴素的”推脏数据 + 拉全量”:
data class SyncReport(val pulled: Int, val pushed: Int)
class TodoSync(
private val local: TodoStore,
private val client: HttpClient = httpClient,
) {
suspend fun syncOnce(): SyncReport {
val dirty = local.pendingUpload()
for (item in dirty) {
client.post("$BASE_URL/todos") {
contentType(ContentType.Application.Json)
setBody(item.toDto())
}
local.markSynced(item.id)
}
val remote = fetchTodos()
local.merge(remote)
return SyncReport(pulled = remote.size, pushed = dirty.size)
}
}
TodoStore 在共享层是个接口,本地实现交给 SQLDelight,测试与开发期先上内存版都行:
interface TodoStore {
suspend fun pendingUpload(): List<TodoDto>
suspend fun markSynced(id: Long)
suspend fun merge(remote: List<TodoDto>)
}
几条设计决定说一下理由。同步入口是一个 suspend 函数而不是 Flow——同步是离散动作,用户点”同步”或应用回前台触发一次,不值得常驻订阅。先推后拉,保证本地未上传的修改不会被拉下来的旧数据覆盖。merge 的合并键用 updatedAt,冲突时新者胜,这是能解释得通的最低成本策略,真正的 CRDT 方案属于另一个量级的工程。merge 的规则要写死在实现注释里:按 id 找本地项,updatedAt 比较取新;本地没有就插入;”远端没有而本地已同步”的项不做删除——把消失当删除的推断在弱网下会误删数据,删除留给显式的删除接口。
给 iOS 侧用的说明一句话:TodoSync 这类共享类要暴露给 Swift,得在 iOS 目标的 framework 导出配置里挂上,Swift 侧拿到的是挂起函数桥接出来的 async 接口——桥接细节不展开,但”共享类能不能被 Swift 正常调用”要列进验收,编译通过不等于调用得通。
冲突长什么样,值得走一遍真实序列:两端同时把同一条 todo 的标题改掉,A 端 10:00 同步、B 端 10:01 同步,B 的 updatedAt 更新,最终两端标题都是 B 的版本。这个行为要在验收时人工确认一次,”新者胜”不是”后同步的赢”——若 B 的修改实际发生在 A 之前(时钟差),按 updatedAt 判 A 赢,这正是用时间戳不用”同步先后”的原因。
验收:Android 真机与 iOS 模拟器各跑一遍,同一份数据在两端各修改几条后互相同步,TodoStore 最终一致;飞行模式下点同步,UI 收到 NetError.Unreachable 而不是崩溃。
测试:MockEngine 在 commonTest 里跑
共享网络层测试有个天然优势:不用真服务器。Ktor 自带 MockEngine,请求不落网络,commonTest 直接覆盖请求构造与响应解析:
val engine = MockEngine { request ->
respond(
content = """[{"id":1,"title":"buy milk","done":false}]""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
@Test
fun testFetchParsesList() = runTest {
val client = HttpClient(engine) {
install(ContentNegotiation) { json(AppJson) }
}
val todos = client.get("https://test/todos").body<List<TodoDto>>()
assertEquals(1, todos.size)
assertEquals("buy milk", todos[0].title)
assertEquals("/todos", engine.requestHistory[0].url.encodedPath)
}
两条用例值得固定下来:响应解析的 round-trip(服务端加未知字段不崩,靠 ignoreUnknownKeys 兜住)和请求路径、方法、头的断言。错误映射也用 MockEngine 造一个 500 响应:
@Test
fun testMapsServerError() = runTest {
val engine = MockEngine {
respond(
content = "{}",
status = HttpStatusCode.InternalServerError,
headers = emptyHeaders(),
)
}
val client = HttpClient(engine) { expectSuccess = true }
assertFailsWith<ServerResponseException> { client.get("https://test/todos") }
}
这条用例钉的是”expectSuccess 真的打开了”——它是最容易在重构中被顺手关掉的开关,值得一条用例看着。测试文件组织、Gradle 任务怎么跑,都在共享层测试篇,不重复。
常见坑
- 某端忘加引擎依赖:编译通过、运行时报无可用引擎,两端各真跑一次才算验证过。
- 每个 HttpClient 实例独立配置:ContentNegotiation 装在实例上,测试里另建的 client 忘装就会拿到裸文本。
- 不开 expectSuccess:4xx 静默返回,错误处理代码永远不执行,UI 显示”成功”但数据是错的。
- catch 顺序压住子类:父类异常放前面,子类分支成死代码。
- DTO 用平台类型:Date、Calendar 出现在 commonMain 的 DTO 里,序列化直接失败或各端行为不一。
- BASE_URL 硬编码:Debug 与生产地址用 expect/actual 或构建配置注入,别等发版才发现写死的是内网地址。
- 在共享工具代码里 runBlocking:它会卡住调用线程,UI 侧一律走协程作用域(Android 的 viewModelScope、iOS 的 MainScope);runBlocking 只允许出现在命令行工具或测试入口。
- 拉全量没留升级口:数据量上来后,syncOnce 的全量拉取要换成带 since 参数的增量接口,接口设计第一版就把参数位留出来。
验收清单
- commonTest 全绿:请求构造断言(路径、方法、Content-Type)、响应反序列化 round-trip、4xx/5xx 映射、超时映射各至少一条。
- httpClient 全应用单例:grep 不到第二处 HttpClient() 的生产构造(测试里的除外)。
- Android 真机与 iOS 模拟器各完成一次真实接口拉取,同一份 commonMain 代码,零平台分支。
- 飞行模式与超时场景分别映射到 NetError.Unreachable 与 NetError.Timeout,UI 层无裸异常。
- 服务端返回带未知字段的 JSON,客户端正常解析——用 MockEngine 固定成回归用例。
- BASE_URL 由 expect/actual 注入,切换 Debug 与生产不改共享代码;iOS 侧 Swift 能调用到 TodoSync。
- syncOnce 幂等:连续调用两次不产生重复条目,第二次 pushed 计数为 0。
网络层落定之后,KMP 共享层最后一块拼图就齐了:持久化、业务逻辑、网络。整个栈的学习节奏与下一站(UI 共享还是发布流水线)看KMP 学习路线;给同步逻辑补边界的做法,回共享层测试篇找现成骨架。
本文数据核对时间:2025-08-20:Kotlin 2.2.0(2025-06-23 发布)+ Ktor 3.2.x(3.2.0 于 2025-06-12 发布)+ kotlinx-serialization(跟随 Ktor 3.x 兼容线,小版本不精确标注);引擎 OkHttp 与 Darwin 为双端标准选型,接口示例为自建演示服务,口径以 Ktor 官方文档当日版本为准。