OKHTTP连接保持

Retrofit2 本身并不包含缓存功能,且代码中目前也没有开启 OkHttp 的磁盘缓存功能

观察到的"第二次请求特别快",并不是因为数据被缓存了 ,而是因为 OkHttp 的连接池复用机制(Connection Pooling / Keep-Alive)

下面详细解释为什么会这样,以及如果真的想要"缓存"该怎么做

1. 为什么第二次请求变快了?(连接复用)

在网络请求中,建立连接是最耗时的步骤之一。

  • 第一次请求:DNS 解析 -> 建立 TCP 连接 (三次握手) -> SSL/TLS 握手 (加密验证) -> 发送数据 -> 接收数据。
  • 第二次请求 :由于 OkHttp 默认开启了连接池 ,它发现还在请求同一个域名(BASE_URL),且之前的连接还没有断开,它会直接跳过前面的握手步骤:直接利用已有连接发送数据 -> 接收数据

这省去了几百毫秒甚至上秒的握手时间,所以会感觉"特别快"

注意:这并不意味着数据被缓存了。如果手机断网,第二次请求依然会失败

2. Retrofit 和 OkHttp 的关系

  • Retrofit:只是一个外壳(Wrapper),它负责把 Interface 方法转换成 HTTP 请求,解析 JSON 数据等。它不负责底层的网络传输,也不负责缓存。
  • OkHttp:是 Retrofit 底下的引擎,它负责真正的网络传输和缓存

3. 代码中为何没有缓存?

在 HTTP 协议中,标准的缓存是基于磁盘的(Disk Cache)。要启用它,需要显式地给 OkHttpClient 配置一个 Cache 对象。

查看代码:

kotlin 复制代码
return OkHttpClient.Builder()
    // ...
    // 这里缺少了 .cache(cache) 的配置
    .addInterceptor(logging)
    .addInterceptor(TokenHeaderInterceptor())
    .build()

因为没有配置 .cache(),OkHttp 不会将服务器返回的数据写入手机存储

4. 还有一个关键点:POST 请求通常不被缓存

ApiService,大部分请求是 @POST

kotlin 复制代码
@POST("/v1/media/getRecapInfo")
fun getRecapInfo(...)

HTTP 标准缓存协议通常只针对 GET 请求有效。 对于 POST 请求(通常代表提交数据或获取非幂等数据),浏览器和 OkHttp 默认都不会去缓存结果

所以,即便开启了 OkHttp 的缓存, getRecapInfo 这种 POST 请求依然不会被缓存。这进一步印证感觉到的"快"纯粹是因为网络连接复用


5. 如果想要真正的"数据缓存"该怎么做?

如果希望在没网的时候也能读取上次的数据,或者减少服务器压力,有两种选择:

方案 A:开启 OkHttp 标准缓存(仅对 GET 有效)

需要修改 buildOkHttpClient,传入 Context 来获取缓存路径:

kotlin 复制代码
// 需要传入 context
fun buildOkHttpClient(context: Context): OkHttpClient {
    // 设置缓存大小,比如 10MB
    val cacheSize = 10 * 1024 * 1024L 
    val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)

    return OkHttpClient.Builder()
        .cache(cache) // 开启缓存
        // ... 其他配置
        .build()
}

前提:后端接口必须是 GET 请求,并且后端返回的 Header 中包含 Cache-Control 信息(比如 max-age=60)。

方案 B:业务层缓存(推荐,适合 POST 和复杂业务)

既然接口多是 POST,且业务逻辑较重,建议不要依赖 HTTP 协议缓存,而是自己控制:

  1. 数据库缓存 (Room):请求成功后,把 JSON 数据存入本地数据库(SQLite/Room)。下次请求前,先读数据库显示旧数据,同时去请求网络更新。

  2. RxJava 内存缓存 :如果只是想在页面没销毁前缓存,可以使用 RxJava 的操作符。

    kotlin 复制代码
    // 示例:使用 replay(1).autoConnect() 让流变热,从而缓存最近一次的结果
    val cachedObservable = apiService.getRecapInfo(req).replay(1).autoConnect()

总结

目前的框架没有缓存

  • 第一次慢:建立连接 + 传输数据
  • 第二次快:复用连接 + 传输数据

这就是 HTTP 协议中的 Keep-Alive(HTTP 持久连接) 机制

在 OkHttp 中,这个机制是通过 ConnectionPool(连接池) 来实现的

1. 为什么叫"类似长连接"?

严格来说,它不是像 WebSocket 那种一直不断的"永久长连接",而是 "在一段时间内保持连接不断开"

  • 没有复用(短连接) :请求 -> 建立连接(3次握手+TLS) -> 传输 -> 断开连接。下次再请求,重新建立。
  • 连接池复用(Keep-Alive) :请求 -> 建立连接 -> 传输 -> 连接闲置放入池中 (不断开)。
    • 如果在 5分钟 (默认)内又有请求 -> 直接复用该连接
    • 如果超过 5分钟 没人用 -> 自动断开并回收。

