前面十一篇,我们已经陆续把 Ktor Client 网络层中的主要知识点拆开讲了一遍:

HttpClient
Engine
ContentNegotiation
DefaultRequest
Provider
NetworkClient
ApiResponse<T>
AppError
Connectivity
Logging
HttpTimeout
HttpRequestRetry
Custom Client Plugin
Auth

单独理解这些概念并不算特别困难。

真正进入项目之后,难的是另外一个问题:

这些东西最后到底应该怎么组合?

比如:

ApiService 和 NetworkClient 谁负责什么?

Provider 为什么不能直接写成普通变量?

Plugin 和 NetworkClient 有什么区别?

为什么 HttpClient 要长期复用?

什么时候需要多个 HttpClient?

Token、Language、NetworkState 应该放在哪?

Android、iOS、Web 最后又是怎么落到不同 Engine 上的?

所以这一篇不再继续增加新的 Ktor API。

我们把前面所有内容重新收回来,最终形成一套完整的:

KMP + Ktor 网络层架构。


一、先看最终架构

先不要急着看代码。

把整个网络层压缩成一张图:

                        UI
                         ↓
                     ViewModel
                         ↓
                     Repository
                         ↓
                     ApiService
                         ↓
                   NetworkClient
                         ↓
                   NetworkClients
                   /            \
                  ↓              ↓
             apiClient      refreshClient
                  │              │
                  └──────┬───────┘
                         ↓
                     HttpClient
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
       Config         Provider        Plugin
          │              │              │
          ↓              ↓              ↓
       BaseUrl          Token      ContentNegotiation
       Timeout         Language       DefaultRequest
       ...             Network        Logging
                         ...          HttpTimeout
                                      Retry
                                      Auth
                                      Custom Plugin
                         │
                         ↓
                       Engine
              ┌──────────┼──────────┐
              ↓          ↓          ↓
           Android      iOS        Web
           OkHttp      Darwin     JS/Wasm
              └──────────┼──────────┘
                         ↓
                        HTTP

看起来组件很多,但实际上只需要回答六个问题:

ApiService
↓
我要调用哪个业务接口?


NetworkClient
↓
项目里的 HTTP 请求应该怎么统一执行?


HttpClient
↓
这一条网络通道拥有哪些能力?


Provider
↓
当前运行期状态是什么?


Plugin
↓
HTTP 生命周期中需要增加什么横切能力?


Engine
↓
这个平台最终怎么真正发送 HTTP?

这六个问题,就是整套架构的主线。


二、ApiService:描述“我要调用哪个接口”

先从最靠近业务的一层开始。

例如:

class UserApiService(
    private val networkClient: NetworkClient,
) {

    suspend fun getUser(
        userId: Long,
    ): User {
        return networkClient.get(
            path = "/users/$userId",
        )
    }
}

订单模块:

class OrderApiService(
    private val networkClient: NetworkClient,
) {

    suspend fun getOrders(): List<Order> {
        return networkClient.get(
            path = "/orders",
        )
    }

    suspend fun createOrder(
        request: CreateOrderRequest,
    ): Order {
        return networkClient.post(
            path = "/orders",
            body = request,
        )
    }
}

ApiService 最核心的职责就是:

描述业务接口。

它应该关心:

接口 Path

Query 参数

Request DTO

Response DTO

接口属于哪个业务模块

例如:

UserApiService
OrderApiService
RobotApiService
FileApiService

而不应该关心:

Token 怎么刷新

Timeout 是多少

JSON 怎么解析

有没有网络

IOException 怎么映射

Android 用什么 Engine

这些都不是 ApiService 的职责。


三、NetworkClient:项目自己的统一请求边界

NetworkClient 不是 Ktor 官方类。

它是项目为了统一网络规则而增加的一层。

比如业务代码如果直接大量使用:

client.get(...)
client.post(...)

很快就会出现:

UserApiService 自己判断网络

OrderApiService 自己解析 ApiResponse

ProductApiService 自己 catch Timeout

RobotApiService 自己处理业务 code

最终每个 ApiService 都有一套网络逻辑。

所以我们增加:

ApiService
↓
NetworkClient
↓
HttpClient

NetworkClient 负责项目自己的统一规则,例如:

Connectivity Pre-check

get / post / put / delete 统一封装

HttpResponse → ApiResponse<T>

