news 2026/10/1 4:34:50

Android OkHttp3拦截器实现POST请求离线缓存与断网回退

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android OkHttp3拦截器实现POST请求离线缓存与断网回退

简介:本资源聚焦 Android 网络请求中的缓存难题,面向使用 OKHttp3 与 Retrofit 的开发者,尤其是希望解决弱网或断网环境下数据空白问题的中高级工程师。内容围绕自定义 Interceptor 拦截器展开,讲解如何借助 DiskLruCache 封装 CacheManager,实现支持 POST 请求、网络异常时回退缓存、缓存为空时继续正常流程,并让调用者完全掌控缓存策略。资源包为 1 个 pdf 文件,约 141KB,篇幅精炼,适合快速通读并对照代码实践。目前已有 634 人学习下载。读者可从中获得完整的缓存拦截器设计思路、CacheManager 关键代码片段与 DiskLruCache 集成方法,理解 GET 与 POST 缓存差异、超时与 UnknownHost 等异常处理逻辑,从而在 Retrofit 项目中低成本接入可配置的本地缓存能力,提升 App 在复杂网络环境下的用户体验。

1. 断网就白屏这件事,值得用拦截器彻底解决一次

做 Android 的应该都遇到过这种反馈:用户在地铁、电梯、地下车库打开 App,页面转两圈,然后一片空白。你查日志,UnknownHostException、SocketTimeoutException轮流出现,代码逻辑没错,就是网络环境拿不到数据。更尴尬的是,明明上一次打开时数据已经加载成功过,用户却只能对着空白框发呆。

这个项目要解决的就是这件事。它基于 OKHttp3 的 Interceptor 机制,配合 DiskLruCache 做本地磁盘缓存,在网络异常时自动回退到缓存数据,让页面至少有内容可展示。同时它补上了官方CacheInterceptor的一个短板——官方方案只对 GET 请求生效,POST 请求拿不到缓存。这个实现把 POST 的请求参数也纳入缓存 Key 的计算,让 POST 接口同样具备离线回退能力。如果你正在用 Retrofit + OKHttp3 做网络层,又不想大改现有架构,这个拦截器可以直接挂上去用。

2. 缓存拦截器的设计取舍:为什么不用官方方案

2.1 官方 CacheInterceptor 的边界在哪

OKHttp3 自带了一个CacheInterceptor,配合Cache对象使用,原理是遵循 HTTP 缓存语义——服务端返回Cache-Control、Last-Modified、ETag这些响应头,客户端据此判断是否使用缓存。这套机制在标准 RESTful 场景下工作得很好,但它有两个硬伤。

第一个硬伤是 POST 不支持。HTTP 规范里 POST 请求默认是不可缓存的,OKHttp 的Cache实现严格遵守这一点。你就算在请求头里写了Cache-Control: public, max-age=60,POST 请求也不会走缓存。但实际业务中,很多接口用 POST 只是因为参数多或者后端图省事,语义上跟 GET 没区别,这时候官方方案就无能为力了。

第二个硬伤是缓存策略由服务端响应头决定,客户端只能被动接受。如果后端没配Cache-Control,或者配了个no-store,你就完全没法用缓存。而现实是,很多后端接口根本不关心 HTTP 缓存头,你没法要求他们为了客户端缓存去改响应头。

这个项目的思路是绕开 HTTP 缓存语义,自己控制缓存逻辑。调用方通过请求头cache: true显式声明“这个接口我要缓存”,拦截器收到后就在网络成功时写入 DiskLruCache,网络异常时从 DiskLruCache 读取。缓存 Key 用 URL + POST 参数拼接后做 MD5,保证不同参数的请求不会互相覆盖。

2.2 DiskLruCache 为什么比 SharedPreferences 和 SQLite 合适

缓存框架选的是 JakeWharton 的 DiskLruCache。这个选择在当时(项目代码写于 2017 年)是很务实的,放到现在也依然成立。

SharedPreferences 适合存少量键值对,但它的设计目标是配置项,不是大量字符串数据。你把接口返回的 JSON 塞进去,文件会膨胀得很快,而且每次读取都是全量加载到内存,性能会崩。SQLite 倒是能存大量数据,但为了缓存一个 JSON 字符串去建表、写 DAO、管理版本迁移,杀鸡用牛刀,而且 SQLite 的并发写入需要额外处理。