2. 配置在哪里?(默认配置)

在代码中,没有显式配置它,因为 OkHttp 默认已经帮你配置好了

源码位置: okhttp3.OkHttpClient.Builder

kotlin 复制代码
// OkHttp 源码片段 (Kotlin 版本)
class Builder constructor() {
    // ...
    internal var connectionPool: ConnectionPool = ConnectionPool() // 这里!默认创建了一个连接池
    // ...
}

如果点进去看 ConnectionPool 的无参构造函数:

kotlin 复制代码
// okhttp3.ConnectionPool 源码
class ConnectionPool(
    maxIdleConnections: Int = 5, // 默认最大闲置连接数:5个
    keepAliveDuration: Long = 5, // 默认保持存活时间:5
    timeUnit: TimeUnit = TimeUnit.MINUTES // 单位:分钟
) {
    // 这意味着:默认情况下,OkHttp 会维护最多 5 个空闲连接,
    // 每个空闲连接如果不被使用,5 分钟后会被自动清理。
}

如果想修改这个配置(比如为了优化性能,想让连接保持更久,或者允许更多并发闲置连接),可以这样写:

kotlin 复制代码
private fun buildOkHttpClient(): OkHttpClient {
    // 自定义连接池
    val myConnectionPool = ConnectionPool(10, 10, TimeUnit.MINUTES)

    return OkHttpClient.Builder()
        .connectionPool(myConnectionPool) // 显式传入
        // ... 其他配置
        .build()
}

3. 源码探究:它是如何"偷偷"复用的?

既然对源码好奇,来看看 OkHttp 是如何管理这些连接的。核心逻辑在 okhttp3.internal.connection 包下。

A. 存:请求结束后不关连接

当请求(比如 getRecapInfo)执行完毕拿到数据后,OkHttp 的底层流处理类(StreamAllocation 或新版的 Exchange)会调用 release 方法。

正常逻辑是关闭 Socket,但 OkHttp 做了一个判断: "如果是 HTTP/1.1 且 header 里有 keep-alive,我不关 Socket,而是把它标记为 idle(闲置),放回 ConnectionPool 的双端队列(Deque)中。"

B. 取:请求开始前先找连接

当发起第二次 请求时,OkHttp 不会立刻 new Socket()

它会去 ConnectionPool 里找(源码类 ExchangeFinder):

  1. 遍历池子:看有没有 host 和 port 都匹配的、而且是可以复用的连接
  2. 找到了 :直接返回这个 RealConnection 对象
  3. 没找到:才去执行 TCP 三次握手和 TLS 握手(就是觉得耗时的那部分)

C. 清理:后台线程自动打扫

OkHttp 怎么知道什么时候关闭那些 5 分钟没用的连接?

ConnectionPool 内部,有一个静态的线程池 (executor),专门运行一个 cleanupRunnable 任务。

kotlin 复制代码
// ConnectionPool 伪代码逻辑
private val cleanupRunnable = Runnable {
    while (true) {
        // 1. 计算下一个连接多久过期
        val waitNanos = cleanup(System.nanoTime())
        
        // 2. 如果池子空了,退出循环,线程结束
        if (waitNanos == -1L) return
        
        // 3. 还没到期,线程挂起等待(wait),到时间再醒来检查
        if (waitNanos > 0) {
            lock.wait(waitNanos) 
        }
    }
}

这个设计非常精妙:如果没有空闲连接,清理线程是不运行的,完全不占资源。一旦有了空闲连接,它才会启动并计时。

4. 为什么握手这么耗时?(直观对比)

感觉到的"第一次慢,第二次快",在 HTTPS 请求中差异巨大

第一次请求(冷启动):

  1. DNS:域名 -> IP (几毫秒到几百毫秒)
  2. TCP 握手:SYN -> SYN/ACK -> ACK (1个 RTT,往返时延)
  3. TLS 握手 (最贵):
    • Client Hello
    • Server Hello + Certificate
    • Key Exchange
    • Finished
    • (如果是 TLS 1.2,这里至少消耗 2个 RTT。跨国网络下,这可能就是 500ms+ 的时间)
  4. 发送 HTTP 数据
  5. 接收数据

第二次请求(复用):

  1. (跳过 DNS)
  2. (跳过 TCP 握手)
  3. (跳过 TLS 握手) -> 省下了最耗时的部分!
  4. 发送 HTTP 数据
  5. 接收数据

所以,在移动端开发中,保持 OkHttpClient 单例(像代码里做的 object NetworkApi + lazy)是非常重要的,这样才能利用连接池。如果每次请求都 new OkHttpClient(),连接池就会失效,每次都要重新握手,速度就会很慢

不需要显式设置,header中Keep-Alive默认是开启的。 而且,目前的日志里可能看不到这个 Header,是因为拦截器的位置原因

下面一步步验证和揭秘

