4. 基于拦截器的离线缓存实现:Interceptor原理、网络拦截器与应用拦截器区别
4.1 Interceptor 原理概述
Retrofit 的底层网络通信由 OkHttp 完成,而 OkHttp 的核心扩展机制就是拦截器链(Interceptor Chain)。每一个网络请求在发出前和响应返回后,都会依次经过一组注册好的拦截器,形成一个类似“洋葱”的嵌套调用模型。
- 责任链模式:每个拦截器都可以对 Request 进行预处理,然后调用
chain.proceed()将请求传给下一个拦截器,最后拿到 Response 进行后处理。 - 分层处理:拦截器链允许开发者在不修改 OkHttp 核心代码的前提下,插入自定义逻辑——缓存、重试、日志、Header 注入等。
- 离线缓存切入点:利用拦截器对 Request/Response 的拦截能力,可以在无网络时直接返回本地缓存,避免请求失败。
核心认知:拦截器是 OkHttp 赋予开发者唯一“劫持”网络请求全生命周期的钩子,离线缓存正是这个钩子最典型的高价值应用场景。
4.2 网络拦截器 vs 应用拦截器
OkHttp 提供两种注册拦截器的方式:addInterceptor()(应用拦截器)和 addNetworkInterceptor()(网络拦截器)。它们在执行时机、触发次数和可见范围上存在本质差异。
| 对比维度 | 应用拦截器(Application Interceptor) | 网络拦截器(Network Interceptor) |
|---|---|---|
| 注册方式 | OkHttpClient.Builder.addInterceptor() |
OkHttpClient.Builder.addNetworkInterceptor() |
| 执行时机 | 在请求真正发出之前执行,一次请求只触发一次 | 在每次网络往返时执行,包括重定向和重试 |
| 可见范围 | 看不到重定向、重试等中间步骤 | 可以观察到完整的网络交互过程(重定向、重试、连接复用) |
| 执行次数 | 始终 1 次 | 可能 多次(重定向/重试时重复触发) |
| Response 来源 | 可能来自缓存,也可能来自网络 | 始终来自网络(或者缓存策略触发的网络请求) |
| 适用场景 | 离线缓存、Header 注入、请求/响应加密解密、统一的业务逻辑处理 | 网络日志、监控统计、重定向跟踪、Cookie 同步 |
选型警告:离线缓存拦截器必须使用 应用拦截器 实现。如果使用网络拦截器,在无网络时拦截器根本不会被触发,无法起到“离线回退”的作用。
4.3 离线缓存拦截器设计思路
离线缓存的核心是利用应用拦截器在请求链最外层的特性,在网络不可用时直接返回已存储的缓存响应,避免请求穿透到网络层导致失败。
- 检测网络状态:在
intercept()方法中通过ConnectivityManager判断当前是否联网。 - 有网络:正常放行请求,同时通过
Cache-Control头控制缓存有效期(如max-age=60)。 - 无网络:修改请求头为
only-if-cached,强制 OkHttp 仅从缓存返回;若缓存不存在则返回自定义兜底响应(如 503 + 错误标识)。 - 缓存写入:依赖 OkHttp 自带的
Cache对象自动管理磁盘缓存,无需手动序列化。
设计原则:离线缓存拦截器应位于拦截器链的最前端(最先添加),确保在进入其他拦截器之前就完成网络判断和缓存回退。
4.4 核心代码实现
下面是一个完整的离线缓存拦截器实现,包含网络检测、缓存策略注入和兜底响应。
// OfflineCacheInterceptor.kt
class OfflineCacheInterceptor(
private val context: Context,
private val maxAgeSeconds: Int = 60 // 在线缓存有效期
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// 1. 检测网络状态
val isOnline = isNetworkAvailable()
return if (!isOnline) {
// 2. 无网络 → 仅从缓存返回
val cacheRequest = originalRequest.newBuilder()
.header("Cache-Control", "only-if-cached")
.removeHeader("Pragma")
.build()
try {
chain.proceed(cacheRequest)
} catch (e: Exception) {
// 3. 缓存不存在时返回兜底响应
Response.Builder()
.code(503)
.message("Offline: No cache available")
.protocol(Protocol.HTTP_1_1)
.request(originalRequest)
.body(ResponseBody.create(
MediaType.parse("text/plain"),
"{\"error\":\"offline\",\"message\":\"无网络且无缓存\"}"
))
.build()
}
} else {
// 4. 有网络 → 正常请求 + 设置缓存有效期
val networkRequest = originalRequest.newBuilder()
.header("Cache-Control", "max-age=$maxAgeSeconds")
.removeHeader("Pragma")
.build()
chain.proceed(networkRequest)
}
}
private fun isNetworkAvailable(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
在 OkHttpClient 中注册并启用 Cache:
// OkHttpClient 配置
val cacheDir = File(context.cacheDir, "http_cache")
val httpCache = Cache(cacheDir, 10L * 1024 * 1024) // 10MB 磁盘缓存
val okHttpClient = OkHttpClient.Builder()
.cache(httpCache) // 启用 OkHttp 缓存
.addInterceptor(OfflineCacheInterceptor(context)) // 离线缓存拦截器(第一个添加)
.addInterceptor(HttpLoggingInterceptor().apply { // 日志拦截器(调试用)
level = HttpLoggingInterceptor.Level.BODY
})
.addNetworkInterceptor(NetworkMonitorInterceptor()) // 网络监控拦截器
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
注册顺序陷阱:
addInterceptor() 的调用顺序决定了拦截器在链中的位置。离线缓存拦截器必须最先添加,这样它才能在其他拦截器之前判断网络状态。如果放在日志拦截器之后,日志拦截器可能会因为无网络而抛出异常。
4.5 调试与验证
离线缓存拦截器的调试核心是验证“有网络时缓存是否写入”和“无网络时是否回退缓存”。
- 日志输出:在拦截器中增加日志标记,区分“在线模式”和“离线模式”。
- 断点调试:在
intercept()方法的isNetworkAvailable分支处设置断点,观察Cache-Control头是否正确注入。 - 网络模拟:使用 Android 模拟器的“飞行模式”或 Charles 的“断网”功能模拟离线场景。
调试命令:在终端通过 adb 查看缓存目录内容,确认缓存文件是否生成:
adb shell ls -l /data/data/<package>/cache/http_cache/
4.6 常见错误与避坑
| 错误现象 | 根本原因 | 解决方案 |
|---|---|---|
离线时仍然抛出 IOException |
拦截器注册顺序错误,网络拦截器先执行导致异常 | 确保离线缓存拦截器为 第一个 应用拦截器 |
| 缓存一直不命中 | 服务器响应头缺少 Cache-Control 或设置了 no-store |
在拦截器中强制覆盖 Cache-Control,忽略服务器头 |
| 磁盘缓存目录未创建 | 未调用 cacheDir.mkdirs() 或权限不足 |
配置 Cache 时使用 context.cacheDir 并确保应用具有存储权限 |
| 缓存大小超限导致 LRU 频繁淘汰 | maxSize 设置太小(如 1MB) |
根据业务数据量设置为 10MB~50MB |
| 离线兜底响应未生效 | only-if-cached 请求在缓存缺失时直接抛异常 |
必须用 try-catch 包裹 chain.proceed(),在 catch 中返回兜底 Response |
4.7 性能与稳定性优化
- 缓存大小控制:根据 API 数据量合理设置
Cache的maxSize,避免耗尽磁盘空间。建议值:图片类 50MB,纯 JSON 数据 10MB。 - 缓存淘汰策略:OkHttp 内部使用 LRU(最近最少使用)算法自动淘汰缓存,无需手动干预。但应避免在短时间内生成大量不同 URL 的请求,防止缓存污染。
- 并发安全:OkHttp 的
Cache是线程安全的,多个请求同时读写缓存不会出现数据竞争。但自定义的isNetworkAvailable()应避免在 UI 线程频繁调用。 - 缓存一致性:对于时效性敏感的数据(如用户余额),可在有网络时设置较短的
max-age(如 15 秒),离线时再使用缓存兜底。 - 响应体复用:缓存的 Response Body 只能读取一次,如果多个拦截器需要读取 Body,必须使用
PeekSource或Buffer复制,避免流关闭。
生产建议:在 Release 包中关闭 HttpLoggingInterceptor,避免日志输出带来额外 I/O 开销。同时建议对离线缓存拦截器增加“缓存有效期”的动态配置,通过远程开关控制在线/离线缓存策略。