🌐 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
}
}
}
🎯 实践任务
- 实现
createHttpClient(),配置 ContentNegotiation + Logging + Timeout - 创建一个
ArticleDto模型,字段包含id/title/published_at - 实现
getArticles()GET 请求,返回List<ArticleDto> - 添加
HttpResponseValidator,将 4xx/5xx 映射为自定义异常