4. Cache-Control 头解析:max-age、no-cache、no-store、must-revalidate、stale-while-revalidate
4.1 本章概述
HTTP 缓存控制的核心是由服务器通过 Cache-Control 响应头传达的。在 OkHttp 拦截器中实现离线缓存,本质上是正确解析这些指令并驱动缓存策略。本章深入解析 5 个最关键指令的行为差异、标准语义以及在 Android 离线场景下的实际影响。
4.2 核心指令对比表
| 指令 | 标准语义 | 对离线缓存的影响 | 典型适用场景 |
|---|---|---|---|
| max-age=seconds | 缓存可被使用的最长时间(从响应生成时刻起) | 决定缓存条目的过期时间戳;离线时只要未过期即可直接返回 | 静态资源(图片、样式)、新闻列表 |
| no-cache | 缓存不能直接使用,必须先向服务器验证(条件请求) | 离线时拒绝返回任何缓存内容(即使有存储数据) | 实时交易数据、用户余额 |
| no-store | 缓存禁止存储响应内容 | 拦截器直接丢弃响应体,不写入缓存数据库 | 支付令牌、一次性密钥、隐私敏感数据 |
| must-revalidate | 缓存过期后必须验证,禁止使用过期缓存(即使服务器不可达) | 离线且无法回源时,直接返回504而非过期缓存 | 配置参数、API 元数据 |
| stale-while-revalidate=seconds | 允许在后台异步验证的同时,短时间内使用过期缓存(窗口期) | 离线且在窗口期内可返回过期缓存;窗口期后回退到标准逻辑 | 用户首页、推荐流、仪表盘数据 |
4.3 解析流程与决策树
一个完整的 Cache-Control 解析模块必须在拦截器处理响应时依次检查以下指令,优先级从高到低:
- no-store:若存在,直接跳过一切缓存写入逻辑。
- no-cache:若存在,缓存可写入存储但标记为“需验证”;离线时禁止返回。
- max-age:计算过期时间戳
Date + max-age并存储。 - must-revalidate:覆盖离线回退逻辑,禁止使用过期缓存。
- stale-while-revalidate:设置一个额外的“宽限期”;从原始过期时间点开始计算窗口期。
⚠️ 常见陷阱:
max-age 与 Expires 同时出现时,max-age 优先级更高。no-cache 不等于不缓存,而是“每次使用前必须验证”——这与许多初学者的理解正好相反。
4.4 内联 SVG:Cache-Control 解析状态机
4.5 拦截器中解析 Cache-Control 的核心实现
以下代码片段展示了在自定义拦截器中如何解析 Cache-Control 头并提取关键参数。将这段逻辑嵌入 CacheInterceptor.intercept() 中是实现离线缓存的第一步。
import okhttp3.Response;
import java.util.concurrent.TimeUnit;
public class CacheControlParser {
public static CacheControlResult parse(Response response, long currentTimeMillis) {
String cacheControl = response.header("Cache-Control");
CacheControlResult result = new CacheControlResult();
if (cacheControl == null || cacheControl.isEmpty()) {
// 无 Cache-Control 时保守处理:标记为不可缓存
result.isNoCache = true;
return result;
}
// 按逗号分割,去除空格后逐个判断
String[] directives = cacheControl.split(",");
for (String directive : directives) {
String trimmed = directive.trim().toLowerCase();
if (trimmed.equals("no-store")) {
result.isNoStore = true;
return result; // no-store 优先级最高,直接返回
}
if (trimmed.equals("no-cache")) {
result.isNoCache = true;
// 不立即返回,继续解析其他指令
}
if (trimmed.equals("must-revalidate")) {
result.isMustRevalidate = true;
}
if (trimmed.startsWith("max-age=")) {
long maxAgeSeconds = Long.parseLong(trimmed.substring(8));
result.expiresAt = currentTimeMillis + TimeUnit.SECONDS.toMillis(maxAgeSeconds);
}
if (trimmed.startsWith("stale-while-revalidate=")) {
long windowSeconds = Long.parseLong(trimmed.substring(23));
result.staleWindowMillis = TimeUnit.SECONDS.toMillis(windowSeconds);
}
}
return result;
}
public static class CacheControlResult {
boolean isNoStore = false;
boolean isNoCache = false;
boolean isMustRevalidate = false;
long expiresAt = 0L;
long staleWindowMillis = 0L;
public boolean isCacheable() {
return !isNoStore;
}
public boolean isUsableOffline(long currentTimeMillis) {
if (isNoCache) return false; // no-cache 离线不可用
if (currentTimeMillis <= expiresAt) return true; // 未过期
if (isMustRevalidate) return false; // 过期 + must-revalidate = 不可用
// 检查 stale-while-revalidate 窗口
if (staleWindowMillis > 0) {
long staleEnd = expiresAt + staleWindowMillis;
if (currentTimeMillis <= staleEnd) return true;
}
return false;
}
}
}
4.6 调试与验证方法
- 使用 Charles / mitmproxy:截获真实 API 响应,手动修改
Cache-Control头(如max-age=0或no-cache),观察isUsableOffline()返回值的变化。 - OkHttp 日志拦截器:在自定义拦截器中打印解析后的
CacheControlResult字段,确认解析逻辑与预期一致。 - Edge Case 测试列表:
- 响应头包含
Cache-Control: no-store, max-age=3600→ 期望isNoStore = true,不写入缓存。 - 响应头包含
Cache-Control: no-cache, max-age=3600→ 期望isNoCache = true,但expiresAt仍被正确计算。 - 响应头包含
Cache-Control: max-age=60, stale-while-revalidate=300→ 离线 350 秒内可用;超过 360 秒不可用。
- 响应头包含
📌 性能优化提示:解析
Cache-Control 时避免在每一条请求上重复调用 response.header() 多次。建议在拦截器中一次性提取原始字符串,再用上方的 CacheControlParser.parse() 方法集中解析。