业务 code 判断

data 解包

Throwable → AppError

可以把它理解成:

Ktor HttpClient 提供通用 HTTP 能力,NetworkClient 定义我们这个项目怎么使用这些能力。


四、一个基础 NetworkClient

假设后端统一返回:

{
    "code": 0,
    "msg": "",
    "data": {}
}

对应:

@Serializable
data class ApiResponse<T>(
    val code: Int,
    val msg: String,
    val data: T? = null,
)

NetworkClient 可以形成:

class NetworkClient(
    private val client: HttpClient,
    private val connectivityProvider: NetworkConnectivityProvider,
    private val exceptionMapper: ExceptionMapper,
) {

    suspend inline fun <reified T> get(
        path: String,
        noinline block: HttpRequestBuilder.() -> Unit = {},
    ): T {
        return execute {
            client.get(path) {
                block()
            }
        }
    }

    suspend inline fun <
        reified T,
        reified B
    > post(
        path: String,
        body: B,
        noinline block: HttpRequestBuilder.() -> Unit = {},
    ): T {
        return execute {
            client.post(path) {
                setBody(body)
                block()
            }
        }
    }

    suspend inline fun <reified T> execute(
        request: () -> HttpResponse,
    ): T {

        if (!connectivityProvider.isNetworkAvailable) {
            throw AppException(
                AppError.Network
            )
        }

        return try {

            val response =
                request()

            val apiResponse =
                response.body<ApiResponse<T>>()

            if (apiResponse.code != 0) {
                throw ApiException(
                    code = apiResponse.code,
                    message = apiResponse.msg,
                )
            }

            apiResponse.data
                ?: throw ApiException(
                    code = -1,
                    message = "Response data is null",
                )

        } catch (
            cancellation:
                CancellationException
        ) {

            throw cancellation

        } catch (
            throwable: Throwable
        ) {

            throw AppException(
                exceptionMapper.map(
                    throwable
                )
            )
        }
    }
}

这里最值得注意的是:

NetworkClient

解决的是:

项目语义

而不是所有网络能力。


五、不要把 NetworkClient 写成 God Class

随着项目增长,很容易开始往里面塞:

Token Refresh
Logging
Timeout
Retry
JSON
Header
Cookie
Signing
Encryption
Cache
Connectivity
异常处理
上传
下载

最后形成:

NetworkClient
├── everything
└── everything else

这就失去了分层意义。

应该保持:

NetworkClient
↓
项目级 Request 执行规则


Plugin
↓
HTTP 生命周期中的横切能力


Provider
↓
运行期动态状态


HttpClient
↓
组合这些能力

一句话:

NetworkClient 管项目规则,Plugin 管 HTTP 生命周期。


六、HttpClient:一条长期复用的“网络能力通道”

Ktor 真正的核心对象还是:

HttpClient

它不是简单的:

HTTP 请求工具

更准确地说,它代表:

一条配置完成、长期复用的网络能力通道。

例如一个 apiClient 可能拥有:

BaseUrl

公共 Header

JSON

Logging

Timeout

Retry

Bearer Auth

Custom Plugin

这些能力组合在一起,构成:

apiClient

所以不要每一次请求:

val client = HttpClient()

client.get(...)

client.close()

HttpClient 背后还有:

Engine
连接复用
Plugin 状态
Auth Token Cache
Cookie
Coroutine / 平台资源

正常设计应该:

Application 创建
↓
创建 HttpClient
↓
长期复用
↓
Application 生命周期结束
↓
close()

七、长期复用不等于整个 App 只能有一个 Client

这一点非常重要。

我们一直强调:

HttpClient 要复用

但并不意味着:

整个 App 必须只有一个 HttpClient

更准确的规则是:

一个明确的网络配置域,对应一个长期复用的 HttpClient。

例如:

apiClient
↓
普通业务 API


refreshClient
↓
Token Refresh


thirdPartyClient
↓
第三方服务


uploadClient
↓
特殊上传通道

但是不要:

UserApi 一个 Client

OrderApi 一个 Client

ProductApi 一个 Client

这通常属于过度拆分。


八、为什么 apiClient 和 refreshClient 很适合拆开?

apiClient

业务 API

Bearer Auth

AccessToken

401 Refresh

普通 Retry Policy

而 refreshClient

Refresh API

不安装 Bearer Auth

