← 返回学习路线
🌐

第 4 章:网络层(Ktor Client)

使用 Ktor Client 构建跨平台网络层,掌握多平台引擎配置、JSON 解析、认证和错误处理

🌐 Ktor Client 简介

Ktor 是 JetBrains 开发的异步框架,其中 Ktor Client 是专为 KMP 设计的 HTTP 客户端库。它使用插件(Plugin)架构,按需引入功能模块。

  • 引擎(Engine) 实际发送 HTTP 请求的底层实现。Android 使用 OkHttp,iOS/macOS 使用 Darwin(基于 NSURLSession),JVM 使用 CIO,浏览器使用 Js。在 commonMain 中的 API 完全一致,引擎差异对上层透明。
  • 插件(Plugin) Ktor 的功能扩展机制,如 ContentNegotiation(序列化)、Logging(日志)、Auth(认证)、HttpTimeout(超时)等,通过 install() 安装到 HttpClient。

4.1 依赖配置

shared/build.gradle.kts sourceSets { commonMain.dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) implementation(libs.ktor.client.logging) implementation(libs.ktor.client.auth) } androidMain.dependencies { implementation(libs.ktor.client.okhttp) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } }

⚙️ 多平台引擎配置

4.2 expect/actual HttpClient 工厂

推荐使用 expect/actual 模式创建引擎,让 commonMain 中的代码对引擎无感知:

commonMain — HttpClientFactory.kt import io.ktor.client.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.* // expect:声明平台特有的引擎构造方式 expect fun httpClientEngine(): HttpClientEngine // commonMain 中统一创建 HttpClient fun createHttpClient(): HttpClient { return HttpClient(httpClientEngine()) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = true }) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.INFO } install(HttpTimeout) { requestTimeoutMillis = 30_000 connectTimeoutMillis = 10_000 socketTimeoutMillis = 30_000 } defaultRequest { url("https://api.example.com/v1/") header("Accept", "application/json") } } }
androidMain import io.ktor.client.engine.okhttp.* actual fun httpClientEngine(): HttpClientEngine = OkHttp.create { config { retryOnConnectionFailure(true) followRedirects(true) } }
iosMain import io.ktor.client.engine.darwin.* actual fun httpClientEngine(): HttpClientEngine = Darwin.create { configureRequest { // NSURLSessionConfiguration 配置 setAllowsCellularAccess(true) } }

📡 基础 HTTP 请求

4.3 GET 请求

commonMain import io.ktor.client.call.* import io.ktor.client.request.* class NewsApiImpl(private val client: HttpClient) : NewsApi { // 简单 GET 请求 override suspend fun getArticles(page: Int): List<ArticleDto> { return client.get("articles") { parameter("page", page) parameter("limit", 20) }.body<ArticlesResponse>().articles } // 带路径参数的 GET override suspend fun getArticleById(id: String): ArticleDto { return client.get("articles/$id").body() } // 带请求头的 GET override suspend fun getPrivateData(token: String): UserData { return client.get("user/data") { headers { append("Authorization", "Bearer $token") } }.body() } }

4.4 POST / PUT / DELETE 请求

commonMain import io.ktor.client.request.* import io.ktor.http.* // POST — 发送 JSON body suspend fun createArticle(request: CreateArticleRequest): ArticleDto { return client.post("articles") { contentType(ContentType.Application.Json) setBody(request) }.body() } // PUT — 更新资源 suspend fun updateArticle(id: String, request: UpdateArticleRequest): ArticleDto { return client.put("articles/$id") { contentType(ContentType.Application.Json) setBody(request) }.body() } // DELETE — 删除资源 suspend fun deleteArticle(id: String) { client.delete("articles/$id") } // 上传文件(MultiPart) suspend fun uploadImage(bytes: ByteArray, filename: String): String { return client.submitFormWithBinaryData( url = "upload/image", formData = formData { append("file", bytes, Headers.build { append(HttpHeaders.ContentDisposition, "filename=\"$filename\"") append(HttpHeaders.ContentType, "image/jpeg") }) } ).body<UploadResponse>().url }

📦 ContentNegotiation + JSON 解析

4.5 响应数据模型

commonMain — DTO 定义 import kotlinx.serialization.* import kotlinx.datetime.* // 分页响应包装 @Serializable data class PagedResponse<T>( val data: List<T>, val total: Int, val page: Int, @SerialName("has_next") val hasNext: Boolean ) @Serializable data class ArticleDto( val id: Int, val title: String, val content: String, @SerialName("cover_url") val coverUrl: String? = null, @SerialName("created_at") val createdAt: String, // ISO 8601 字符串,再手动解析为 Instant val author: AuthorDto ) @Serializable data class AuthorDto( val id: Int, val name: String, val avatar: String? = null ) // API 错误响应 @Serializable data class ApiError( val code: Int, val message: String )

4.6 自定义 JSON 实例

commonMain val AppJson = Json { ignoreUnknownKeys = true # 忽略未知字段,防止 API 升级导致崩溃 isLenient = true # 宽松模式:允许不带引号的字符串 encodeDefaults = true # 序列化时包含默认值字段 coerceInputValues = true # null 值强制转换为默认值 prettyPrint = false # 生产环境不美化输出 }

🔒 认证:Bearer Token

4.7 使用 Auth 插件

commonMain — 带 Token 刷新的认证配置 import io.ktor.client.plugins.auth.* import io.ktor.client.plugins.auth.providers.* fun createAuthenticatedClient(tokenStorage: TokenStorage): HttpClient { return HttpClient(httpClientEngine()) { install(Auth) { bearer { // 加载已保存的 Token loadTokens { val tokens = tokenStorage.getTokens() BearerTokens( accessToken = tokens.accessToken, refreshToken = tokens.refreshToken ) } // Token 过期后自动刷新 refreshTokens { val refreshToken = oldTokens?.refreshToken ?: return@refreshTokens null try { val response = client.post("auth/refresh") { markAsRefreshTokenRequest() // 防止无限刷新循环 setBody(RefreshRequest(refreshToken)) }.body<TokenResponse>() val newTokens = BearerTokens( accessToken = response.accessToken, refreshToken = response.refreshToken ) tokenStorage.saveTokens(newTokens) newTokens } catch (e: Exception) { tokenStorage.clearTokens() // Token 刷新失败,清除登录状态 null } } // 401 时才尝试刷新(避免不必要的请求) sendWithoutRequest { request -> request.url.host == "api.example.com" } } } } }

4.8 TokenStorage — 跨平台存储

commonMain interface TokenStorage { suspend fun getTokens(): Tokens? suspend fun saveTokens(tokens: BearerTokens) suspend fun clearTokens() } // androidMain 实现:使用 EncryptedSharedPreferences class AndroidTokenStorage(context: Context) : TokenStorage { private val prefs = EncryptedSharedPreferences.create( context, "tokens", MasterKey.Builder(context).build(), EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) override suspend fun getTokens() = prefs.getString("access", null)?.let { Tokens(it, prefs.getString("refresh", "")!!) } override suspend fun saveTokens(tokens: BearerTokens) { prefs.edit().putString("access", tokens.accessToken).putString("refresh", tokens.refreshToken).apply() } override suspend fun clearTokens() { prefs.edit().clear().apply() } }

⚠️ 错误处理

4.9 自定义 API 异常

commonMain // 自定义异常体系 sealed class ApiException(message: String) : Exception(message) { class Unauthorized : ApiException("请重新登录") class NotFound(resource: String) : ApiException("$resource 不存在") class ServerError(code: Int, msg: String) : ApiException("服务器错误 $code: $msg") class NetworkError(cause: Throwable) : ApiException("网络异常:${cause.message}") class Unknown(cause: Throwable) : ApiException("未知错误:${cause.message}") } // HttpResponseValidator — 统一处理 HTTP 错误状态码 fun HttpClientConfig<*>.installErrorHandler() { HttpResponseValidator { validateResponse { response -> when (response.status.value) { 401 -> throw ApiException.Unauthorized() 404 -> throw ApiException.NotFound("resource") in 500..599 -> { val error = try { response.body<ApiError>() } catch (e: Exception) { ApiError(response.status.value, "服务器错误") } throw ApiException.ServerError(error.code, error.message) } } } handleResponseExceptionWithRequest { cause, _ -> throw when (cause) { is ApiException -> cause is UnresolvedAddressException, is ConnectTimeoutException -> ApiException.NetworkError(cause) else -> ApiException.Unknown(cause) } } } }

🏭 封装 ApiClient

4.10 完整 ApiClient 实现

commonMain — NewsApiImpl.kt interface NewsApi { suspend fun getArticles(page: Int = 1, pageSize: Int = 20): PagedResponse<ArticleDto> suspend fun getArticle(id: String): ArticleDto suspend fun searchArticles(query: String): List<ArticleDto> } class NewsApiImpl(private val client: HttpClient) : NewsApi { override suspend fun getArticles(page: Int, pageSize: Int): PagedResponse<ArticleDto> = client.get("articles") { parameter("page", page) parameter("page_size", pageSize) }.body() override suspend fun getArticle(id: String): ArticleDto = client.get("articles/$id").body() override suspend fun searchArticles(query: String): List<ArticleDto> = client.get("articles/search") { parameter("q", query) }.body<PagedResponse<ArticleDto>>().data }

🗃️ Repository 模式

4.11 Repository 封装网络 + 缓存

commonMain class NewsRepositoryImpl( private val api: NewsApi, private val localDataSource: NewsLocalDataSource ) : NewsRepository { override fun getArticles(): Flow<List<Article>> = localDataSource.getAllArticles() .map { it.map { entity -> entity.toDomain() } } override suspend fun refreshArticles() { try { val dtos = api.getArticles().data localDataSource.upsertAll(dtos.map { it.toEntity() }) } catch (e: ApiException) { // 网络错误:本地缓存仍可用,向上传播让 ViewModel 处理 throw e } } }

🎯 实践任务

  1. 实现 createHttpClient(),配置 ContentNegotiation + Logging + Timeout
  2. 创建一个 ArticleDto 模型,字段包含 id/title/published_at
  3. 实现 getArticles() GET 请求,返回 List<ArticleDto>
  4. 添加 HttpResponseValidator,将 4xx/5xx 映射为自定义异常