3. 拦截器链中的缓存拦截器:CacheInterceptor 源码分析与策略抉择

在 OkHttp 的拦截器链中,CacheInterceptor 扮演着守门人的角色。它位于 BridgeInterceptor(负责处理请求头/自动压缩)之后,ConnectInterceptor(负责建立 TCP/TLS 连接)之前。它的核心职责是根据预设的缓存策略,决定是直接返回缓存的响应、发起一次条件 GET(带 ETag/If-None-Match)请求,还是完全忽略缓存、直接发送完整请求到服务器。

本章将从源码层面剖析 CacheInterceptor 的主流程,深入理解其缓存命中与写入的决策机制,并掌握如何通过配置 CacheStrategy 来精确控制网络与缓存策略的选择。

CacheInterceptor 核心决策流程 CacheInterceptor.intercept() 决策链路 1. 进入 intercept() 2. 查找缓存候选者 候选者可用? 3a. 发起网络请求 3b. 构建 CacheStrategy 策略: 仅使用缓存 策略: 条件 GET 4. 合并返回 Response 说明:CacheStrategy 是决策核心。条件 GET 通过 ETag 或 Last-Modified 减少带宽消耗。 服务器返回 304 状态码表示资源未修改,拦截器会合并新的头部信息到缓存响应中。

3.1 核心源码分析:intercept() 方法的完整闭环

CacheInterceptor 的 intercept() 方法实现了缓存的核心闭环。以下代码展示了其骨架逻辑:

@Override
public Response intercept(Chain chain) throws IOException {
    // 1. 尝试从缓存中获取候选响应
    Response cacheCandidate = cache != null ? cache.get(request) : null;

    long now = System.currentTimeMillis();
    // 2. 利用 CacheStrategy 工厂生成当前请求的策略
    CacheStrategy strategy = new CacheStrategy.Factory(now, request, cacheCandidate).get();
    Request networkRequest = strategy.networkRequest;
    Response cacheResponse = strategy.cacheResponse;

    // 3. 策略禁止网络且无缓存 -> 返回 504 Unsatisfiable Request
    if (networkRequest == null && cacheResponse == null) {
        return new Response.Builder()
                .code(504)
                .message("Unsatisfiable Request (only-if-cached)")
                ...
                .build();
    }

    // 4. 策略禁止网络但有缓存 -> 直接返回缓存响应
    if (networkRequest == null) {
        return cacheResponse.newBuilder()
                .cacheResponse(stripBody(cacheResponse))
                .build();
    }

    // 5. 策略允许网络 -> 发起实际网络请求
    Response networkResponse = null;
    try {
        networkResponse = chain.proceed(networkRequest);
    } catch (IOException e) {
        // 6. 网络请求失败,但有缓存回退 -> 使用过期缓存(离线容灾)
        if (cacheResponse != null) {
            return cacheResponse.newBuilder()
                    .cacheResponse(stripBody(cacheResponse))
                    .build();
        }
        throw e;
    }

    // 7. 缓存可用且网络返回 304 -> 使用缓存响应,合并网络头部
    if (cacheResponse != null) {
        if (networkResponse.code() == HTTP_NOT_MODIFIED) {
            Response response = cacheResponse.newBuilder()
                    .headers(networkResponse.headers())
                    .cacheResponse(stripBody(cacheResponse))
                    .networkResponse(stripBody(networkResponse))
                    .build();
            ...
            return response;
        }
    }

    // 8. 写入缓存(只有当响应可缓存时)
    Response response = networkResponse.newBuilder()
            .cacheResponse(stripBody(cacheResponse))
            .networkResponse(stripBody(networkResponse))
            .build();
    if (cache != null) {
        if (response.cacheControl().noStore() || ...) {
            cache.put(response);
        }
    }
    return response;
}

3.2 缓存命中与写入时机

缓存的命中与写入完全由 CacheStrategy 控制,它遵循 RFC 7234 规范。以下表格展示了常见的 Cache-Control 指令如何影响决策:

场景 请求/响应指令 CacheStrategy 结果 实际网络行为
强制缓存 max-age=3600 (Response) 仅使用缓存 直接返回缓存的响应体,不发起任何网络请求。
条件验证 no-cache 或 携带 ETag 条件 GET 发起带 If-None-Match 头的请求。304 则合并头部并复用缓存体,200 则更新缓存。
禁止存储 no-store (Response) 仅网络 忽略所有缓存逻辑,直接发起完整网络请求,不写入缓存。
仅限缓存 only-if-cached (Request) 仅缓存 / 504 缓存未命中则立即返回 504 Unsatisfiable Request,不发起网络请求。

写入时机核心原则: 只有当 networkRequest 不为 null(即实际发出了网络请求),且返回的 networkResponse 是可缓存的状态码(通常为 2xx、3xx),并且响应头中不包含 no-store 指令时,CacheInterceptor 才会在返回响应前将结果写入 DiskLruCache。如果响应包含 private 指令,缓存会正常写入,但共享缓存不得存储。

3.3 网络与缓存策略选择:CacheStrategy 的深度工作流

CacheStrategy 内部类通过工厂方法 get() 计算决策。其核心算法会计算缓存响应的当前年龄(ageMillis),并与新鲜度生命周期(freshnessLifespan)进行对比:

  • 年龄计算: ageMillis = (receivedTimestamp - sentTimestamp) + (ageFromHeader ?: 0)。
  • 新鲜度计算: freshnessLifespan = maxAge != -1 ? maxAge : (expiresTimestamp - dateValue)。
  • 决策逻辑: 如果 ageMillis 小于 freshnessLifespan,且没有验证器要求重新验证,则直接使用缓存。如果 ageMillis 超过新鲜度,但存在 ETag 或 Last-Modified,则生成条件 GET 请求。
// CacheStrategy.Factory 中的核心计算逻辑
if (ageMillis < freshnessLifespan) {
    // 1. 缓存仍然是新鲜的,直接使用
    strategy.networkRequest = null;
    strategy.cacheResponse = cacheResponse;
} else if (hasValidator(cacheResponse)) {
    // 2. 缓存已过期,但存在验证器(ETag/Last-Modified),执行条件 GET
    Request.Builder conditionalRequest = request.newBuilder();
    if (etag != null) {
        conditionalRequest.header("If-None-Match", etag);
    }
    strategy.networkRequest = conditionalRequest.build();
    strategy.cacheResponse = cacheResponse;
} else {
    // 3. 缓存已过期且无验证器,直接发起网络请求
    strategy.networkRequest = request;
    strategy.cacheResponse = null;
}

3.4 调试、常见错误与性能优化

调试技巧: 通过添加自定义 Interceptor 可以清晰观察到 CacheInterceptor 的执行结果。

// 添加日志拦截器观察缓存行为
OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(chain -> {
        Request request = chain.request();
        Log.d("CacheDemo", "请求 URL: " + request.url());
        Log.d("CacheDemo", "请求头: " + request.headers());
        Response response = chain.proceed(request);
        // 关键:检查 cacheResponse 和 networkResponse 是否为 null
        Log.d("CacheDemo", "是否来自缓存: " + (response.cacheResponse() != null));
        Log.d("CacheDemo", "是否来自网络: " + (response.networkResponse() != null));
        return response;
    })
    .cache(new Cache(new File(context.getCacheDir(), "http_cache"), 10 * 1024 * 1024))
    .build();

常见错误陷阱:

  • 禁用缓存而非验证: 在处理用户信息变更时,许多开发者使用 no-cache 而非 must-revalidate,导致每次都浪费带宽重新下载完整内容。
  • 忘记设置 Cache 目录: 只添加了 Cache-Control 头但未使用 OkHttpClient.cache() 配置 Cache 对象,导致拦截器永远无法从磁盘命中缓存。
  • 静态资源不缓存: 动态 API 响应未设置 max-age 或 ETag,导致每次页面切换都需要重复请求相同的资源数据。

性能优化建议: 利用 CacheStrategy 实现离线优先架构。对于文档型列表页,建议设置 max-age=60, stale-while-revalidate=86400,这样可以在缓存过期后仍然立即展示旧数据,同时在后台静默更新,极大提升应用启动速度与内容加载感知。