不携带普通 AccessToken

不会再次触发自身 Refresh

于是:

apiClient
↓
401
↓
refreshClient
↓
获取新 Token
↓
apiClient 重发原 Request

职责非常清楚。

需要强调:

双 Client 是一种架构选择,不是 Ktor Auth 的强制要求。

Ktor 本身也支持单 Client 配合 Refresh Request 标记完成 Token 刷新。

我们拆 Client,是因为:

网络责任不同

而不是因为:

Ktor 要求这么做

九、NetworkClients:统一管理不同网络通道

如果项目存在:

api

refresh

甚至以后还有:

upload
download
thirdParty

可以增加一层:

class NetworkClients(
    private val configs: NetworkConfigs,
    private val tokenProvider: TokenProvider,
    private val commonHeadersProvider: CommonHeadersProvider,
    private val connectivityProvider: NetworkConnectivityProvider,
    private val exceptionMapper: ExceptionMapper,
) {

    val refresh: NetworkClient by lazy {
        createRefreshNetworkClient()
    }

    val api: NetworkClient by lazy {
        createApiNetworkClient(
            refreshClient = refresh,
        )
    }
}

它的职责不是:

执行请求

而是:

组织整个项目有哪些网络责任域。


十、Config:描述“这个 Client 是什么”

例如:

data class NetworkConfig(
    val baseUrl: String,
    val requestTimeoutMillis:
        Long = 15_000,
    val connectTimeoutMillis:
        Long = 10_000,
    val socketTimeoutMillis:
        Long = 15_000,
)

多个 Client:

data class NetworkConfigs(
    val api: NetworkConfig,
    val refresh: NetworkConfig,
)

Config 适合存:

BaseUrl

Timeout

固定 Header

Client 类型相关配置

因为这些东西回答的是:

这个 Client 本身是什么?


十一、Provider:描述“现在是什么”

前面我们已经出现了很多 Provider:

TokenProvider

NetworkConnectivityProvider

CommonHeadersProvider

LanguageProvider

TenantProvider

它们本质上属于同一类设计:

一个长期存在、提供当前运行期状态的数据源。

例如:

interface LanguageProvider {
    val currentLanguage: String
}

重点不是:

Provider 里面有一个变量

而是:

HttpClient
↓
长期持有 Provider 引用

Provider
↓
状态可以继续变化

下一次 Request
↓
重新读取最新状态

所以:

Client 持有的是 Provider 引用,不是创建 Client 那一刻的值。


十二、Config、Provider、Request 三种作用域

这也是前面整个系列非常重要的一套模型。

可以记成:

Config
↓
这个 Client 是什么?


Provider
↓
现在默认是什么?


Request
↓
这一笔请求我要什么?

例如:

Config

BaseUrl =
https://api.example.com

Provider:

Language =
zh-CN

某一次特殊 Request:

Language =
en-US

于是:

Client Config
↓
长期稳定


Provider
↓
运行期变化


Request Override
↓
一次性变化

三个层级不会冲突。


十三、Provider 不等于“谁都可以修改的全局变量”

例如:

NetworkConnectivityProvider

更合理的结构:

Platform NetworkMonitor
↓
Writer

NetworkConnectivityProvider
↓
State Holder

NetworkClient / Retry
↓
Reader

对网络层只暴露:

interface NetworkConnectivityProvider {
    val isNetworkAvailable: Boolean
}

而真正的:

update(...)

留在具体实现或网络监控模块内部。

这样就不会变成:

ViewModel
Repository
ApiService
↓
谁都可以随便改 NetworkState

Provider 是:

状态来源。

不是:

全局可写变量。


十四、Plugin:把横切能力挂进 HttpClient 生命周期

前面第十篇已经讲过:

Ktor
≠
一个大号 OkHttp Interceptor

Ktor 更核心的模型是:

HttpClient Lifecycle
+
Plugin
+
Hook

官方 Plugin:

ContentNegotiation

DefaultRequest

Logging

HttpTimeout

HttpRequestRetry

Auth

Custom Plugin:

Trace

Signing

Encryption

特殊协议

Plugin 适合解决:

跨多个 Request、并且与 HTTP 生命周期有关的能力。


十五、Plugin 和 NetworkClient 怎么判断放哪?

这是项目里特别容易纠结的问题。

可以使用一个简单判断:

如果逻辑是:

这个项目的 Response
必须先检查 code
再解包 data

放:

NetworkClient

因为这是:

项目业务协议

如果逻辑是:

每个 Request
都要增加 TraceId

放:

Plugin / DefaultRequest

因为这是:

HTTP 横切能力

如果逻辑是:

401
↓
Refresh Token
↓
重新发送

放:

Auth Plugin

因为它与:

Request / Response 生命周期

直接相关。

所以可以记:

NetworkClient 负责项目语义,Plugin 负责生命周期横切能力。


十六、HttpClient 最终就是这些能力的组合器

例如:

fun createApiHttpClient(
    config: NetworkConfig,
    tokenProvider: TokenProvider,
    commonHeadersProvider:
        CommonHeadersProvider,
    connectivityProvider:
        NetworkConnectivityProvider,
    refreshClient: HttpClient,
): HttpClient {

    return createPlatformHttpClient {

        expectSuccess =
            true

        install(
            ContentNegotiation
        ) {
            json(
                createJson()
            )
        }

        install(
            DefaultRequest
        ) {

            url(
                config.baseUrl
            )

            headers
                .appendIfNameAbsent(
                    "Language",
                    commonHeadersProvider
                        .language,
                )

            headers
                .appendIfNameAbsent(
                    "App-Version",
                    commonHeadersProvider
                        .appVersion,
                )
        }

        install(
            HttpRequestRetry
        ) {

            maxRetries =
                2

            retryIf {
                    request,
                    response,
                ->

                request.method ==
                    HttpMethod.Get &&
                    response.status.value
                        in 500..599 &&
                    connectivityProvider
                        .isNetworkAvailable
            }

            exponentialDelay()
        }

        install(
            HttpTimeout
        ) {

            requestTimeoutMillis =
                config
                    .requestTimeoutMillis

            connectTimeoutMillis =
                config
                    .connectTimeoutMillis

            socketTimeoutMillis =
                config
                    .socketTimeoutMillis
        }

        install(
            Logging
        ) {

            logger =
                createHttpLogger()

            level =
                LogLevel.ALL

            sanitizeHeader {
                it ==
                    HttpHeaders.Authorization
            }

            bodyFilter =
                createSafeBodyFilter()
        }

        install(Auth) {

            bearer {

                loadTokens {
                    tokenProvider
                        .getBearerTokens()
                }

                refreshTokens {
                    refreshAccessToken(
                        oldTokens =
                            oldTokens,
                        refreshClient =
                            refreshClient,
                        tokenProvider =
                            tokenProvider,
                    )
                }
            }
        }
    }
}

这里最值得观察的已经不是某一个 API。

而是:

HttpClient
↓
把各种能力组合起来

所以可以把 HttpClient 理解成:

网络能力容器。


十七、refreshClient 配置明显更简单

例如:

fun createRefreshHttpClient(
    config: NetworkConfig,
): HttpClient {

    return createPlatformHttpClient {

        expectSuccess =
            true

        install(
            ContentNegotiation
        ) {
            json(
                createJson()
            )
        }

        install(
            HttpTimeout
        ) {
            requestTimeoutMillis =
                config
                    .requestTimeoutMillis
        }

        install(
            Logging
        ) {
            logger =
                createHttpLogger()

            bodyFilter =
                createSafeBodyFilter()
        }

        // 不安装 Bearer Auth
    }
}

于是:

apiClient
↓
业务认证通道


refreshClient
↓
认证刷新通道

两个 Client 的差异来自:

职责

而不是:

为了拆而拆

十八、哪些公共配置可以提取?

如果两个 Client 都需要:

ContentNegotiation

Logging

基础 Timeout

可以:

fun HttpClientConfig<*>.
    installCommonPlugins() {

    install(
        ContentNegotiation
    ) {
        json(
            createJson()
        )
    }

    install(
        Logging
    ) {
        logger =
            createHttpLogger()
    }
}

然后:

createPlatformHttpClient {

    installCommonPlugins()

    install(Auth) {
        ...
    }
}

但是不要为了:

“不要出现重复代码”

把:

Auth
Retry Policy
BaseUrl
特殊 Header

全部强行揉进一个万能 Factory。

还是那句话:

抽公共,但不要封死变化。


十九、Engine:真正执行 HTTP 的平台层

前面的:

ApiService