DiskLruCache 的定位刚好卡在中间:它把数据按条目存成文件,用 LRU 算法管理磁盘空间,有 journal 日志保证一致性,支持多线程读写。它的 API 很简单——edit(key)拿 Editor 写入,get(key)拿 Snapshot 读取,remove(key)删除。对于“按 Key 存字符串、按 Key 取字符串”这个需求,它是最轻量的方案。

CacheManager里把 DiskLruCache 的容量设为 10MB,valueCount设为 1(一个 Key 对应一个文件),缓存目录放在context.getCacheDir()/responses下。用getAppVersion()作为 DiskLruCache 的版本号,App 升级后缓存自动失效,避免旧版本数据结构不兼容的问题。

2.3 缓存 Key 的构造逻辑

缓存 Key 的构造直接决定了缓存会不会串数据。这个实现用的是:

// CacheInterceptor.intercept() 中 String url = request.url().url().toString(); String reqBodyStr = getPostParams(request); // 缓存 Key = MD5(url + POST参数字符串) CacheManager.getInstance(context).setCache( CacheManager.encryptMD5(url + reqBodyStr), responStr );

getPostParams()会遍历FormBody里的每个键值对,拼成key1=value1,key2=value2的形式。这样同一个 URL 不同参数的请求,MD5 结果不同,缓存不会互相覆盖。

这里有个细节值得注意:FormBody的encodedName(i)和encodedValue(i)返回的是已经 URL 编码过的字符串。这意味着参数顺序不同但内容相同的请求,比如a=1&b=2和b=2&a=1,会生成不同的缓存 Key。如果你的接口参数顺序不固定,需要自己先排序再拼接。常见做法是在getPostParams()里把键值对收集到TreeMap再遍历,保证顺序一致。

另外,如果请求体不是FormBody(比如是MultipartBody或自定义的RequestBody),getPostParams()返回空字符串,缓存 Key 就只由 URL 决定。这种情况下,同一个 URL 的不同上传内容会命中同一个缓存,需要调用方自己评估是否可接受。

3. 把拦截器挂进 OKHttpClient:从依赖到跑通

3.1 依赖引入与项目配置

项目通过 JCenter 发布,Gradle 依赖一行搞定:

// app/build.gradle dependencies { compile 'com.xiaolei:OkhttpCacheInterceptor:1.0.0' }

如果拉不下来(JCenter 审核期间确实可能遇到),在 Project 级build.gradle里加 Maven 仓库:

allprojects { repositories { maven { url 'https://dl.bintray.com/kavipyouxiang/maven' } } }

权限方面,网络请求和磁盘缓存各需要一组:

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

注意:Android 6.0 以上,READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE属于危险权限,需要在运行时动态申请。不过CacheManager实际用的是context.getCacheDir(),这是应用内部目录,不需要存储权限。如果你把缓存目录改到外部存储,才需要动态申请。

3.2 构建 OkHttpClient 并挂载拦截器

拦截器的挂载位置很关键。addInterceptor()添加的是应用拦截器,它在所有内置拦截器之前执行,能看到原始请求和最终响应。addNetworkInterceptor()添加的是网络拦截器,它只能看到实际走网络的请求,重定向和重试对它透明。

缓存拦截器应该用addInterceptor(),因为我们需要在网络异常时直接返回缓存响应,不走后续的网络流程。如果用addNetworkInterceptor(),网络异常时拦截器链已经断了,你拿不到机会去读缓存。

// 构建 OkHttpClient OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(new CacheInterceptor(context)) // 缓存拦截器,放在最前面 .retryOnConnectionFailure(true) // 连接失败自动重试 .connectTimeout(30, TimeUnit.SECONDS) // 连接超时 30 秒 .build();

retryOnConnectionFailure(true)让 OKHttp 在连接失败时尝试其他 IP 或路由,对多网卡设备有帮助。connectTimeout设 30 秒是偏保守的值,实际项目中可以按接口类型分档——列表页接口 10 秒,上传接口 60 秒。

