OkHttp 缓存策略:缓存目录、缓存策略、离线缓存

说到 OkHttp 的缓存,我觉得有必要先聊一个亲身踩过的坑。有一次公司 App 在弱网环境下频繁闪退,查了半天才发现——缓存目录没配好,OkHttp 把缓存写到了外部存储一个临时路径,系统一回收,缓存就崩了。从那以后我对缓存这块就格外较真。

OkHttp 的缓存机制其实不复杂,但要真正用对,你得理清三个维度:缓存目录(存哪)、缓存策略(怎么存、存多久)、离线缓存(没网时怎么办)。咱们一个一个说。

1. 缓存目录

OkHttp 使用磁盘缓存,数据存在本地文件里。你需要在构建 OkHttpClient 时配置一个 Cache 对象,它会自动管理缓存文件的读写和释放。

我建议:缓存目录放在应用私有文件目录下(比如 context.cacheDir),既安全又不会被用户误删。放到 context.externalCacheDir 虽然空间大,但容易被清理掉,稳定性差一些。

配置示例:

// 缓存目录大小 10MB
long cacheSize = 10 * 1024 * 1024;
File cacheDirectory = new File(context.getCacheDir(), "okhttp_cache");
Cache cache = new Cache(cacheDirectory, cacheSize);

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

这里有个细节:缓存大小必须是 64KB 的倍数,否则 OkHttp 会直接抛异常。曾经我在项目里写过 10 * 1024 * 1024 - 1,编译能过,跑起来就崩。嗯,这种边界问题很容易忽略。

缓存目录的结构其实很简单:

  • journal 文件:记录缓存索引,类似数据库的日志。
  • xxxxx.0 文件:缓存响应体数据。
  • xxxxx.1 文件:缓存元数据(HEADERS)。

如果你发现缓存目录里的 journal 文件损坏了,OkHttp 会自动重建,但代价是丢失所有缓存。所以不建议在运行时手动去修改目录里的文件。

注意:缓存目录的路径不要写在 SharedPreferences 里动态读取,最好在 Application.onCreate() 中一次性初始化。我见过有人每次请求前都 new 一个 Cache 对象……后果就是内存泄漏加缓存永远不生效。

2. 缓存策略(Cache-Control)

缓存策略的核心就是 HTTP 协议里的 Cache-Control 头。OkHttp 完全遵循这个标准,你不需要额外写逻辑,只要正确设置请求/响应的 header 就行。

常用的指令有哪些?我整理了一张表:

指令 含义 放在哪
max-age=3600 缓存 1 小时内有效,过期后必须重新验证 响应头 / 请求头
no-cache 每次使用缓存前必须向服务器验证(ETag/Last-Modified) 响应头 / 请求头
no-store 完全禁止缓存,不写磁盘 响应头 / 请求头
only-if-cached 仅从缓存获取,没有缓存就返回 504 请求头
min-fresh=60 缓存有效期至少在 60 秒以上才使用 请求头

实际项目中怎么用? 举个栗子。我们做新闻类 App,列表页数据 5 分钟内不变化,后端就可以在响应里返回 Cache-Control: public, max-age=300。客户端第一次请求后,5 分钟内相同请求直接走缓存,不联网。

但有些接口(比如用户余额)不能缓存,后端返回 Cache-Control: no-store, private。你想想看,要是用户看到别人钱包里的余额,那是多大的事故。

最容易被忽略的一点:如果服务器返回了 no-cache,并不代表完全不缓存,而是缓存必须在下次使用时重新验证。验证的方法就是 ETag 或 Last-Modified。OkHttp 会帮你自动发条件请求(If-None-Match / If-Modified-Since),如果服务器返回 304,就直接用缓存。

有时候我们需要覆盖服务器策略。比如开发阶段关掉缓存,可以给请求添加 CacheControl.FORCE_NETWORK:

Request request = new Request.Builder()
        .url(url)
        .cacheControl(CacheControl.FORCE_NETWORK)
        .build();

反之,想要离线模式就得用 CacheControl.FORCE_CACHE,这个我下面详细说。

3. 离线缓存

离线缓存说白了就是——没有网络时,从缓存里拿旧数据展示,而不是让用户看到白屏。这个场景在电商、资讯类 App 里太常见了。我去年做海外市场 App 时,很多地区的网络很不稳定,离线缓存几乎成了核心体验。

OkHttp 本身不提供“自动离线切换”,你需要自己判断网络状态,然后动态调整 CacheControl。

基本思路:

  1. 联网时:正常请求,尊重服务器 Cache-Control。
  2. 断网时:给所有请求加上 CacheControl.FORCE_CACHE,强制只用缓存。

代码实现示例:

CacheControl cacheControl;
if (isNetworkAvailable(context)) {
    cacheControl = CacheControl.FORCE_NETWORK;
} else {
    cacheControl = CacheControl.FORCE_CACHE;
}

Request request = new Request.Builder()
        .url(url)
        .cacheControl(cacheControl)
        .build();
注意:用 FORCE_CACHE 时,如果缓存里根本没有这个请求的数据,OkHttp 会返回 504 错误。所以你要做好兜底——要么展示默认占位图,要么显示“网络未连接”提示。

还有一个进阶技巧:预缓存。在用户有网络时,主动请求一些大概率会用到的数据,把缓存养起来。比如用户刷完列表后,App 可以在后台静默请求前 5 条内容的详情接口,确保离线后也能看到详情。

我个人的习惯:不要把所有接口都丢给 FORCE_CACHE。像“用户的登录状态”“配置开关”这类强依赖实时性的接口,离线时应该直接显示错误,而不是展示旧数据。你想想看,如果用户都退出登录了,还展示之前的个人首页,那是功能 bug。

OkHttp 的缓存有效期是和服务端 Cache-Control 挂钩的。如果你想要一个更灵活的离线策略——比如“离线时即使缓存过期也先用着”——可以自己实现一个 Interceptor 来覆盖缓存规则。这里给个简化版的思路:

class OfflineInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        if (!isNetworkAvailable()) {
            // 离线时允许使用过期缓存
            request = request.newBuilder()
                    .cacheControl(new CacheControl.Builder()
                            .maxStale(Integer.MAX_VALUE, TimeUnit.SECONDS)
                            .build())
                    .build();
        }
        return chain.proceed(request);
    }
}

maxStale 这个参数很多人不知道——它告诉 OkHttp,即使缓存过期了,只要还在 maxStale 时间内,仍然可以返回。你可以设成非常长的值(比如 7 天),这样离线缓存就变成了“只要曾经缓存过,都能看”。

小结一下:缓存核心逻辑

我画了一张图,把 OkHttp 原生的缓存决策流程整理出来了:

OkHttp 缓存决策流程图 发起请求 检查缓存目录 journal 有可用缓存吗? 无缓存 发网络请求 有缓存 检查Cache-Control 过期 → 验证 走网络验证(ETag等) 未过期 直接返回缓存 返回新响应 / 304 复用缓存 离线缓存通过 CacheControl.FORCE_CACHE 强制跳过网络,流程简化

看完这张图你会发现,OkHttp 的缓存本质上是一个状态机:有缓存 → 看是否过期 → 过期则验证 / 未过期则直接返回。离线模式不过是把这个状态机里的“网络检查”分支全部堵死,强制走缓存路径。

最后聊一句:别为了 Mock 测试而把缓存搞复杂。很多团队在做 CI 分层测试时,直接给测试环境的请求加上 Cache-Control: no-cache,这样每次都是真实请求,保证 Mock 数据能落到被测接口上。我个人觉得这是一种省心又安全的做法。