本课目标:理解 Ktor 客户端的引擎抽象与多平台适配机制,掌握 HttpClient 的创建、配置与生命周期管理,学会用 ContentNegotiation 处理 JSON 序列化,建立 Repository 模式隔离网络与 UI,为后续 ViewModel 和依赖注入打下数据层基础。
系列整体规划
| 课次 | 主题 | 核心内容 | 难度 |
|---|---|---|---|
| 第1课 | 从零开始 | 技术概览、环境搭建、第一个应用、代码解读 | ⭐ |
| 第2课 | Compose 基础语法 | @Composable、状态管理、重组机制、Modifier 体系 | ⭐⭐ |
| 第3课 | 布局与组件 | Column/Row/Box、LazyColumn、Material3 组件库 | ⭐⭐ |
| 第4课 | 导航与路由 | Navigation Compose、类型安全路由、深层链接 | ⭐⭐⭐ |
| 第5课 | 网络与数据层 | Ktor 客户端、序列化、Repository 模式 | ⭐⭐⭐ |
| 第6课 | 状态管理与架构 | ViewModel、单向数据流、依赖注入 | ⭐⭐⭐⭐ |
| 第7课 | 平台适配与互操作 | expect/actual、SwiftUI 互操作、平台特定 API | ⭐⭐⭐⭐ |
| 第8课 | 资源管理与主题 | 多平台资源、图片加载、深浅色主题 | ⭐⭐⭐ |
| 第9课 | 测试与调试 | Compose UI 测试、单元测试、性能分析 | ⭐⭐⭐⭐ |
| 第10课 | 发布与部署 | Android/iOS/桌面/Web 打包发布、CI/CD | ⭐⭐⭐⭐⭐ |
第5课 网络与数据层
一、为什么选择 Ktor
1.1 跨平台网络请求的挑战
在单平台开发中,网络请求的选型很直接:Android 用 Retrofit + OkHttp,iOS 用 URLSession,桌面端用各种 HTTP 客户端。但 CMP 项目需要在 commonMain 中编写共享的网络代码,这意味着你必须选择一个能在所有平台上运行的 HTTP 客户端。
选择的标准有三个:纯 Kotlin 实现 (不需要依赖 JVM 或 Native 平台特有的 API)、协程原生支持 (与 Compose 的异步模型一致)、可插拔的引擎抽象(各平台用最优的底层实现)。
Ktor Client 同时满足这三个标准。它是 JetBrains 官方维护的异步 HTTP 客户端,完全用 Kotlin 编写,基于协程,并且通过"引擎"抽象让你在共享代码中编写请求逻辑,在各平台使用不同的底层实现。
1.2 引擎抽象:一套代码,多套底层
Ktor 的核心设计是客户端与引擎分离 。HttpClient 是你在 commonMain 中使用的 API,它不关心底层如何发送请求。真正的网络操作由"引擎"完成,每个平台可以选择最合适的引擎:
| 平台 | 推荐引擎 | 底层实现 |
|---|---|---|
| Android | OkHttp 或 Android | OkHttp / HttpURLConnection |
| iOS | Darwin | NSURLSession |
| 桌面端(JVM) | CIO 或 OkHttp | 纯 Kotlin 协程实现 / OkHttp |
| Web(Wasm) | Js 或 Wasm | Fetch API |
引擎的选择策略 :在 commonMain 中只依赖 ktor-client-core,在各平台的 *Main 中分别添加对应的引擎依赖。运行时,Ktor 会自动根据构建脚本中存在的引擎选择默认实现,也可以显式指定。
二、配置 Ktor 客户端
2.1 添加依赖
在 gradle/libs.versions.toml 中定义版本和库:
toml
[versions]
ktor = "3.4.1"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
在 composeApp/build.gradle.kts 中按源集添加:
kotlin
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
desktopMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
2.2 创建 HttpClient
HttpClient 的创建不应该是每次请求都新建的。Ktor 官方明确建议复用 HttpClient 实例,因为创建过程涉及线程池、连接池的初始化,开销不小。
kotlin
val client = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
关键配置项:
expectSuccess = true:当 HTTP 状态码表示错误时(4xx、5xx),自动抛出异常,而不是静默返回错误响应。这让错误处理更符合 Kotlin 的异常风格。
install(ContentNegotiation) :启用内容协商插件,自动处理请求体的序列化和响应体的反序列化。配合 json() 方法,指定使用 kotlinx.serialization 的 JSON 格式。
ignoreUnknownKeys = true:当服务端返回的 JSON 包含客户端未定义的字段时,忽略而不是报错。这是对接真实 API 的必备配置,因为服务端可能随时添加新字段。
2.3 生命周期管理
HttpClient 需要被关闭以释放资源。在 CMP 项目中,通常有两种管理方式:
单例模式:创建一个全局的、应用生命周期级别的 HttpClient,在应用退出时关闭。这是大多数场景的正确选择。
协程作用域绑定 :如果 HttpClient 的生命周期与某个协程作用域绑定,使用 use 函数自动关闭:
kotlin
HttpClient().use { client ->
val response = client.get("https://api.example.com/data")
}
跨平台注意事项 :在 Android 上,HttpClient 的关闭应该在 Application.onTerminate() 或类似的生命周期钩子中处理。在桌面端,窗口关闭时关闭。iOS 上则与 ComposeUIViewController 的生命周期对齐。
三、发起请求与处理响应
3.1 基本请求
Ktor 提供了简洁的请求 API:
kotlin
suspend fun fetchUser(id: String): User {
return client.get("https://api.example.com/users/$id").body()
}
client.get() 是一个挂起函数,必须在协程或另一个挂起函数中调用。这保证了网络请求不会阻塞 UI 线程。
.body() 是扩展函数,它利用 ContentNegotiation 插件自动将响应体反序列化为目标类型。返回类型由函数的返回类型推断。
3.2 携带请求参数
路径参数直接拼接到 URL 中:
kotlin
client.get("https://api.example.com/users/$userId/posts")
查询参数 使用 parameter() 或 url.parameters:
kotlin
client.get("https://api.example.com/search") {
parameter("q", query)
parameter("page", page)
}
请求体(POST/PUT)直接传递可序列化的对象:
kotlin
client.post("https://api.example.com/users") {
contentType(ContentType.Application.Json)
setBody(NewUser(name = "张三", age = 25))
}
3.3 响应处理
如果只关心响应体,直接 .body()。如果需要检查状态码或响应头:
kotlin
val response = client.get("https://api.example.com/users")
if (response.status.isSuccess()) {
val users: List<User> = response.body()
// 处理成功
} else {
// 处理错误
}
由于我们设置了 expectSuccess = true,错误状态码会自动抛出异常,所以通常只需要处理成功路径,错误由异常机制统一处理。
3.4 错误处理模式
推荐的做法是在 Repository 层封装错误处理,返回一个密封类表示结果:
kotlin
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val message: String, val cause: Throwable? = null) : NetworkResult<Nothing>()
}
suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> {
return try {
NetworkResult.Success(block())
} catch (e: CancellationException) {
throw e // 协程取消不应被吞掉
} catch (e: Exception) {
NetworkResult.Error(e.message ?: "未知错误", e)
}
}
关键细节 :CancellationException 必须重新抛出,否则协程取消机制会被破坏。这是 KMP 网络请求中容易忽略的坑。
四、数据模型与序列化
4.1 定义可序列化的数据模型
使用 kotlinx.serialization 定义数据模型:
kotlin
@Serializable
data class User(
val id: String,
val name: String,
val email: String,
val avatarUrl: String? = null,
)
设计原则 :数据模型的字段应该与 API 响应一一对应。使用 @SerialName 注解处理命名差异:
kotlin
@Serializable
data class User(
val id: String,
@SerialName("full_name") val name: String,
@SerialName("email_address") val email: String,
)
可空性处理 :如果某个字段可能不存在,声明为可空类型并提供默认值。ignoreUnknownKeys = true 处理额外的字段,可空类型处理缺失的字段。
4.2 处理嵌套结构
真实 API 的响应通常是嵌套的:
kotlin
@Serializable
data class ApiResponse<T>(
val code: Int,
val message: String,
val data: T?,
)
@Serializable
data class Paginated<T>(
val items: List<T>,
val total: Int,
val page: Int,
)
泛型数据类配合 kotlinx.serialization 可以处理大部分 API 响应结构。
五、Repository 模式
5.1 为什么需要 Repository
直接在 Composable 中调用网络请求会导致几个问题:UI 层与网络细节耦合、无法复用请求逻辑、测试困难、状态管理混乱。
Repository 模式在 UI 层和数据层之间插入一个抽象层。它的职责是:
封装数据来源。网络请求、本地缓存、数据库查询,对 UI 层暴露统一的接口。
转换数据模型。将 API 的 DTO(Data Transfer Object)转换为领域模型。DTO 服务于 API 的序列化需求,领域模型服务于业务逻辑的需求。两者不应该混用。
统一错误处理。将网络异常转换为领域层的错误类型。
5.2 实现 Repository
kotlin
interface UserRepository {
suspend fun getUser(id: String): NetworkResult<User>
suspend fun getUsers(page: Int = 1): NetworkResult<List<User>>
}
class UserRepositoryImpl(
private val client: HttpClient,
) : UserRepository {
override suspend fun getUser(id: String): NetworkResult<User> {
return safeApiCall {
client.get("https://api.example.com/users/$id").body()
}
}
override suspend fun getUsers(page: Int): NetworkResult<List<User>> {
return safeApiCall {
client.get("https://api.example.com/users") {
parameter("page", page)
}.body()
}
}
}
接口与实现分离 :接口定义在 commonMain,实现也在 commonMain(因为 Ktor 本身就是跨平台的)。接口的存在是为了依赖注入和测试替换。
5.3 Repository 与 UI 的集成
在 Composable 中,Repository 的调用应该放在 LaunchedEffect 或 rememberCoroutineScope 中:
kotlin
@Composable
fun UserScreen(userId: String) {
var user by remember { mutableStateOf<User?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(userId) {
isLoading = true
error = null
when (val result = repository.getUser(userId)) {
is NetworkResult.Success -> user = result.data
is NetworkResult.Error -> error = result.message
}
isLoading = false
}
when {
isLoading -> CircularProgressIndicator()
error != null -> Text("加载失败: $error")
user != null -> UserDetail(user!!)
}
}
但这不是最终形态。第6课引入 ViewModel 后,网络请求逻辑会从 Composable 中移出,状态管理会更清晰。Repository 模式的真正价值在 ViewModel 层才能完全体现。
六、习题与参考答案
本课习题分为三类:概念理解 (1-4 题)、代码实践 (5-10 题)、综合设计(11-13 题)。
概念理解
习题 1:Ktor 引擎抽象的价值
题目:为什么 Ktor 要把客户端 API 和底层引擎分离?这种设计在 CMP 项目中有什么优势?
参考答案 :分离后,commonMain 中的请求逻辑不依赖任何平台特有的网络 API。每个平台可以选择最优的底层实现:Android 用 OkHttp(成熟的连接池和拦截器生态),iOS 用 Darwin(基于 NSURLSession,与系统网络栈集成),桌面端用 CIO(纯 Kotlin 协程实现,无额外依赖)。优势是共享代码完全平台无关,同时各平台保持最优性能。
习题 2:HttpClient 的复用
题目:为什么 Ktor 官方建议复用 HttpClient 实例,而不是每次请求都创建新的?
参考答案:创建 HttpClient 涉及线程池初始化、连接池初始化、引擎配置等开销。频繁创建会消耗大量资源,且无法利用连接复用(keep-alive),每次请求都需要重新建立 TCP 连接,性能极差。
习题 3:CancellationException 的处理
题目 :在 safeApiCall 的 catch 块中,为什么 CancellationException 必须重新抛出?
参考答案 :CancellationException 是 Kotlin 协程取消机制的一部分。当协程被取消时,挂起函数会抛出这个异常来终止执行。如果被捕获并吞掉,协程的取消信号会被阻断,可能导致资源泄漏和逻辑错误。正确的做法是捕获后立即重新抛出,让取消机制正常工作。
习题 4:Repository 的职责边界
题目:Repository 层应该包含哪些逻辑?不应该包含哪些逻辑?
参考答案:应该包含:网络请求的封装、错误转换、DTO 到领域模型的映射、缓存策略、数据来源的抽象。不应该包含:UI 状态管理(isLoading、error 状态)、业务规则(这些应该放在 ViewModel 或 UseCase 层)、平台特定的 UI 逻辑。
代码实践
习题 5:添加 Ktor 依赖
题目 :在项目的 libs.versions.toml 和 build.gradle.kts 中添加 Ktor 客户端、OkHttp 引擎、Darwin 引擎和 ContentNegotiation 的依赖。
参考答案:
toml
[versions]
ktor = "3.4.1"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlin
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
习题 6:创建配置好的 HttpClient
题目:创建一个 HttpClient 实例,启用 ContentNegotiation,配置 JSON 忽略未知字段和宽松模式。
参考答案:
kotlin
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
val httpClient = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
})
}
}
习题 7:定义数据模型
题目:为以下 JSON 定义一个可序列化的数据类:
json
{
"user_id": 123,
"username": "zhangsan",
"email": "zhangsan@example.com",
"profile": {
"avatar": "https://example.com/avatar.jpg",
"bio": "Hello"
},
"created_at": "2024-01-01T00:00:00Z"
}
参考答案:
kotlin
@Serializable
data class User(
@SerialName("user_id") val id: Int,
val username: String,
val email: String,
val profile: Profile? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class Profile(
val avatar: String,
val bio: String? = null,
)
习题 8:发起 GET 请求
题目 :实现一个函数,从 https://jsonplaceholder.typicode.com/posts 获取帖子列表,返回 List<Post>。
参考答案:
kotlin
@Serializable
data class Post(
val id: Int,
val title: String,
val body: String,
val userId: Int,
)
suspend fun fetchPosts(): List<Post> {
return httpClient.get("https://jsonplaceholder.typicode.com/posts").body()
}
习题 9:实现 safeApiCall
题目 :实现一个通用的 safeApiCall 函数,将挂起函数的执行结果包装为 NetworkResult。
参考答案:
kotlin
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val message: String, val cause: Throwable? = null) : NetworkResult<Nothing>()
}
suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> {
return try {
NetworkResult.Success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
NetworkResult.Error(e.message ?: "网络请求失败", e)
}
}
习题 10:实现 Repository
题目 :为 Posts API 实现一个 PostRepository 接口和实现,包含获取所有帖子和按 ID 获取单个帖子。
参考答案:
kotlin
interface PostRepository {
suspend fun getAllPosts(): NetworkResult<List<Post>>
suspend fun getPost(id: Int): NetworkResult<Post>
}
class PostRepositoryImpl(
private val client: HttpClient,
) : PostRepository {
override suspend fun getAllPosts(): NetworkResult<List<Post>> {
return safeApiCall {
client.get("https://jsonplaceholder.typicode.com/posts").body()
}
}
override suspend fun getPost(id: Int): NetworkResult<Post> {
return safeApiCall {
client.get("https://jsonplaceholder.typicode.com/posts/$id").body()
}
}
}
综合设计
习题 11:完整的帖子列表界面
题目:结合第3课的 LazyColumn 和第4课的导航,实现一个帖子列表界面,点击帖子导航到详情页。
参考答案:
kotlin
@Composable
fun PostListScreen(
repository: PostRepository,
onPostClick: (Int) -> Unit,
) {
var posts by remember { mutableStateOf<List<Post>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
when (val result = repository.getAllPosts()) {
is NetworkResult.Success -> posts = result.data
is NetworkResult.Error -> { /* 处理错误 */ }
}
isLoading = false
}
if (isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn {
items(posts, key = { it.id }) { post ->
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp)
.clickable { onPostClick(post.id) },
) {
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.titleMedium)
Text(post.body, maxLines = 2,
overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
习题 12:错误处理与重试
题目:在习题 11 的基础上,添加错误状态显示和一个"重试"按钮。
参考答案:
kotlin
@Composable
fun PostListScreen(
repository: PostRepository,
onPostClick: (Int) -> Unit,
) {
var posts by remember { mutableStateOf<List<Post>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var retryTrigger by remember { mutableIntStateOf(0) }
LaunchedEffect(retryTrigger) {
isLoading = true
error = null
when (val result = repository.getAllPosts()) {
is NetworkResult.Success -> posts = result.data
is NetworkResult.Error -> error = result.message
}
isLoading = false
}
when {
isLoading -> CircularProgressIndicator()
error != null -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("加载失败: $error")
Button(onClick = { retryTrigger++ }) { Text("重试") }
}
}
else -> { /* 列表 */ }
}
}
retryTrigger 作为 LaunchedEffect 的 key,点击重试时 key 变化,触发重新加载。
习题 13:带缓存的 Repository
题目 :为 PostRepository 添加内存缓存:第一次请求后缓存结果,后续请求直接返回缓存,除非显式要求刷新。
参考答案:
kotlin
class CachedPostRepositoryImpl(
private val client: HttpClient,
) : PostRepository {
private var cachedPosts: List<Post>? = null
override suspend fun getAllPosts(): NetworkResult<List<Post>> {
cachedPosts?.let {
return NetworkResult.Success(it)
}
val result = safeApiCall {
client.get("https://jsonplaceholder.typicode.com/posts").body<List<Post>>()
}
if (result is NetworkResult.Success) {
cachedPosts = result.data
}
return result
}
override suspend fun getPost(id: Int): NetworkResult<Post> {
// 优先从缓存中查找
cachedPosts?.find { it.id == id }?.let {
return NetworkResult.Success(it)
}
return safeApiCall {
client.get("https://jsonplaceholder.typicode.com/posts/$id").body()
}
}
}
延伸思考:内存缓存只适用于单次会话。如果需要跨会话持久化,需要引入 SQLDelight(第7课平台适配中会涉及)。缓存的失效策略(TTL、手动刷新)是 Repository 层的重要设计决策。
七、本课小结
Ktor 的核心设计 :客户端与引擎分离。commonMain 中编写请求逻辑,各平台选择最优引擎。OkHttp 用于 Android 和桌面端,Darwin 用于 iOS。
HttpClient 配置 :expectSuccess = true 让错误状态码自动抛异常,ContentNegotiation + json() 处理序列化,ignoreUnknownKeys 保证对接真实 API 的容错性。HttpClient 应复用而非每次创建。
请求与响应 :client.get() 是挂起函数,.body() 自动反序列化。错误处理用 safeApiCall 封装为 NetworkResult,注意 CancellationException 必须重新抛出。
Repository 模式 :隔离网络细节与 UI,统一错误处理,转换数据模型。Repository 接口定义在 commonMain,实现也在 commonMain(因为 Ktor 本身跨平台)。
下一步:Repository 提供了数据,但 Composable 中的状态管理(isLoading、error、数据)仍然是分散的。第6课将引入 ViewModel,把这些状态集中管理。
八、下一课预告
第6课 状态管理与架构