3.3 用注解控制哪些接口走缓存

拦截器通过请求头cache: true来判断是否启用缓存。项目里定义了一个CacheHeaders接口来集中管理:

// CacheHeaders.java public interface CacheHeaders { String NORMAL = "cache: true"; // 可以扩展更多策略,比如 "cache: true, Cache-Control: max-age=60" }

在 Retrofit 接口上用@Headers注解声明:

// Net.java public interface Net { @Headers(CacheHeaders.NORMAL) // 这个接口启用缓存 @FormUrlEncoded @POST("geocoding") Call<DataBean> getIndex(@Field("a") String a); }

@Headers的值会被 OKHttp 解析成请求头。CacheInterceptor里通过request.header("cache")读取,值为"true"时启用缓存逻辑。同时它还兼容Cache-Control头——如果请求里带了非空的Cache-Control,也会触发缓存流程。这样设计是为了兼容 Web 端协议的缓存头,方便前后端统一。

3.4 拦截器内部的完整处理链路

intercept()方法的逻辑可以拆成四条分支:

分支一:不需要缓存。请求头里没有cache: true也没有Cache-Control,直接chain.proceed(request)走正常流程。

分支二:需要缓存且网络成功。执行chain.proceed(request)拿到响应,检查response.isSuccessful()。成功的话,把响应体读成字符串,用CacheManager.setCache()异步写入磁盘,然后通过getOnlineResponse()重建一个 Response 返回给调用方。

这里有个容易忽略的点:responseBody.string()只能调用一次,调用后流就关闭了。所以必须用读出来的字符串重建 Response,不能直接把原 Response 返回。getOnlineResponse()做的就是这件事——保留原响应的 code、message、protocol、request,只替换 body。

分支三:需要缓存但网络异常。chain.proceed(request)抛出异常(IOException及其子类),进入 catch 块。调用getCacheResponse()从 DiskLruCache 读取缓存。如果读到数据,构造一个 code=200、protocol=HTTP_1_0 的 Response 返回;如果缓存为空,返回 null,然后chain.proceed(request)再走一次正常流程,让异常继续向上抛。

分支四:网络返回不成功(4xx/5xx)。response.isSuccessful()为 false,直接chain.proceed(request)重新执行。这里其实有个小问题——chain.proceed()被调用了两次,第二次会重新发起网络请求。更合理的做法是直接返回原响应,让调用方处理错误码。不过这个分支在实际使用中触发频率不高,因为大部分异常在chain.proceed()阶段就以 Exception 形式抛出了。

4. 避坑与排查:缓存拦截器上线后容易翻车的几个点

4.1 缓存写入成功但读取为空

现象:日志里看到Push Cache: xxx :Success,但断网后读缓存返回 null。

原因:CacheManager.putCache()里调用了editor.commit()和mDiskLruCache.flush(),但 DiskLruCache 的flush()只是把 journal 刷到磁盘,数据文件的写入依赖commit()时的流关闭。如果os.close()在commit()之前被异常跳过,数据可能不完整。

解决:确保commit()之前os.flush()和os.close()都执行到位。代码里的finally块已经做了os.close(),但commit()在 try 块内、close()在 finally 块内,顺序上commit()先执行。DiskLruCache 的commit()内部会关闭输出流,所以这个顺序没问题。如果还是读不到,检查encryptMD5()的输入是否一致——写入和读取时传入的 Key 必须完全相同。

4.2 POST 参数顺序不同导致缓存不命中

现象:同一个接口,参数内容一样但顺序不同,断网后一个能读到缓存,一个读不到。

原因:getPostParams()按FormBody里的原始顺序拼接参数,a=1,b=2和b=2,a=1生成的 MD5 不同。

解决:在getPostParams()里用TreeMap对参数排序后再拼接:

private String getPostParams(Request request) { String reqBodyStr = ""; if ("POST".equals(request.method()) && request.body() instanceof FormBody) { FormBody body = (FormBody) request.body(); TreeMap<String, String> params = new TreeMap<>(); for (int i = 0; i < body.size(); i++) { params.put(body.encodedName(i), body.encodedValue(i)); } StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : params.entrySet()) { sb.append(entry.getKey()).append("=").append(entry.getValue()).append(","); } if (sb.length() > 0) sb.deleteCharAt(sb.length() - 1); reqBodyStr = sb.toString(); } return reqBodyStr; }

