【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层

本课目标:理解 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课 状态管理与架构

相关推荐
时速GEO系统1 小时前
深圳科飞时速推出桌面级AI应用软件 -初元AI 24天内迭代三个版本,面向零基础用户提供建站与业务软件生成能力
网络·人工智能
yt004yt2 小时前
园区 VOC 绿岛集中治理项目,管网与监测系统设计要点
大数据·学习
一条破秋裤2 小时前
06_从设备读数据_字符设备read_write流程
学习
茶底世界之下2 小时前
视频预览切换为何会闪回旧帧:用 generation + mode identity 管住异步回调
ios·swift
ao-weilai2 小时前
MySQL数据库:内置函数
android·数据库·mysql
SWAGGY..2 小时前
【C++进阶】:(7)红黑树的原理与 C++ 实现:结构设计、插入调整及性质验证
android·java·开发语言·c++·算法
Android打工仔2 小时前
CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程?
android·kotlin·源码阅读
m4Rk_2 小时前
【论文阅读】Agent 记忆机制(92):EMPO²——让 Memory 从经验复用走向主动探索
论文阅读·人工智能·学习·开源·github
2401_868534783 小时前
安全故障分析
服务器·网络·安全