Retrofit2与OkHttp3异常触发式缓存回退实现及问题排查
全场景网络异常下的本地缓存兜底实现方案
核心需求对齐
- 每次接口拉取成功时强制更新本地缓存,适配每5秒轮询拉取的场景
- 任意一次拉取失败时,直接返回最近一次的有效缓存;拉取成功则返回线上最新数据
- 缓存有效期支持数天到数周,仅在后续拉取成功时覆盖更新原有缓存
- 无可用缓存且首次拉取失败时,才向用户展示错误提示
现有实现的核心问题定位
根因拆解
现有实现中,「Wi-Fi已连接但无互联网通路」场景下回退逻辑不执行的核心原因有三点:
- 拦截器顺序逻辑错位:配置将
onlineOfflineHandling作为应用层拦截器添加,同时把强制缓存逻辑写在网络拦截器中,当线上请求超时抛出异常时,异常抛出的位置会直接被OkHttp调用链捕获,不会进入catch分支的缓存回退逻辑——尤其是超时类异常(对应已联网但无数据通路的场景),异常会在网络拦截器阶段就抛出,根本不会触发应用层拦截器的catch块。 - 服务端响应头的缓存规则未被强制覆盖:绝大多数第三方API(比如YouTube API)返回的响应头会携带
Cache-Control: no-cache/no-store,如果不强制覆盖这类响应头的缓存规则,OkHttp根本不会把成功的响应写入本地缓存,自然在需要回退的时候读不到缓存数据。 - 回退请求的容错缺失:构造带
onlyIfCached的离线请求发起时,如果本地没有对应缓存,这个请求本身会抛出IOException,如果不做二次捕获,就会直接把异常抛给上层的RxJava订阅,导致用户收不到任何响应。
可直接复用的修正版实现
OkHttp客户端配置
拦截器顺序的优先级、缓存规则强制覆盖、异常二次捕获是实现全场景兜底的核心:
interface EndpointServices { companion object { // 强制覆盖服务端响应头,将所有成功响应写入本地缓存,支持最长30天的缓存有效期 private fun forceCacheInterceptor(): Interceptor { return Interceptor { chain -> val request = chain.request() val originalResponse = chain.proceed(request) // 仅对成功响应做缓存写入,4xx/5xx错误不写入缓存,避免污染兜底数据 if (originalResponse.isSuccessful) { val cacheControl = CacheControl.Builder() .maxAge(0, TimeUnit.SECONDS) // 强制每次发起请求时优先取线上最新数据 .maxStale(30, TimeUnit.DAYS) // 缓存最长保留30天,匹配数周有效周期的需求 .build() originalResponse.newBuilder() .removeHeader("Pragma") .removeHeader("Cache-Control") .header("Cache-Control", cacheControl.toString()) .build() } else { originalResponse } } } // 异常回退拦截器:必须放置在最外层应用拦截器位置,捕获所有请求阶段的异常 private fun fallbackToCacheInterceptor(): Interceptor { return Interceptor { chain -> val request = chain.request() try { // 正常请求流程:获取线上最新数据 chain.proceed(request) } catch (e: Exception) { // 覆盖所有异常场景:超时、连接失败、API配额超限、网络无通路等 Log.w("INTERCEPT", "请求失败,触发缓存回退: ${e.message}") val cacheRequest = request.newBuilder() .cacheControl( CacheControl.Builder() .maxStale(30, TimeUnit.DAYS) .onlyIfCached() .build() ) .build() try { // 读取本地缓存返回兜底数据 val cachedResponse = chain.proceed(cacheRequest) Log.w("INTERCEPT", "成功读取缓存数据") cachedResponse } catch (cacheException: Exception) { // 无可用缓存时,将原始异常抛给上层,走用户错误提示逻辑 Log.e("INTERCEPT", "无可用本地缓存") throw e } } } } fun create(baseUrl: String, context: Context): EndpointServices { // 150MB缓存容量,适配多资产的本地存储需求 val cacheSize = 150 * 1024 * 1024L val cache = Cache(context.cacheDir, cacheSize) val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } val httpClient = OkHttpClient.Builder() .cache(cache) // 拦截器顺序严格按照优先级配置: // 1. 最外层放置兜底拦截器,第一时间捕获所有请求异常 .addInterceptor(fallbackToCacheInterceptor()) .addInterceptor(loggingInterceptor) // 网络拦截器负责强制覆盖响应的缓存规则,确保成功响应能写入本地缓存 .addNetworkInterceptor(forceCacheInterceptor()) .callTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS) .build() val retrofit = Retrofit.Builder() .baseUrl(baseUrl) .client(httpClient) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(MoshiConverterFactory.create()) .build() return retrofit.create(EndpointServices::class.java) } } @GET("search") fun getVideoItems( @Query("key") key: String, @Query("part") part: String, @Query("maxResults") maxResults: String, @Query("order") order: String, @Query("type") type: String, @Query("channelId") channelId: String ): Single<VideoItemModel> }
上层调用逻辑优化
- 全局复用Retrofit实例:之前每次发起请求都重新调用
EndpointServices.create()的写法,会每次创建新的OkHttp和Cache实例,导致缓存完全不生效,需将服务实例做成全局单例,复用同一个Cache对象。 - 统一响应渲染逻辑:不管是线上拉取的新数据还是回退的缓存数据,统一走UI渲染流程,不需要区分数据来源:
// 全局单例初始化,避免重复创建客户端实例 private val endpointService = EndpointServices.create(Config.BASE_URL, applicationContext) // 5秒轮询逻辑 intervalDisposable = Observable.interval(0L, 5L, TimeUnit.SECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe { fetchVideoList() } private fun fetchVideoList() { disposable = endpointService.getVideoItems( AppUtils.videoKey, "id,snippet", "20", "date", "video", channelId ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { result -> adapter.submitList(result.videoData) swipeRefreshLayout.isRefreshing = false logTxt.text = null }, { error -> // 仅在无可用缓存、请求失败时展示错误提示 swipeRefreshLayout.isRefreshing = false logTxt.text = getString(R.string.swipeToRefresh) Log.e("FETCH_ERROR", error.message, error) } ) }
修正后全场景验证效果
- 移动网络/Wi-Fi完全关闭:请求异常触发,直接读取本地缓存返回兜底数据
- Wi-Fi连接但无互联网通路:请求超时触发异常,拦截器捕获后读取本地缓存返回,回退逻辑正常执行
- 网络通路正常:直接拉取线上最新数据,拉取成功后自动更新覆盖本地缓存
- 首次打开无缓存且请求失败:直接抛异常给上层展示错误提示
内容的提问来源于stack exchange,提问作者Mihae Kheel
相关产品推荐
相关产品推荐