4.3 缓存目录被系统清理导致数据丢失

现象:用户反馈昨天断网还能看的内容,今天断网就空白了。

原因:CacheManager用的是context.getCacheDir(),这个目录在 Android 系统存储空间不足时会被系统自动清理。用户手机存储紧张时,缓存文件可能被删掉。

解决:如果缓存数据的重要性高于存储空间,把目录改到context.getFilesDir()下。getFilesDir()不会被系统自动清理,但需要自己管理容量上限。改法很简单,把getDiskCacheDir()里的context.getCacheDir()换成context.getFilesDir()即可。代价是用户卸载 App 前这些文件一直占着空间,10MB 的上限需要根据业务调整。

4.4 拦截器里重建 Response 丢失了响应头

现象:调用方从缓存拿到数据后,发现response.headers()是空的,拿不到Content-Type等信息。

原因:getCacheResponse()构造 Response 时只设置了 code、body、request、message、protocol,没有复制原响应的 headers。缓存里只存了 body 字符串,没存 headers。

解决:如果业务依赖响应头,需要在写缓存时把关键 headers 也序列化存进去,读缓存时反序列化重建。简单做法是在CacheManager里存一个 JSON,包含 body 和 headers 两个字段。不过大多数场景下,调用方只关心 body 内容,headers 缺失影响不大。Retrofit 的 GsonConverterFactory 只读 body,不读 headers,所以这个问题在 Retrofit 场景下通常不会暴露。

4.5 并发请求同一接口时的缓存竞争

现象:多个线程同时请求同一个接口,网络都失败,都去读缓存,日志里出现多次Try to Get Cache。

原因:CacheManager.getCache()是同步方法,但多个线程同时调用时,DiskLruCache 的get()是线程安全的,不会崩,只是会重复读。

解决:这不是 bug,是正常行为。DiskLruCache 内部有锁,并发读不会出问题。如果在意性能,可以在CacheInterceptor里加一个ConcurrentHashMap做内存缓存,读磁盘前先查内存。但要注意内存缓存的失效策略,否则会读到过期数据。

5. 进阶:把缓存策略做成可配置的,而不是硬编码

上面跑通的是最基础的用法——请求头写cache: true,拦截器就按“网络优先、异常回退缓存”的逻辑处理。但实际业务里,不同接口对缓存的需求不一样。有的接口希望“缓存优先,网络只做更新”,有的希望“缓存有效期 5 分钟,过期就重新请求”,还有的希望“只在 Wi-Fi 下走网络,移动网络直接读缓存”。把这些策略都硬编码在拦截器里,改一次需求就要动一次拦截器代码,不现实。

我后来习惯的做法是,把缓存策略从请求头里解析出来,做成一个轻量的配置对象。请求头不再只传cache: true,而是传一个类似cache: strategy=network_first, max_age=300的字符串。拦截器解析这个字符串,决定走哪条分支。

// 解析请求头中的缓存策略 private CacheStrategy parseStrategy(Request request) { String cacheHeader = request.header("cache"); if (cacheHeader == null || cacheHeader.isEmpty()) { return CacheStrategy.NONE; // 不缓存 } CacheStrategy strategy = new CacheStrategy(); // 解析 strategy=xxx if (cacheHeader.contains("strategy=cache_first")) { strategy.mode = CacheStrategy.MODE_CACHE_FIRST; } else if (cacheHeader.contains("strategy=network_first")) { strategy.mode = CacheStrategy.MODE_NETWORK_FIRST; } // 解析 max_age=xxx Pattern pattern = Pattern.compile("max_age=(\\d+)"); Matcher matcher = pattern.matcher(cacheHeader); if (matcher.find()) { strategy.maxAge = Integer.parseInt(matcher.group(1)); } return strategy; }

MODE_CACHE_FIRST的逻辑是:先读缓存,有缓存且未过期就直接返回;没有缓存或已过期,再走网络。MODE_NETWORK_FIRST就是当前实现的逻辑:先走网络,异常时回退缓存。maxAge用来判断缓存是否过期,需要在写缓存时把时间戳也存进去。