NetworkClient

Provider

Plugin

HttpClient

基本都可以放在:

commonMain

真正来到平台差异的是:

Engine

例如:

Android
↓
OkHttp Engine


iOS
↓
Darwin Engine


Web
↓
JS / Wasm 对应 Engine

可以把它理解成:

Ktor 公共 HttpClient API
↓
Engine
↓
平台底层网络实现

所以:

Plugin 决定请求过程中做什么,Engine 决定这个平台最终怎么把请求发出去。

这是两个完全不同的层级。


二十、KMP 到底应该共享什么?

比较理想的是:

commonMain

ApiService
NetworkClient
NetworkConfig
Provider Interface
ApiResponse
AppError
ExceptionMapper
JSON Config
Auth Logic
Retry Policy
Plugin Config

平台层:

androidMain / iosMain / wasmJsMain

Engine
Connectivity Monitor
Secure Storage
平台日志实现
证书 / TLS 平台差异

也就是:

业务网络规则共享,真正的平台差异下沉。

而不是:

Android 网络写一套

iOS 网络写一套

Web 网络再写一套

二十一、expect / actual 应该用在哪里?

如果只是:

选择一个可以工作的 Engine

不一定非要为了“用了 KMP”就写:

expect / actual

但是如果不同平台确实有:

Android OkHttp 特殊配置

iOS Darwin 特殊配置

Web 网络限制

TLS / Certificate

Proxy

平台网络监控

那么:

expect / actual

就非常合理。

例如:

// commonMain

expect fun createPlatformHttpClient(
    block:
        HttpClientConfig<*>.() -> Unit,
): HttpClient

然后:

androidMain
↓
OkHttp


iosMain
↓
Darwin


wasmJsMain
↓
对应 Web Engine

原则:

不是为了 expect/actual 而 expect/actual,而是只把真正的平台差异放进去。


二十二、Repository 又处在哪一层?

例如:

class UserRepository(
    private val userApiService:
        UserApiService,

    private val userLocalDataSource:
        UserLocalDataSource,
) {

    suspend fun getUser():
        User {

        return userApiService
            .getUser()
    }
}

Repository 关心的是:

数据来自 Remote?

来自 Local?

是否需要 Cache?

什么时候同步?

它不应该关心:

Authorization Header

HttpTimeout

Ktor Plugin

OkHttp Engine

Darwin Engine

所以:

ViewModel
↓
Repository
↓
ApiService
↓
NetworkClient
↓
HttpClient

每一层的问题是不一样的。


二十三、一次正常请求到底怎么走?

例如:

userApiService
    .getUser(
        userId = 1001
    )

流程:

UserApiService
↓
NetworkClient.get()
↓
Connectivity Pre-check
↓
apiClient
↓
DefaultRequest
↓
读取 Provider 当前状态
↓
Auth
↓
Logging
↓
HttpTimeout
↓
HttpRequestRetry
↓
Custom Plugin
↓
Engine
↓
HTTP

Response:

HTTP
↓
Engine
↓
HttpResponse
↓
Auth / Retry 等生命周期处理
↓
Logging
↓
ContentNegotiation
↓
ApiResponse<User>
↓
NetworkClient
↓
检查 code
↓
解包 data
↓
User
↓
ApiService
↓
Repository
↓
ViewModel

这才是一次完整的 Ktor 网络调用。


二十四、如果发生 401 呢?

网络生命周期会产生分支:

GET /user
↓
Authorization: Bearer A1
↓
HTTP
↓
401
↓
Auth
↓
RefreshToken
↓
refreshClient
↓
A2
↓
保存新 Token
↓
重新发送原 Request
↓
Authorization: Bearer A2
↓
200

所以:

一次业务请求,不一定只对应一次真实 HTTP Request。

这也是理解 Ktor Send 生命周期以后非常重要的一点。


二十五、如果发生 503 呢?

又会走另一条恢复链:

GET /products
↓
503
↓
HttpRequestRetry
↓
判断:

错误是否值得 Retry?
AND
当前网络是否 Available?
AND
Request 是否可以安全重放?
↓
Backoff
↓
Send #2
↓
200

注意:

Auth Refresh

和:

HttpRequestRetry

都会重新发送 Request。

但两者原因完全不同:

401
↓
认证状态恢复


503 / Network Error
↓
瞬时故障恢复