1. 为什么不需要设置?(协议默认值)

  • HTTP/1.1 协议规定 :只要是 HTTP/1.1,默认就是 Keep-Alive(持久连接)。除非显式发送 Connection: close,否则服务器和客户端都默契地保持连接。
  • HTTP/2 协议规定 :在 HTTP/2 中,连接复用是核心特性(Multiplexing),Connection header 甚至被视为非法或被忽略,因为连接默认就是持久且多路复用的。

2. 为什么日志里可能看不到?(拦截器陷阱)

现在的代码是这样写的:

kotlin 复制代码
// ...
.addInterceptor(logging) // <--- 注意这里
// ...

这叫做 Application Interceptor(应用拦截器)

OkHttp 的工作流程是这样的:

  1. 应用拦截器 (Application Interceptor) :就是 logging。这时候请求还是"原始"的,是代码里写的样子。OkHttp 还没来得及加默认 Header。
  2. 桥接拦截器 (BridgeInterceptor)OkHttp 内部的一个拦截器。它负责补全 Header(比如 Host, User-Agent, Gzip, Connection: Keep-Alive)。
  3. 网络拦截器 (Network Interceptor):在真正发包之前。
  4. 发送网络请求

结论 :因为日志拦截器在第 1 步,而添加 Keep-Alive 发生在第 2 步,所以在日志里看不到它

3. 如何亲眼看到它?(两个方法)

方法一:临时修改代码(最快验证)

把代码中的 addInterceptor(logging) 改为 addNetworkInterceptor(logging)

kotlin 复制代码
// NetworkApi.kt 修改

private fun buildOkHttpClient(): OkHttpClient {
    // ... 前面省略
    return OkHttpClient.Builder()
        // ...
        // 删掉这行 .addInterceptor(logging)
        // 改用下面这行:
        .addNetworkInterceptor(logging) 
        .addInterceptor(TokenHeaderInterceptor())
        .build()
}

重新运行并看日志: 会在 Header 里清晰地看到: Connection: Keep-Alive Accept-Encoding: gzip (OkHttp 也会自动帮加这个,之前看不到,现在能看到了) User-Agent: okhttp/4.x.x

验证完记得改回来,通常我们开发用 addInterceptor 就够了,addNetworkInterceptor 会打印一些底层的、解压前的数据,有时候比较乱

方法二:使用 Android Studio Network Inspector(不用改代码)

  1. 运行 App。
  2. 打开 Android Studio 底部的 App InspectionNetwork Inspector 标签页。
  3. 点击发起一个网络请求。
  4. 点击左侧列表里的请求,查看右侧的 Request Headers
  5. 会看到 Connection: Keep-Alive 赫然在列。这是最真实的、发给服务器的数据。

4. 源码验证(核心证据)

既然想看源码实现,这个逻辑在 okhttp3.internal.http.BridgeInterceptor 类中

OkHttp 的责任链模式中,BridgeInterceptor 专门负责把用户的请求转换为网络友好的请求

kotlin 复制代码
// OkHttp 源码:BridgeInterceptor.kt
class BridgeInterceptor(private val cookieJar: CookieJar) : Interceptor {

  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    val userRequest = chain.request()
    val requestBuilder = userRequest.newBuilder()

    // ... 省略其他 Header 设置 (Content-Type, Content-Length 等)

    // 重点在这里!!!
    if (userRequest.header("Connection") == null) {
      // 如果用户没有自己设置 Connection Header,OkHttp 帮加上 Keep-Alive
      requestBuilder.header("Connection", "Keep-Alive")
    }

    // ... 省略 Gzip 设置 (Accept-Encoding)

    val networkResponse = chain.proceed(requestBuilder.build())
    
    // ...
  }
}

代码逻辑解读: 它检查 userRequest.header("Connection") == null。 没有设置,所以它确实是空的 于是它执行 requestBuilder.header("Connection", "Keep-Alive")

这就是为什么没写,但它确实存在,且 OkHttp 会帮助维护连接池的原因

相关推荐
QING6188 小时前
简单说下Kotlin 作用域函数中 apply 和 also 为什么不能空安全调用?
android·kotlin·android jetpack
城东米粉儿8 小时前
着色器 (Shader) 的基本概念和 GLSL 语法 笔记
android
儿歌八万首10 小时前
Jetpack Compose :封装 MVVM 框架
android·kotlin·compose
2501_9159214310 小时前
iOS App 中 SSL Pinning 场景下代理抓包失效的原因
android·网络协议·ios·小程序·uni-app·iphone·ssl
壮哥_icon10 小时前
Android 系统级 USB 存储检测的工程化实现(抗 ROM、抗广播丢失)
android·android-studio·android系统
Junerver10 小时前
积极拥抱AI,ComposeHooks让你更方便地使用AI
android·前端
城东米粉儿10 小时前
ColorMatrix色彩变换 笔记
android
方白羽10 小时前
告别onActivityResult:Android数据回传的三大痛点与终极方案
android·app·客户端
oMcLin10 小时前
如何在 RHEL 8 系统上实现高可用 MySQL 集群,保障电商平台的 24 小时稳定运行
android·mysql·adb