离线场景下缓存读取流程:请求拦截器处理,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() 时置于后台线程。

整体流程图

离线场景缓存读取流程 请求进入 CacheInterceptor.intercept() DiskLruCache 快照读取 CacheStrategy 命中判断 离线可用? 组装 Response 并返回 网络层 当策略命中且cacheResponse非空时, 直接跳过网络请求返回本地缓存

常见错误与优化

  • 缓存失效过早:忽略 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 或自写拦截器观察缓存填充情况。