不要混在一起。


二十六、完整错误链路

现在异常体系也可以重新串起来。

断网

NetworkClient
↓
Connectivity Pre-check
↓
Unavailable
↓
AppError.Network

Timeout

HttpClient
↓
HttpTimeout
↓
Timeout Exception
↓
ExceptionMapper
↓
AppError.Timeout

HTTP 500

500
↓
Retry Policy
↓
最终仍失败
↓
ExceptionMapper
↓
AppError.Server

JSON 解析失败

Response
↓
ContentNegotiation
↓
SerializationException
↓
ExceptionMapper
↓
AppError.Parse

业务错误

HTTP 200
↓
ApiResponse<T>
↓
code != 0
↓
ApiException
↓
AppError.Business

登录失效

401
↓
Auth Refresh
↓
失败
↓
最终 Unauthorized
↓
AppError.Unauthorized

于是 ViewModel 最终面对的是:

User / Order / Product

或者

AppError

而不是底层 Ktor 异常。


二十七、推荐的目录结构

实际项目可以参考:

shared/
└── src/
    ├── commonMain/
    │
    │   └── network/
    │       │
    │       ├── client/
    │       │   ├── NetworkClient.kt
    │       │   ├── NetworkClients.kt
    │       │   └── HttpClientFactory.kt
    │       │
    │       ├── config/
    │       │   ├── NetworkConfig.kt
    │       │   └── NetworkConfigs.kt
    │       │
    │       ├── provider/
    │       │   ├── TokenProvider.kt
    │       │   ├── CommonHeadersProvider.kt
    │       │   └── NetworkConnectivityProvider.kt
    │       │
    │       ├── model/
    │       │   └── ApiResponse.kt
    │       │
    │       ├── error/
    │       │   ├── AppError.kt
    │       │   └── ExceptionMapper.kt
    │       │
    │       ├── plugin/
    │       │   ├── HttpLoggingConfig.kt
    │       │   ├── RetryPolicy.kt
    │       │   └── CustomPlugin.kt
    │       │
    │       └── api/
    │           ├── UserApiService.kt
    │           ├── OrderApiService.kt
    │           └── AuthApiService.kt
    │
    ├── androidMain/
    │   └── network/
    │       ├── PlatformHttpClient.kt
    │       ├── AndroidConnectivityMonitor.kt
    │       └── AndroidSecureStore.kt
    │
    ├── iosMain/
    │   └── network/
    │       ├── PlatformHttpClient.kt
    │       ├── IosConnectivityMonitor.kt
    │       └── IosSecureStore.kt
    │
    └── wasmJsMain/
        └── network/
            ├── PlatformHttpClient.kt
            └── WebConnectivityMonitor.kt

目录名称不是标准答案。

真正重要的是:

commonMain
↓
共享网络规则


platformMain
↓
平台实现差异

二十八、DI:谁负责对象生命周期?

如果使用 Koin,可以:

single<TokenProvider> {
    DefaultTokenProvider(...)
}

single<
    NetworkConnectivityProvider
> {
    DefaultNetworkConnectivityProvider(...)
}

single {
    NetworkClients(
        configs = get(),
        tokenProvider = get(),
        commonHeadersProvider = get(),
        connectivityProvider = get(),
        exceptionMapper = get(),
    )
}

single {
    UserApiService(
        networkClient =
            get<NetworkClients>().api
    )
}

这里一定要把两件事分开:

Factory
↓
这个对象怎么创建?


DI / Koin
↓
这个对象创建几个?
活多久?
谁依赖它?

所以:

Factory 管怎么造,DI 管造几个和生命周期。

或者更简单:

Ktor 负责网络,Koin 负责对象。


二十九、什么时候应该创建新的 HttpClient?

以后项目增加业务时,不要第一反应:

新业务
↓
新 Client

先问:

BaseUrl 是否明显不同?

Auth 是否完全不同?

Timeout / Retry Policy 是否明显不同?

Header / Cookie / TLS 是否属于独立配置域?

Client 生命周期是否应该隔离?

如果大部分答案都是:

才考虑:

新的 HttpClient

否则优先:

复用现有 Client
+
Request Override

三十、例如上传一定要独立 Client 吗?

不一定。

如果只是:

BaseUrl 一样

Auth 一样

Header 一样

只是 Timeout 更长

