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 离线缓存拦截器设计思路

离线缓存的核心是利用应用拦截器在请求链最外层的特性,在网络不可用时直接返回已存储的缓存响应,避免请求穿透到网络层导致失败。

  1. 检测网络状态:在 intercept() 方法中通过 ConnectivityManager 判断当前是否联网。
  2. 有网络:正常放行请求,同时通过 Cache-Control 头控制缓存有效期(如 max-age=60)。
  3. 无网络:修改请求头为 only-if-cached,强制 OkHttp 仅从缓存返回;若缓存不存在则返回自定义兜底响应(如 503 + 错误标识)。
  4. 缓存写入:依赖 OkHttp 自带的 Cache 对象自动管理磁盘缓存,无需手动序列化。
设计原则:离线缓存拦截器应位于拦截器链的最前端(最先添加),确保在进入其他拦截器之前就完成网络判断和缓存回退。
拦截器链架构与离线缓存拦截器位置 离线缓存拦截器 应用拦截器 A 应用拦截器 B 应用层边界 网络拦截器 A 网络拦截器 B 网络层边界 服务器 请求 ↓ 响应 ↑ 离线缓存拦截器 • 作为第一个应用拦截器 • 无网络时直接回退缓存 • 有网络时注入缓存策略头 • 只执行一次,全局生效 网络拦截器 • 每次网络往返都触发 • 可观测重定向/重试 • 不适合做离线缓存 关键决策:离线缓存拦截器必须注册为应用拦截器,且放在拦截器链最前面

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 开销。同时建议对离线缓存拦截器增加“缓存有效期”的动态配置,通过远程开关控制在线/离线缓存策略。