存时间戳的方式有两种。一种是在缓存 Value 里加一个前缀,比如timestamp|body,读的时候按|分割。另一种是单独用一个 Key 存时间戳,比如MD5(url+params)+"_ts"。第一种方式更紧凑,但要注意 body 里如果包含|会解析错误,所以用第一种方式时最好用不会出现在 JSON 里的分隔符,比如\u0000。

验证缓存是否按预期工作,最直接的方法是看日志。拦截器里已经打了关键节点的日志:Push Cache表示写入成功,Try to Get Cache表示尝试读缓存,Get Cache Failure表示缓存为空,Get Cache: 200 OK表示缓存命中。跑 Demo 的时候,先把网络断开,观察日志里是否出现Try to Get Cache和Get Cache: 200 OK;再恢复网络,观察是否出现Push Cache。如果断网后日志里连Try to Get Cache都没有,说明请求头里的cache: true没生效,检查@Headers注解的值和拦截器里request.header("cache")的读取逻辑是否匹配。

还有一个容易忽略的细节:DiskLruCache 的open()方法在目录不存在时会抛IOException。CacheManager的构造函数里先判断diskCacheDir.exists(),不存在就mkdirs(),然后再open()。但如果mkdirs()失败(比如权限问题),open()会直接抛异常,mDiskLruCache保持为 null,后续所有缓存操作都会静默失败。所以putCache()和getCache()开头都有if (mDiskLruCache == null) return;的保护。排查缓存不生效的问题时,先确认mDiskLruCache是否成功创建,日志里搜mDiskLruCache created有没有出现。

从那以后我每次接入新的缓存方案,都强制走一遍“断网→恢复→再断网”的完整链路,确认写入、读取、失效三个环节都符合预期,再放到真实项目里用。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 4:34:41

Python跨平台WiFi扫描器:从系统命令到信号强度解析

说实话&#xff0c;“用Python扫描周围WiFi”这个需求&#xff0c;我最早是在办公室被逼出来的。当时工位离路由器远&#xff0c;WiFi信号常年两格&#xff0c;我想知道附近到底有哪些网络、哪个信号更好、哪个频段更空&#xff0c;好决定是自己加路由还是蹭楼下那个信号还行的…

作者头像 李华
网站建设 2026/10/1 4:34:41

用提示词让AI写赛车游戏:大模型生成HTML游戏的完整实战指南

周末刷到一条消息&#xff1a;有人用一句提示词&#xff0c;让顶级 AI 模型写出了一款能玩的 QQ飞车风格赛车游戏。我最初是不信的&#xff0c;游戏这东西&#xff0c;建模、物理、手感&#xff0c;哪个不是硬功夫&#xff1f;但转念一想&#xff0c;现在大模型写代码的能力已经…

作者头像 李华
网站建设 2026/10/1 4:34:41

基于Python的汽车车辆故障管理系统:从需求拆解到OBD诊断与工单落地

三年前我接了一个4S店集团的管理系统项目&#xff0c;对方上来第一句话就是&#xff1a;“师傅&#xff0c;我们售后车间现在全靠Excel和墙上白板&#xff0c;每天维修工单、故障码、配件调用全乱成一锅粥&#xff0c;能不能整一套能跑起来的车辆故障管理系统&#xff1f;”那时…

作者头像 李华
网站建设 2026/10/1 4:33:56

大模型驱动Blender:从自然语言到可运行脚本的实操指南

1. 从一条热搜说起&#xff1a;Grok 4.7 到底被网友拿去干了什么Grok 4.7 上线那天&#xff0c;我正蹲在几个技术群里看热闹。按理说&#xff0c;新模型发布&#xff0c;大家第一反应应该是跑分、对比、测推理能力&#xff0c;结果群里刷屏的却是另一幅画面&#xff1a;有人让它…

作者头像 李华
网站建设 2026/10/1 4:33:43

HAProxy超时配置与负载均衡算法实战:避开生产环境那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 4:33:40

GitLab从入门到实战:Docker部署、SSH连接与CI/CD流水线指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华