完全可以:

apiClient.post(
    "/upload"
) {

    timeout {

        requestTimeoutMillis =
            120_000
    }
}

没必要因为:

Timeout 不一样

就创建一个新 Client。


如果上传已经变成:

独立域名

独立认证

特殊并发策略

完全不同 Retry

Binary Logging 策略

上传进度 Plugin

那:

uploadClient

就开始成为一个真正独立的:

网络责任域

这才值得拆。


三十一、WebSocket 不应该硬塞进普通 NetworkClient

普通 HTTP:

Request
↓
Response

而 WebSocket:

Connect
↓
Session
↓
持续 Message
↓
Heartbeat
↓
Reconnect
↓
Disconnect

生命周期完全不同。

所以可以共享:

Config
Provider
Logger
Auth 信息

但最好形成:

WebSocketClient

自己的抽象。

不要为了:

“网络相关都放 NetworkClient”

把两个完全不同的通信模型强行揉在一起。


三十二、网络架构不要过度抽象

架构设计还有一个常见误区:

BaseClient

AbstractClient

ClientManager

RequestExecutor

RequestProcessor

ResponseProcessor

NetworkGateway

NetworkCoordinator

最后一个 GET:

穿十几层

这不是好的架构。

真正的原则应该是:

每增加一层,都必须解决一个明确问题。

例如:

ApiService
↓
解决业务接口描述


NetworkClient
↓
解决项目统一网络规则


HttpClient
↓
解决 HTTP 能力组合


Engine
↓
解决平台网络实现

这几层已经足够明确。


三十三、整套网络架构其实只有四条主线

如果觉得前面内容太多,可以最后压缩成四条线。

1. 业务调用线

ViewModel
↓
Repository
↓
ApiService
↓
NetworkClient

2. HTTP 能力线

NetworkClient
↓
HttpClient
↓
Plugin
↓
Engine
↓
HTTP

3. 动态状态线

App / Platform State
↓
Provider
↓
NetworkClient / Plugin / HttpClient

4. 错误返回线

Network / HTTP / JSON / Business
↓
Throwable
↓
ExceptionMapper
↓
AppError
↓
Repository / ViewModel

四条线组合起来,就是整个 KMP 网络层。


三十四、最后再看一遍完整架构

                          UI
                           ↓
                       ViewModel
                           ↓
                       Repository
                           ↓
                       ApiService
                           ↓
                      NetworkClient
                           │
                Connectivity Pre-check
                           ↓
                      NetworkClients
                 ┌─────────┴─────────┐
                 ↓                   ↓
             apiClient          refreshClient
                 │                   │
                 └─────────┬─────────┘
                           ↓
                       HttpClient
                           │
        ┌──────────────────┼──────────────────┐
        ↓                  ↓                  ↓
      Config            Provider           Plugin
        │                  │                  │
        ↓                  ↓                  ↓
     BaseUrl             Token        ContentNegotiation
     Timeout            Language       DefaultRequest
     ...                Network        Logging
                          ...          HttpTimeout
                                       Retry
                                       Auth
                                       Custom Plugin
                           │
                           ↓
                         Engine
                ┌──────────┼───────────┐
                ↓          ↓           ↓
             Android      iOS       JS/Wasm
             OkHttp      Darwin       ...
                └──────────┼───────────┘
                           ↓
                          HTTP
                           ↓
                  Response / Throwable
                           ↓
                 ContentNegotiation
                           ↓
                    ApiResponse<T>
                           ↓
                     NetworkClient
                           ↓
              ┌────────────┴────────────┐
              ↓                         ↓
          code == 0                 Throwable
              ↓                         ↓
             data               ExceptionMapper
              ↓                         ↓
              T                    AppError

如果能自己解释这张图中的每一层:

为什么存在?

负责什么?

为什么不能放到旁边那一层?

那么这套 Ktor 网络架构就已经真正理解了。


三十五、最终记住六句话

整个系列其实可以压缩成下面六句话。

1. ApiService

描述“我要调用哪个业务接口”。

2. NetworkClient

规定“项目里的请求应该怎么统一执行”。

3. HttpClient

代表“一条长期复用、已经配置完成的网络能力通道”。

4. Provider

提供“当前运行期动态状态”。

5. Plugin

把横切能力挂到 Request / Response 生命周期。

6. Engine

