离线场景下缓存读取流程:请求拦截器处理,CacheStrategy命中判断,缓存响应组装
在离线场景中,设备无法访问网络,OkHttp 必须完全依赖本地磁盘缓存来满足请求。整个读取流程被封装在 CacheInterceptor 拦截器中,依次经过三个核心阶段:请求拦截器处理 → CacheStrategy 命中判断 → 缓存响应组装。理解这一流程是构建离线友好缓存系统的基石。
关键前提:缓存必须已通过
Cache 对象初始化(包含 writeTimeout、readTimeout、目录及大小限制),且在离线前至少成功写入过一次响应数据。
1. 请求拦截器处理
OkHttp 的 RealInterceptorChain 在调用网络拦截器前,会先执行 CacheInterceptor.intercept()。拦截器首先检查请求是否允许使用缓存:
- 如果请求头包含
Cache-Control: no-cache或no-store,直接跳过缓存处理。 - 否则,通过
Cache.get(request)尝试从磁盘缓存中获取与请求 URL 匹配的CacheEntry。 - 若缓存未命中,则直接传递请求给网络层(离线时网络层会快速失败)。
// CacheInterceptor 核心逻辑示意(简化版)
@Override public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
// 1. 获取缓存快照
Response cacheCandidate = cache.get(request);
// 2. 生成缓存策略
CacheStrategy strategy = new CacheStrategy.Factory(now, request, cacheCandidate).get();
// 3. 根据策略决定下一步
if (strategy.networkRequest == null && strategy.cacheResponse == null) {
return new Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(504)
.message("Unsatisfiable Request (only-if-cached)")
.build();
}
if (strategy.networkRequest == null) {
// 完全离线命中 → 直接组装缓存响应
return strategy.cacheResponse;
}
// 否则走网络(离线场景最终会抛出 SocketException)
return chain.proceed(strategy.networkRequest);
}
2. CacheStrategy 命中判断
CacheStrategy.Factory 是判断命中的核心类。它基于以下输入:
- 当前时间 (
now) - 请求的
Cache-Control约束 - 缓存的响应头(Date、Expires、Last-Modified、ETag、Age 等)
判断逻辑分为三个分支:
| 条件 | 结果 |
|---|---|
| 缓存缺失或缓存过时(Age > max-age) | networkRequest = 原始请求(需网络验证或重新获取) cacheResponse = null |
| 缓存新鲜(Age ≤ freshness)且请求无强制校验 | networkRequest = null cacheResponse = 缓存的 Response 对象 |
| 请求要求 only-if-cached 但缓存不可用 | networkRequest = null, cacheResponse = null → 返回 504 |
离线场景下,只要缓存未过期且请求未附带 no-cache,就会进入“网络请求为空”分支,从而完全绕过网络层。
可靠性技巧:离线前应主动触发一次缓存写入(即使网络可用),并确保响应头包含合理的
Cache-Control: max-age 或 Expires。推荐设置 max-age 为离线预期时长(如 3600 秒)。
3. 缓存响应组装
当 strategy.cacheResponse 非空时,OkHttp 会从 CacheEntry 还原完整的 Response 对象:
- 头部还原:读取存储在磁盘的原始 HTTP 头(包括 Content-Type、Content-Length、Cache-Control 等)。
- 主体还原:通过
CacheEntry的输入流,配合Source和Buffer构建可读取的响应体。OkHttp 使用内存映射或文件流避免 OOM。 - 组装 Response:绑定原始请求、协议版本、状态码、消息、首部及主体。组装后的 Response 直接返回到应用层。
// 组装缓存 Response 的典型过程(来自 CacheInterceptor)
Response cacheResponse = new Response.Builder()
.request(chain.request())
.protocol(cacheResponse.protocol())
.code(cacheResponse.code())
.message(cacheResponse.message())
.headers(cacheResponse.headers())
.body(cacheResponse.body()) // 注意:body 复用自 CacheEntry 的 Source
.handshake(null) // 离线时无 TLS 握手信息
.sentRequestAtMillis(-1L)
.receivedResponseAtMillis(-1L)
.cacheResponse(cacheResponse)
.build();
性能陷阱:组装时反复读取磁盘头图元文件会导致 I/O 抖动。OkHttp 在
Cache.get() 内部使用了 DiskLruCache 的快照机制,但应用层应避免在 UI 线程直接读取大响应体。建议在离线场景中使用 Response.body().bytes() 时置于后台线程。
整体流程图
常见错误与优化
- 缓存失效过早:忽略
max-age导致离线后立即过期 → 设置与离线时长匹配的max-age值。 - 大缓存阻塞主线程:
Response.body().bytes()直接读取会拖慢 UI → 使用source().readAll()异步或分块读取。 - 磁盘 I/O 竞争:多个请求同时读相同缓存条目 → 利用
DiskLruCache的Snapshot引用计数,应用层避免并发重复构建CacheEntry。 - 未处理 504 Unsatisfiable Request:当请求包含
only-if-cached且缓存无效时,应优雅降级显示离线数据。
调试方法:在
CacheInterceptor 拦截器中添加日志,输出 strategy.networkRequest 和 strategy.cacheResponse 的 URL 与响应码。使用 stetho 或自写拦截器观察缓存填充情况。