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 对象。
查看代码:
return OkHttpClient.Builder()
// ...
// 这里缺少了 .cache(cache) 的配置
.addInterceptor(logging)
.addInterceptor(TokenHeaderInterceptor())
.build()
因为没有配置 .cache(),OkHttp 不会将服务器返回的数据写入手机存储
4. 还有一个关键点:POST 请求通常不被缓存
ApiService,大部分请求是 @POST:
@POST("/v1/media/getRecapInfo")
fun getRecapInfo(...)
HTTP 标准缓存协议通常只针对 GET 请求有效。
对于 POST 请求(通常代表提交数据或获取非幂等数据),浏览器和 OkHttp 默认都不会去缓存结果
所以,即便开启了 OkHttp 的缓存, getRecapInfo 这种 POST 请求依然不会被缓存。这进一步印证感觉到的“快”纯粹是因为网络连接复用
5. 如果想要真正的“数据缓存”该怎么做?
如果希望在没网的时候也能读取上次的数据,或者减少服务器压力,有两种选择:
方案 A:开启 OkHttp 标准缓存(仅对 GET 有效)
需要修改 buildOkHttpClient,传入 Context 来获取缓存路径:
// 需要传入 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 协议缓存,而是自己控制:
- 数据库缓存 (Room):请求成功后,把 JSON 数据存入本地数据库(SQLite/Room)。下次请求前,先读数据库显示旧数据,同时去请求网络更新。
- RxJava 内存缓存:如果只是想在页面没销毁前缓存,可以使用 RxJava 的操作符。
// 示例:使用 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
// OkHttp 源码片段 (Kotlin 版本)
class Builder constructor() {
// ...
internal var connectionPool: ConnectionPool = ConnectionPool() // 这里!默认创建了一个连接池
// ...
}
如果点进去看 ConnectionPool 的无参构造函数:
// okhttp3.ConnectionPool 源码
class ConnectionPool(
maxIdleConnections: Int = 5, // 默认最大闲置连接数:5个
keepAliveDuration: Long = 5, // 默认保持存活时间:5
timeUnit: TimeUnit = TimeUnit.MINUTES // 单位:分钟
) {
// 这意味着:默认情况下,OkHttp 会维护最多 5 个空闲连接,
// 每个空闲连接如果不被使用,5 分钟后会被自动清理。
}
如果想修改这个配置(比如为了优化性能,想让连接保持更久,或者允许更多并发闲置连接),可以这样写:
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):
- 遍历池子:看有没有 host 和 port 都匹配的、而且是可以复用的连接
- 找到了:直接返回这个
RealConnection对象 - 没找到:才去执行 TCP 三次握手和 TLS 握手(就是觉得耗时的那部分)
C. 清理:后台线程自动打扫
OkHttp 怎么知道什么时候关闭那些 5 分钟没用的连接?
在 ConnectionPool 内部,有一个静态的线程池(executor),专门运行一个 cleanupRunnable 任务。
// 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 请求中差异巨大
第一次请求(冷启动):
- DNS:域名 -> IP (几毫秒到几百毫秒)
- TCP 握手:SYN -> SYN/ACK -> ACK (1个 RTT,往返时延)
- TLS 握手(最贵):
- Client Hello
- Server Hello + Certificate
- Key Exchange
- Finished
- (如果是 TLS 1.2,这里至少消耗 2个 RTT。跨国网络下,这可能就是 500ms+ 的时间)
- 发送 HTTP 数据
- 接收数据
第二次请求(复用):
- (跳过 DNS)
- (跳过 TCP 握手)
- (跳过 TLS 握手) -> 省下了最耗时的部分!
- 发送 HTTP 数据
- 接收数据
所以,在移动端开发中,保持 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),
Connectionheader 甚至被视为非法或被忽略,因为连接默认就是持久且多路复用的。
2. 为什么日志里可能看不到?(拦截器陷阱)
现在的代码是这样写的:
// ...
.addInterceptor(logging) // <--- 注意这里
// ...
这叫做 Application Interceptor(应用拦截器)。
OkHttp 的工作流程是这样的:
- 应用拦截器 (Application Interceptor):就是
logging。这时候请求还是“原始”的,是代码里写的样子。OkHttp 还没来得及加默认 Header。 - 桥接拦截器 (BridgeInterceptor):OkHttp 内部的一个拦截器。它负责补全 Header(比如
Host,User-Agent,Gzip,Connection: Keep-Alive)。 - 网络拦截器 (Network Interceptor):在真正发包之前。
- 发送网络请求。
结论:因为日志拦截器在第 1 步,而添加 Keep-Alive 发生在第 2 步,所以在日志里看不到它
3. 如何亲眼看到它?(两个方法)
方法一:临时修改代码(最快验证)
把代码中的 addInterceptor(logging) 改为 addNetworkInterceptor(logging)。
// 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(不用改代码)
- 运行 App。
- 打开 Android Studio 底部的 App Inspection 或 Network Inspector 标签页。
- 点击发起一个网络请求。
- 点击左侧列表里的请求,查看右侧的 Request Headers。
- 会看到
Connection: Keep-Alive赫然在列。这是最真实的、发给服务器的数据。
4. 源码验证(核心证据)
既然想看源码实现,这个逻辑在 okhttp3.internal.http.BridgeInterceptor 类中
OkHttp 的责任链模式中,BridgeInterceptor 专门负责把用户的请求转换为网络友好的请求
// 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 会帮助维护连接池的原因
设定:
- 某个App:OkHttp 配置了 100 年的连接超时,极其自信地认为连接活着
- Server:现实且冷酷,60秒没收到数据,直接踢人
- Android 底层:负责搬运 TCP 数据包的系统内核
当 Server 销毁连接(发送 TCP FIN 包)后,你的 App 会经历以下几个阶段的“挣扎”和“幻灭”:
第一阶段:Server 动手了(TCP FIN)
- 服务器端:计时器到了,Nginx 发送一个
FIN(Finish) 包给你的手机,意思是:“我不玩了,挂电话吧” - 手机操作系统(Linux内核):收到了
FIN包- 内核自动回复一个
ACK - 内核把这个 Socket 的状态标记为
CLOSE_WAIT(被动关闭等待) - 关键点:此时,你的 App 进程(Java层/OkHttp)可能还完全不知情! 除非 App 此时此刻正好在读写这个 Socket。如果 App 只是把它放在连接池里睡觉,App 根本收不到通知
- 内核自动回复一个
第二阶段:App 醒来,取出“僵尸连接”
过了 10 分钟,用户点了一个按钮。OkHttp 接到任务,去连接池找连接
- OkHttp:看了一眼连接池:“嘿,这有个连接,过期时间是 2125 年。太棒了,还没过期!”
- 健康检查(Crucial Step):OkHttp 其实很鸡贼,它拿出来之前会试探性地检查一下连接是否健康(
isHealthy方法)。- 它会尝试读取一下 Socket 的状态,或者判断
socket.isClosed()。 - 悲剧的分岔路:
- 情况 A(运气好):OkHttp 发现 Socket 已经是
CLOSE_WAIT或者输入流已经关闭了。它会叹口气:“唉,虽然没到 100 年,但它死了。” —— 动作:默默销毁,重新建立新连接。(用户无感知,只是稍微慢一点点)。 - 情况 B(运气差/竞态条件):如果操作系统还没来得及同步状态,或者检测机制漏网,OkHttp 会误判它是健康的。
- 情况 A(运气好):OkHttp 发现 Socket 已经是
- 它会尝试读取一下 Socket 的状态,或者判断
第三阶段:向“僵尸”尸体开枪(写入数据)
假设 OkHttp 误判连接是活的(或者连接被取出的那一毫秒是活的,下一毫秒 Server 的 FIN 到了):
- 写入请求(Write):OkHttp 开始往
OutputStream里写 HTTP 请求头(GET /v1/...)。 - 系统内核反应:
- 因为 Socket 处于
CLOSE_WAIT或类似状态,或者 Server 那边已经发送了RST(Reset,表示你发错人了,我不认识这个连接) - 当试图往一个对方已经关闭的连接里写数据时,会触发
SocketException: Broken pipe(管道破裂)或者SocketException: Software caused connection abort。
- 因为 Socket 处于
第四阶段:惊慌与补救(Retry)
这时候 App 抛出了异常 会 Crash 吗?
通常不会 Crash,但会经历一次“剧烈震荡”
OkHttp 默认开启了 retryOnConnectionFailure(失败重试)
- 捕获异常:OkHttp 的拦截器链条捕获到了这个
SocketException。 - 判定:它会分析:“这是一个网络层面的路由故障?还是我复用的这个旧连接有问题?”
- 结论:“这大概率是因为我复用了旧连接,但旧连接坏了。”
- 亡羊补牢:
- OkHttp 会立刻把这个 100 年的连接丢进垃圾桶(关闭 Socket)。
- 重新走冷启动流程:DNS 解析 -> TCP 握手 -> TLS 握手 -> 建立全新的连接。
- 再次发送请求。
最终结果:用户的感知
虽然设置了 100 年缓存想让 App 变快,但结果适得其反:
-
延迟增加(抖动):
- 正常复用:50ms。
- 正常新建:300ms。
- 你的“僵尸”复用:50ms (尝试复用) + 10ms (报错) + 300ms (新建重试) = 360ms。
- 结论:比不缓存还要慢!
-
潜在的“请求失败”风险(POST 请求):
- 如果是
GET请求,OkHttp 敢大胆重试。 - 如果是
POST请求(比如提交订单):- OkHttp 可能会犹豫:“刚才那半截数据发出去了吗?服务器收到了吗?如果我重试,会不会导致用户重复下单?”
- 为了安全,OkHttp 有时会放弃重试,直接抛出异常给业务层。
- 用户界面:弹出 Toast “网络请求失败”,用户一脸懵逼(明明信号满格)。
- 如果是
总结
如果设置 100 年超时:
- 服务端会无视你,到时间就把连接掐断(Close FD)。
- Android 底层知道连接断了,标记为不可用。
- App (OkHttp) 可能会被“连接池里的过期时间”欺骗,拿出一个已经死掉的连接试图使用。
- 结果:写入失败 -> 触发异常 -> 触发重试(或者直接报错)。
这就像保存了一张 100 年有效期的“餐厅订座券”,但去的时候,那家餐厅早就倒闭或者换锁了。你拿着券在门口干瞪眼(报错),最后还得重新找一家新餐厅(重建连接)
所以,OkHttp 默认的 5 分钟,是经过精确计算的“保鲜期”。 超过 5 分钟,它假设这盘菜大概率馊了,宁愿倒掉也不给你吃,免得你拉肚子(报错)