1. OkHttp 缓存核心组件解析
本章深入剖析 OkHttp 离线缓存体系的四个基础构件:Cache 类、CacheStrategy、DiskLruCache 与拦截器链。理解这些组件的职责与协作方式是掌握离线缓存同步策略的前置条件。
核心要点:OkHttp 的缓存并非全自动行为,而是通过拦截器在请求/响应链路中插入决策点,由 CacheStrategy 根据缓存头与本地缓存数据动态决定是使用缓存、发起网络请求还是返回缓存失败。
1.1 架构概览
下图展示了缓存组件在 OkHttp 请求链路中的位置与关系:
1.2 Cache 类
Cache 是 OkHttp 暴露给开发者的高层缓存接口,位于 okhttp3.Cache。它封装了 DiskLruCache 并提供缓存写入、读取、驱逐、大小监控等能力。
| 成员/方法 | 说明 |
|---|---|
| 构造方法 | Cache(File directory, long maxSize) 指定缓存目录与最大字节数 |
| get(Request) | 根据请求查找缓存条目,返回 CacheResponse?|null |
| put(Response) | 将网络响应写入缓存(仅当响应允许缓存时) |
| remove(Request) | 主动删除指定请求的缓存 |
| update(Response, Response) | 更新已有缓存条目(用于条件请求后的刷新) |
| size() / maxSize() | 当前缓存占用字节 / 最大容量 |
| evictAll() | 清空缓存(谨慎使用) |
| close() | 关闭缓存,释放所有资源 |
实际项目中初始化缓存:
File cacheDir = new File(context.getCacheDir(), "http_cache");
int cacheSize = 10 * 1024 * 1024; // 10MB
Cache cache = new Cache(cacheDir, cacheSize);
OkHttpClient client = new OkHttpClient.Builder()
.cache(cache)
.build();
1.3 CacheStrategy 缓存策略
CacheStrategy 是 OkHttp 缓存决策的核心计算单元。它接收一个原始的 Request 和缓存的 Response(如果存在),输出一个 CacheStrategy 对象,其中包含两个字段:
- networkRequest:如果为
null,则直接返回缓存;否则发起网络请求。 - cacheResponse:如果为
null,则不可使用缓存;否则使用此缓存响应。
决策依据主要来自 HTTP 缓存头:
| 请求头 / 响应头 | 说明 |
|---|---|
Cache-Control: max-age=… | 缓存新鲜期(秒) |
Cache-Control: no-cache | 每次使用时必须与服务器验证 |
Cache-Control: no-store | 禁止缓存(响应和请求都不可缓存) |
Cache-Control: only-if-cached | 只使用缓存,若未命中则返回 504 |
Expires | 过期时间(优先级低于 max-age) |
ETag / Last-Modified | 条件验证(If-None-Match / If-Modified-Since) |
Age | 响应已存在的时间(由代理添加) |
常见误区:仅通过 max-age 控制缓存时长是不够的。若服务端返回 Cache-Control: no-cache,客户端仍然会发起条件请求(携带 If-None-Match 等头),若服务返回 304 Not Modified 则使用缓存;但这个过程仍消耗一次网络往返。真正的“离线可用”需要配合 Cache-Control: max-age=…, stale-while-revalidate 等策略。
1.4 DiskLruCache 源码分析
DiskLruCache 是 OkHttp 缓存组件的底层存储实现,基于 LRU(Least Recently Used)驱逐算法。核心源码位于 okhttp3.internal.cache.DiskLruCache。其主要特性:
- Journal 文件:记录每次读取、写入、删除操作,用于重启后重建 LRU 顺序。
- 条目(Entry):每个缓存 URL 对应一个条目,每个条目包含最多 2 个快照(Clean / Dirty)。
- 容量控制:当总大小超出
maxSize时,触发trimToSize()驱逐最旧条目。 - 原子性:写入通过临时文件 + 重命名保证原子性;读操作通过
Snapshot提供一致性视图。
Journal 文件每行格式示例:
DIRTY 1be32b1f5d5a5a5e
READ 1be32b1f5d5a5a5e
CLEAN 1be32b1f5d5a5a5e 128 256
REMOVE 1be32b1f5d5a5a5e
其中 DIRTY 表示正在写入,CLEAN 后跟文件大小(两个数字分别对应索引 0 和 1 的文件大小),REMOVE 表示删除。
性能关键点:DiskLruCache 的 get() 和 edit() 方法都会先读取整个 Journal 文件到内存(最多 32KB 限制),因此缓存条目过多(例如超过 2000 条)时启动加载可能变慢。优化建议:配置较大的 maxSize 但不要超过 100MB,或者定期 close() 后重新打开。
1.5 拦截器链中的缓存拦截器
OkHttp 默认在内部添加了 CacheInterceptor,位于拦截器链的末尾(在网络拦截器之后)。其处理流程如下:
- 获取缓存响应:调用
Cache.get(request)。 - 创建 CacheStrategy:传入原始请求与缓存响应。
- 决策分支:
- 若
networkRequest == null且cacheResponse != null→ 直接返回缓存(离线命中)。 - 若
networkRequest != null且cacheResponse == null→ 发起网络请求,并将响应缓存。 - 若两者都非空 → 发起条件请求(带
If-None-Match/If-Modified-Since),若服务返回 304 则更新缓存并返回;否则替换缓存并返回新响应。 - 若两者均为空 → 返回 504 Gateway Timeout(未缓存且网络不允许?实际会走网络请求,仅当
only-if-cached时返回 504)。
- 若
- 缓存写入:将由网络获取的有效响应通过
Cache.put(response)写入。 - 响应处理:移除不必要的
Content-Length等头,保持与原始响应一致。
自定义拦截器可以放在 CacheInterceptor 之前或之后以影响缓存行为。例如在应用拦截器中强制添加 Cache-Control: only-if-cached 实现离线优先。
代码实验:查看默认拦截器顺序
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(chain -> {
System.out.println("应用拦截器: " + chain.request().url());
return chain.proceed(chain.request());
})
.addNetworkInterceptor(chain -> {
System.out.println("网络拦截器: " + chain.request().url());
return chain.proceed(chain.request());
})
.cache(new Cache(new File("cache"), 1024 * 1024))
.build();
// 执行一次请求,观察输出顺序:
// 应用拦截器 → 网络拦截器 → CacheInterceptor(内部)
1.6 常见问题与调优
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 缓存未生效,每次都是网络请求 | 服务端返回 Cache-Control: no-cache 或 no-store | 与后端协商修改缓存头;或在应用拦截器中强制缓存(需谨慎) |
| 缓存大小超过设置值仍不清理 | DiskLruCache 的 trimToSize() 在每次写入后异步执行 | 确保写入完成后等待下一次 flush() 或 close();检查 Journal 损坏 |
| 离线时请求返回 504 | 未命中缓存且未设置 only-if-cached | 在应用拦截器中判断网络可用性,若离线则添加 Cache-Control: only-if-cached |
| 缓存命中但数据过期 | 未配置 stale-while-revalidate 或 stale-if-error | 服务端返回 Cache-Control: max-age=60, stale-while-revalidate=3600 |