KMP 共享网络层:Ktor client 双端复用

《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 官方文档当日版本为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