负责“这个平台最终怎么真正发出 HTTP 请求”。

串起来就是:

ApiService
↓
NetworkClient
↓
HttpClient
↓
Plugin + Provider
↓
Engine
↓
HTTP

这就是整个 KMP + Ktor 网络架构的核心。


三十六、写在最后

刚开始学习 Ktor 时,很容易把它理解成:

Retrofit 的替代品

然后关注:

GET 怎么写?

POST 怎么写?

JSON 怎么解析?

但学到这里以后,会发现 Ktor Client 真正值得理解的是:

HttpClient
+
Plugin
+
Lifecycle
+
Engine

它提供的是一套完整的网络能力组合模型。

而项目自己的架构,则继续在它上面增加:

ApiService

NetworkClient

Provider

AppError

ExceptionMapper

NetworkClients

最后形成:

业务层
↓
项目网络抽象
↓
Ktor Client
↓
平台 Engine
↓
真实 HTTP

所以真正成熟的 KMP 网络层追求的并不是:

代码最少

封装层数最多

所有东西都做成 Plugin

而是:

业务层足够简单

每一层职责清楚

动态状态可以安全变化

HttpClient 可以长期复用

错误语义稳定

平台差异被隔离

特殊请求仍然保留扩展能力

未来新增网络能力时知道应该放在哪一层

当你能够看到一个新的需求,例如:

“我要做请求签名”

第一反应已经不是:

Ktor 有没有哪个 API?

而是:

这个需求属于什么职责?

它应该发生在哪个生命周期?

应该放 NetworkClient?

Provider?

还是 Custom Plugin?

那么你就已经从:

会使用 Ktor

走到了:

能够设计 Ktor 网络架构

Ktor 网络层系列主线到这里形成闭环

第一篇
Ktor Client 到底是什么?

↓

第二篇
GET、POST、参数与请求体

↓

第三篇
ContentNegotiation

↓

第四篇
kotlinx.serialization

↓

第五篇
DefaultRequest

↓

补充篇 5.1
静态配置、Provider 与 Request 三种作用域

↓

第六篇
NetworkClient 请求层封装

↓

第七篇
ApiResponse<T> 与业务 code

↓

第八篇
统一异常体系 AppError

↓

补充篇 8.1
KMP 断网处理与 ConnectivityProvider

↓

第九篇
Logging、HttpTimeout 与 HttpRequestRetry

↓

补充篇 9.1
Logging 深入:
Header、Body 脱敏与自定义 Logger

↓

补充篇 9.2
Ktor DSL 深入:
为什么 install、get、headers 可以这样写?

↓

第十篇
Custom Client Plugin:
从 OkHttp Interceptor 理解 Ktor 生命周期

↓

第十一篇
Bearer Token、Refresh Token 与 401 自动刷新

↓

第十二篇
完整 KMP + Ktor 网络架构

到这里,Ktor Client 的网络主线已经可以告一段落。

=================================================================

后续:进入 Ktor 网络层进阶系列

到第十二篇为止,KMP + Ktor 网络层的基础架构已经完整闭环。

接下来不再继续补基础框架,而是进入真正的工程进阶阶段。

后续计划:

进阶第一篇
AppResult<T>:
网络层到底应该 throw,还是返回统一 Result?

↓

进阶第二篇
Ktor Multipart 文件上传

↓

进阶第三篇
文件下载与 Progress

↓

进阶第四篇
Ktor WebSocket 长连接

↓

进阶第五篇
Cookie 与 Session

↓

进阶第六篇
请求签名:
Timestamp、Nonce、Body Hash 与 Signature

↓

进阶第七篇
Request Body 加密与 Response 解密

↓

进阶第八篇
TLS、证书校验与 Certificate Pinning

↓

进阶第九篇
Ktor Engine 深入:
OkHttp、Darwin、CIO、JS/Wasm

↓

进阶第十篇
NetworkClient 与 Plugin 单元测试

前十二篇解决的是:

一套完整的 KMP + Ktor 网络层应该怎么设计。

后面的进阶系列,则继续解决:

上传下载
长连接
认证与 Session
请求安全
数据加密
TLS
平台 Engine 差异
网络测试

这些内容已经不是“把基础网络层搭起来”,而是在现有架构上继续增加真正面向复杂项目的工程能力。

Logo

一站式 AI 云服务平台

更多推荐