ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

OKHttp3网络缓存实战:Interceptor拦截器与Retrofit集成方案

OKHttp3网络缓存实战:Interceptor拦截器与Retrofit集成方案 简介面向Android开发者的OKHttp3网络缓存实现参考文档重点讲解如何通过Interceptor拦截器为Retrofit网络请求增加数据缓存能力解决弱网或设备异常时拿不到数据、页面空白的问题。文档针对官方缓存仅支持GET的不足进行了扩展支持POST请求当网络正常时直接访问网络遇到超时、UnknownHostException等异常则自动从本地缓存读取缓存为空时继续走原有请求流程。内容还涵盖缓存设计思路、基于DiskLruCache封装的CacheManager单例、MD5缓存键、缓存读写与清理等核心代码逐段解析并强调调用者对缓存策略的自主控制与简单接入。整套资源为PDF电子文档仅1个文件压缩包约141KB便于按章节研读已有634人浏览学习适合需要优化网络体验、自研缓存拦截器的中高级Android开发人员参考。1. 先说结论OKHttp3 的网络数据缓存本质是在 Interceptor 里做拦截与回放后端接口不做 Cache-Control、列表页在同一网络环境里被反复拉取、弱网下按一次刷新就转圈。这个时候给 OKHttp3 加一层「网络数据缓存」比改后端更快常规落地方式就是写一个 Interceptor按请求 URL 把响应结果连同过期时间存到本地下一次直接回放不再触发真正的网络请求。Retrofit 自身没有缓存概念它的 Call 最终会走进 OkHttp 的执行链因此这一套对 Retrofit 完全透明。适合在 Android/Java 客户端做接口提速、离线展示和弱网降级的工程师。2. OKHttp3 的 Interceptor 机制缓存为什么挂在应用拦截器而不是网络拦截器2.1 应用拦截器与网络拦截器执行差异决定了缓存层级常见做法是把缓存 Interceptor 加在OkHttpClient.Builder().addInterceptor(...)也就是 Application Interceptor 列表里。这一层有两个特点第一一次业务请求无论底层经历过几次重定向、几次重试应用拦截器里的代码只执行一次第二如果不调用chain.proceed(request)整个请求就会终止不会发生真实网络往返。网络拦截器addNetworkInterceptor则不同它位于RetryAndFollowUpInterceptor之后同一个请求每次建立连接都会再执行一次重定向时会重复触发。缓存命中的最佳路径是在最接近客户端的层级直接短路所以我从未把自研缓存放在网络拦截器那一层。放网络层会让缓存逻辑跟随重定向被重复计算还容易把连接池状态一并读进缓存数据。2.2 chain.proceed 是把请求移交给下一级缓存拦截器不需要它在 OkHttp 的拦截器链里chain.request()拿到当前请求对象chain.proceed(request)把组装好的请求交给后端网络栈。对缓存 Interceptor 来说读不到缓存时才需要执行chain.proceed()读到缓存时直接构建一个Response返回。注意返回的 Response 必须显式指定request否则 OkHttp 在协议层校验响应来源时会抛IllegalArgumentException。这个细节经常被忽略尤其是从磁盘重建响应时很多人直接用new Response.Builder()只填 code 和 body结果一运行就崩。2.3 与 OkHttp 自带 Cache 的区别为什么后端不配合就得自己写有人会问OkHttp 不是有okhttp3.Cache和内置的CacheInterceptor吗是的但内置缓存严格按 HTTP 语义执行响应头没有Cache-Control或Expires它就不缓存Cache-Control: no-store更会跳过。真实项目里接口往往由后端返回200和application/json完全没有缓存响应头这时内置缓存实际上不起作用。自研 Interceptor 的优势在于用业务规则替代 HTTP 语义可以直接按「这个 URL 缓存 60 秒」来工作。下表是两者关键差异。维度OkHttp 内置 CacheInterceptor 自研缓存触发条件依赖 Cache-Control / ETag 响应头按调用方的请求头与默认 TTL后端改动需要配合不需要按接口区分策略需要后端给不同响应头通过 Headers 声明离线回放受限于服务器校验只要未过期即可返回缓存 KeyURL 校验字段URL 查询参数可扩展2.4 这一层对 Retrofit 的透明性Retrofit 的retrofit2.Call只是一个适配层Call.execute()与Call.enqueue()最终都会进入OkHttpClient的newCall(request)。这意味着只要 OkHttpClient 实例带了缓存拦截器Retrofit 创建的接口代理就自动获得缓存能力不需要在 Retrofit 侧做任何代码改造。需要明确的是Retrofit.Builder().client(client)是唯一正确的注入点只在 Retrofit 的 Converter 或 CallAdapter 里做处理是走不通的因为拦截器只能在 OkHttp 层看到请求和响应。3. 支持 Retrofit 的场景组装拦截器注入与接口级 TTL 声明3.1 用带缓存拦截器的 OkHttpClient 构建 Retrofit把拦截器加进 OkHttpClient然后把同一个 client 交给 Retrofit。代码如下。CacheInterceptor cacheInterceptor new CacheInterceptor( new File(context.getCacheDir(), http_cache), 60 * 1000L // 默认有效期 60 秒 ); OkHttpClient okHttpClient new OkHttpClient.Builder() .addInterceptor(cacheInterceptor) // 应用拦截器缓存逻辑在这里 .connectTimeout(10, TimeUnit.SECONDS) .build(); Retrofit retrofit new Retrofit.Builder() .baseUrl(https://api.example.com/) .client(okHttpClient) // 关键点注入同一个 client .addConverterFactory(GsonConverterFactory.create()) .build();这段代码的逻辑说明addInterceptor加入的是应用拦截器client(okHttpClient)让 Retrofit 在发起请求时实际使用的就是带此拦截器的 OkHttpClient。连接超时参数默认是 10 秒但缓存命中时超时根本不会被触发因为拦截器在进入网络层之前就返回了 Response。同一个OkHttpClient可以被多个Retrofit实例共享构建多个Retrofit时各自配不同的 Converter 不影响缓存行为。3.2 Retrofit 接口里用 Headers 声明单个请求的缓存策略被缓存请求自己决定多久过期比统一 TTL 灵活得多。常见做法是给接口方法加上自定义请求头例如public interface UserApi { Headers(Cache-TTL: 30) GET(user/info) CallUserInfo getUserInfo(); Headers({Cache-TTL: 300, Cache-Strategy: cache-first}) GET(banner/list) CallListBanner getBanners(); }这些头不会进入业务参数但会被 OkHttpClient 的拦截器看到。拦截器里可以用request.header(Cache-TTL)读取并把它转换为毫秒数作为本条数据的有效期没有这个头时用构造拦截器传入的默认 TTL。这样做的好处是把缓存策略收敛在接口定义里列表接口缓存 5 分钟个人资料只缓存 30 秒互不影响。Cache-Strategy这类自定义头可以作为扩展位后续加「仅读缓存」「网络失败后读过期缓存」等策略时不用改接口签名。3.3 同步与异步调用的一致性Call.execute()、Call.enqueue()都会调用intercept()只是外层最终执行线程不同对缓存而言没有区别。有一个要留神的地方enqueue的响应回调线程与 OkHttp 分发线程是同一批线程池如果缓存目录很大磁盘 IO 会占用分发线程进而拖慢其他请求。扩容时优先给拦截器内部增加锁而不是把缓存文件分散到多个目录后一种做法会产生并发读写同一 key 的风险。磁盘清理也要在这里做通常做法是拦截器每次写入时检查目录总大小超过阈值就按最后修改时间删除最旧的一批文件。4. 完整的网络数据缓存 Interceptor令牌、磁盘存储与响应体回放4.1 拦截器主流程先查缓存再放行网络下面是一个可直接运行的实现骨架包含查缓存、请求网络、写缓存三个步骤。public final class CacheInterceptor implements Interceptor { private final File cacheDir; private final long defaultTtlMillis; public CacheInterceptor(File cacheDir, long defaultTtlMillis) { this.cacheDir cacheDir; this.defaultTtlMillis defaultTtlMillis; } Override public Response intercept(Chain chain) throws IOException { Request request chain.request(); if (!request.method().equalsIgnoreCase(GET)) { return chain.proceed(request); // POST、PUT 等不处理 } String key buildKey(request); CachedEntry entry readEntry(key); if (entry ! null !entry.expired()) { return entry.toResponse(); // 命中直接返回 } Response response chain.proceed(request); if (response.isSuccessful()) { long ttl resolveTtl(request); writeEntry(key, request, response, ttl); } return response; } }逻辑说明只有 GET 请求参与缓存因为 POST 带请求体URL 无法唯一标识一次请求readEntry返回 null 或已过期都走网络网络响应码 2xx 时把新的响应体写入磁盘并记录过期时间。参数defaultTtlMillis与请求头的优先级决定每条数据各自的存活期resolveTtl内部先读Cache-TTL读不到再回退默认值。4.2 缓存 key 的生成规则一个典型实现是「URL 去掉可变 token 查询参数拼接」再对字符串做摘要。摘要用 SHA-256 而不是 MD5避免文件重名和刻意碰撞带来的安全隐患。private static final String KEY_CHARSET UTF-8; private static String buildKey(Request request) { String url request.url().toString(); try { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(url.getBytes(KEY_CHARSET)); return bytesToHex(hash); } catch (NoSuchAlgorithmException e) { return String.valueOf(url.hashCode()); // 兜底不推荐多用 } } private static String bytesToHex(byte[] bytes) { StringBuilder sb new StringBuilder(bytes.length * 2); for (byte b : bytes) { if ((b 0xff) 0x10) { sb.append(0); } sb.append(Integer.toHexString(b 0xff)); } return sb.toString(); }这里把完整 URL 作为摘要输入包含 query、path 和 host不同域名的相同路径不会互相污染。需要注意 URL 中如果有签名或 token 参数每次都会变导致缓存永远不命中这种情况要先把签名参数剔除后再拼 key。我一般在拦截器里加一个白名单字段把timestamp、sign、nonce这类参数从 key 计算中排除避免解析 URL 时误删业务参数。4.3 磁盘存储meta 与 body 分离每个 key 对应两个文件meta文件保存过期时间、响应头body文件保存响应体字节。这样读取时不用把整个响应体读进内存就能判断是否过期miss 时直接删除这两个文件。private static final String META_SUFFIX .meta; private static final String BODY_SUFFIX .body; private void writeEntry(String key, Request request, Response response, long ttl) throws IOException { File metaFile new File(cacheDir, key META_SUFFIX); File bodyFile new File(cacheDir, key BODY_SUFFIX); ResponseBody body response.body(); if (body null) return; byte[] bodyBytes body.bytes(); // 读取一次后续回放用字节数组 long expireAt System.currentTimeMillis() ttl; try (BufferedWriter writer new BufferedWriter(new FileWriter(metaFile))) { writer.write(expireAt expireAt); writer.newLine(); for (int i 0; i response.headers().size(); i) { writer.write(response.headers().name(i) : response.headers().value(i)); writer.newLine(); } } try (FileOutputStream out new FileOutputStream(bodyFile)) { out.write(bodyBytes); } }这段代码说明先调用body.bytes()把响应体完整读取出来否则后面chain.proceed()返回的 Response 一旦作用域结束body 将不可再读meta 文件用keyvalue格式写第一行紧接着写响应头body 文件保持纯粹的字节流读取时按行解析到空行即可切分。由于meta中的响应头来自网络 Response回放时不需要再经过 Gson 或 ConverterRetrofit 会直接读到 body 字节。4.4 回放响应的关键ResponseBody 需要支持重复读OkHttp 的RealResponseBody内部有一次性 source网络响应体被读过之后就不能再读。返回给上层时必须换成自己实现的 ResponseBody保证每次调用source()都返回完整数据。private static final class CachedResponseBody extends ResponseBody { private final MediaType contentType; private final byte[] data; CachedResponseBody(MediaType contentType, byte[] data) { this.contentType contentType; this.data data; } Override public MediaType contentType() { return contentType; } Override public long contentLength() { return data.length; } Override public BufferedSource source() { return new Buffer().write(data); // 每次都是新 Buffer } }contentType()取原响应的 MediaTypecontentLength()直接返回数组长度。特别注意source()里不能复用同一个 Buffer 成员变量因为 Converter 读过一次后 Buffer 的读写指针已经移动再次调用就返回空数据。这就是网上很多人遇到「缓存返回 200 但解析出来是空对象」的根源。4.5 参数速查决定缓存行为的 5 个开关参数类型作用defaultTtlMillislong未声明 Cache-TTL 时的默认有效期Cache-TTL 请求头String秒接口级 TTL 覆盖默认值仅 GET 请求boolean是否只缓存 GET默认只处理 GETcache key 去签名布尔逻辑避免 URL 带 timestamp/sign 导致永远 misscacheDir 路径File推荐使用context.getCacheDir()的子目录其中Cache-TTL头最实用因为它能让你在不新增接口的前提下给不同 Method 指定不同过期策略。若某接口数据禁止被缓存直接约定该接口在拦截器里被整体排除不把它写进白名单。5. 验证方式和 4 个常见坑命中、失效与 Retrofit 解析链5.1 用自带响应头验证缓存是否命中调试时给回放的 Response 打一个标志头日志拦截器里就能直接看到是 HIT 还是 MISS。Response toResponse() { return new Response.Builder() .code(200) .message(OK) .request(originRequest) .protocol(Protocol.HTTP_1_1) .addHeader(X-Cache, HIT) .body(new CachedResponseBody(contentType, data)) .build(); }再配合HttpLoggingInterceptor只要看到响应头里有X-Cache: HIT就能确认拦截器生效网络返回的响应不会有这个头标记为 MISS。这里request(originRequest)不能省略OkHttp 会用响应里的 request 校验请求与响应的匹配关系。5.2 过期后网络失败的降级处理读缓存时发现过期但又不能确保网络可用常见做法是先尝试网络网络抛IOException时回退到过期缓存并加上X-Cache: STALE头。这需要把chain.proceed包在 try/catch 里并把过期 entry 透传出来。我一般会在过期缓存上保留原始过期时间根据它计算 stale 时长方便统计弱网下的服务可用性。5.3 四个容易出现的问题第一缓存目录被系统清理。getCacheDir()在存储紧张时可能被系统删除初始化时要判断目录是否可用不可用就静默降级为不缓存。第二并发写同一个 key。OkHttp 分发线程会并发执行多个请求建议对 key 做分段锁避免两个请求同时写 meta 和 body 导致文件内容交错。第三没有剔除Set-Cookie头。回放的响应头如果原样写入会话相关头会污染后续请求写 meta 时要把该头过滤掉。第四Content-Encoding: gzip的响应不要原样缓存 bodybody.bytes()拿到的是未解压的字节如果原样回放Retrofit 的 converter 拿不到可解读的文本。处理方式是拦截器拿到 response 后先GzipSource解压再执行缓存写入回放时把Content-Encoding头去掉保证上层始终消费明文数据。一个更稳妥的实验方法是在写入缓存前打印bodyBytes.length回放时再打印CachedResponseBody构造收到的data.length两者不一致就说明问题出在缓存写入路径而不是 Retrofit 层。本文还有配套的精品资源点击获取
返回列表