OkHttp 缓存架构:它到底怎么存的

讲到 Retrofit 缓存,绕不开 OkHttp。毕竟 Retrofit 只是把网络请求封装成了接口,真正干活的是 OkHttp。缓存这块更是彻底交给了 OkHttp。我当初刚接手公司项目时,发现缓存配置乱七八糟,有的接口重复请求几十次——说白了,就是没搞懂 OkHttp 的缓存架构。

咱们先画个图,把整体脉络理清楚。

OkHttp 缓存架构概览 OkHttp 缓存核心架构 Cache 类 存储引擎 DiskLruCache Journal 文件 决策引擎 缓存策略 过期判断 cacheDirectory + cacheMaxSize 核心流程 请求 → 决策引擎判断是否可用缓存 → 若过期/不可用则发起网络请求 响应回来 → 存储引擎写入 DiskLruCache → 更新 Journal 索引

Cache 类——入口也是总控

OkHttp 的缓存入口非常干净。你只需要构建 OkHttpClient 的时候扔一个 Cache 对象进去。这个 Cache 类干了三件事:

  • 管理存储:内部包装了 DiskLruCache,负责把响应数据写到磁盘。
  • 决策缓存:根据请求头和响应头,判断「能不能用缓存」「缓存是否过期」。
  • 提供状态:我可以随时查 hit count、miss count、network count——方便调优。

小提示:我刚用 OkHttp 时以为 Cache 类会维护内存缓存,其实它只在磁盘干活。内存里的缓存条目只有个 LRU 索引,数据还是从磁盘读的。所以别指望它像图片缓存一样毫秒级响应。

看看最常用的构造方式:

// 指定目录 /data/data/包名/cache/http
// 最大缓存 10 MB
int cacheSize = 10 * 1024 * 1024;
File cacheDir = new File(context.getCacheDir(), "http");
Cache cache = new Cache(cacheDir, cacheSize);

OkHttpClient client = new OkHttpClient.Builder()
    .cache(cache)
    .build();

注意:cacheSize 是硬盘空间的硬限制。达到上限后,OkHttp 会按照 LRU 策略逐步淘汰旧数据。我在项目中遇到过有人把缓存设成 0 字节,其实等于没缓存——因为 OkHttp 的实现里,如果 maxSize == 0 会直接跳过所有缓存逻辑。

缓存目录——别乱放

缓存目录的选择挺讲究。我个人习惯放在 context.getCacheDir() 下,因为 Android 会在系统空间不足时自动清理这个目录。很多新手直接写死路径 /sdcard/myapp/cache,结果应用被系统清理时数据残留,甚至引发 Crash。

OkHttp 在缓存目录里会创建这些文件:

文件/目录 作用
journal 日志文件,记录所有缓存操作的索引。DiskLruCache 靠它维持 LRU 顺序。
journal.tmp 临时文件,写 journal 时使用。读写异常时可用来恢复。
journal.bkp journal 的备份,当 journal 损坏时会用这个恢复。
[0-9a-f]{16}.0 缓存条目文件,每个 URL 对应一个随机命名的文件,存储响应 Header 和 Body。

⚠ 我曾经踩过的坑: 有用户反馈缓存目录下生成了一堆 .0 文件无法清理。查了半天发现是 DiskLruCache 的 journal 文件被系统 kill 时写坏了,导致缓存条目无法回收。后来我加了一行代码:cache.evictAll() 在应用启动时检测 journal 损坏就清空。

缓存大小——设多少才合适?

这是一个被问烂了的问题。知乎上有人说 10MB,有人说 50MB。说实话,没有标准答案。我个人的经验值是:

  • 图文类应用:10~20MB。图片已经在 Glide 那边做了磁盘缓存,OkHttp 只缓存 API 响应。
  • 列表类应用:5~10MB。首页数据、分类列表,响应体通常很小。
  • 音视频元数据应用:1~2MB。真的不需要大,元数据而已。

你可能会问:设大了会不会有副作用?会。DiskLruCache 启动时需要读 journal 文件重建 LRU 索引,journal 大小和缓存条目数成正比。我试过把缓存设成 100MB,应用冷启动时 journal 文件解析花了 200ms——用户能明显感觉到白屏。

技巧:想要实时监控缓存大小?通过 Cache.size() 方法获取当前已使用的字节数,配合 maxSize() 算一下使用率。我一般在开发版首页展示这个指标,方便调优。

缓存目录和大小设置的完整示例

把上面的知识串起来,给一个我自认为比较健壮的做法:

public class OkHttpCacheProvider {

    private static final int MAX_CACHE_SIZE = 10 * 1024 * 1024; // 10MB

    public static Cache provideCache(Context context) {
        File cacheDir = new File(context.getCacheDir(), "okhttp_cache");
        if (!cacheDir.exists()) {
            cacheDir.mkdirs();
        }

        Cache cache = new Cache(cacheDir, MAX_CACHE_SIZE);

        // 检测 journal 是否损坏
        try {
            cache.initialize(); // 内部会尝试读取 journal
        } catch (IOException e) {
            // journal 损坏,清空重建
            try {
                cache.evictAll();
            } catch (IOException ex) {
                // 不理了
            }
        }

        return cache;
    }
}

嗯,注意 cache.initialize() 这个方法是 OkHttp 4.x 内部暴露的,外部调用需要用反射或者升级到 OkHttp 5.x。如果不想折腾,最简单的判断就是:启动后取 cache.size() 如果返回负数或者抛出异常,就 evictAll()。

说了这么多,你可能会想:怎么知道我设的缓存大小到底够不够?其实 OkHttp 提供了几个有用的统计方法:

方法 说明
cache.hitCount() 缓存命中的次数
cache.missCount() 缓存未命中的次数
cache.networkCount() 实际发送网络请求的次数
cache.requestCount() 所有请求的总次数

我有个习惯:在 Release 版本里埋点上报这些数据。如果hitCount / requestCount 长期低于 30%,说明缓存策略太保守或者缓存太小,该调了。

核心结论:OkHttp 缓存架构本质上就是「DiskLruCache + 策略决策」。Cache 类把这两块包了一层,暴露给开发者一个清爽的接口。缓存目录和大小虽然基础,但踩坑概率不低。我这几年遇到的线上问题,有一小半都能追溯到「缓存目录被系统清掉但 journal 没同步」或者「缓存大小设得太大导致启动卡顿」。

最后提一下,看到这里你应该对 OkHttp 的缓存骨架有数了。下一层咱们就要深入缓存策略——那些 CacheControl、max-age、must-revalidate 到底是啥意思?不急,先把基础打牢。