结合Retrofit 改造OKHttp 缓存

537 阅读4分钟

最近突然有一个想法,想稍微改下Okhttp 自带的网络缓存让他可以支持POST请求,再稍微好用一点。本篇是基于Okhttp 缓存方式改造思路的介绍 以及结合 Retrofit来使用的方法。

网络缓存

说到网络缓存,肯定都不陌生,多多少少使用过不同的缓存方案。使用网络缓存有什么作用:

  • 减少服务器请求次数
  • 减少用户等待时间
  • 增加应用流畅度
  • 节省用户流量(虽然现在流量也不怎么值钱了)

.........

OkHttp缓存

OkHttp 是自带缓存的,基于请求头的缓存,使用起来有局限性,说实话之前只是了解过,项目中并没有真正去用到过 OkHttp 自带的缓存。原因如下:

OkHttp 只缓存Get请求,其他请求会直接忽略,使用起来并不是太方便,而且内部定义的缓存的策略不是太符合业务的需求。

RxCache

RxCache 专门为Retrifit写出来的缓存库。RxCache看名字就知道了,Rx开头肯定是基于RxJava的库。使用方式跟Retrofit 的使用方式类似,都要创建一个Service,在Service里用注解声明不同的缓存方式,所以用起来还是挺灵活的,不过作者好像不维护的样子,上个版本是 2016 年出的了。上手难度稍微一点点高,里边那几个注解的意思不自己测试下还真不太好完全明白是什么意思。这个库不是今天介绍的重点,有兴趣的还是看下官方文档。

思路

相比于 RxCache 的缓存方式 和 OkHttp 自带的基于拦截器的缓存方式。我个人还是更看好后者,基于拦截器来做缓存。所以才有了这个想法,想改一下能更适合项目中的具体业务需求。

总体思路是:用 Retrofit@Header@Header注解去添加 缓存策略、缓存时间、以及 缓存 Key值。拿到这些缓存信息后,然后在自定义拦截器里边取到缓存模式等信息,再把这几个请求头移除掉, 再去进行缓存的判断以及读写操作。不影响正常的网络请求。

缓存模式

首先要考虑的就是缓存策略,结合业务的需求定义出来以下几种:

  • ONLY_NETWORK :只请求网络,不加缓存
  • ONLY_CACHE :只读取缓存(没有缓存抛出异常)
  • NETWORK_PUT_CACHE : 先请求网络,再写入缓存
  • READ_CACHE_NETWORK_PUT :先读取缓存,如果缓存失效再请求网络更新缓存
  • NETWORK_PUT_READ_CACHE :先请求网络,网络请求失败使用缓存 (未过期缓存)

可能第二种ONLY_CACHE 好多朋友会有疑问,只读取缓存的场景想不出来哪里会用到,这个场景用到的确实不多,有朋友经常看谷歌Demo的话,里边倒是有好多这种场景,谷歌Demo里在页面打开的时候去拿缓存先展示出来,然后再请求网络把数据更新上去。

缓存Key

缓存的Key值需要是唯一的,直接用 URL 来做缓存的Key值,GET 请求是没问题的,但是POST请求参数是放在Body 里边的,POST 请求就需要读取出来请求参数,再添加到URL里生成新的 URl 来当做 缓存Key。

private fun buildCacheKey(request: Request): String {
    val requestBody = request.body ?: return request.url.toString()
    val buffer = Buffer()
    requestBody.writeTo(buffer)

    val contentType = requestBody.contentType()
    val charset = contentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8

    if (isProbablyUtf8(buffer)) {
        val questParam = buffer.readString(charset)
        buffer.close()
        if (questParam.isBlank()) return request.url.toString()
        val builder = request.url.newBuilder()
        kotlin.runCatching {
            builder.addQueryParameter("${request.method.lowercase()}param", questParam)
            return builder.build().toString()
        }.onFailure {
            return ""
        }
    }
    return request.url.toString()
}

拦截器

我们在拦截器里做缓存,每次请求可能会是不同的策略,所以首先要拿到的就是缓存模式,拿到缓存模式之后再根据不同的模式去读取或者写入操作,核心代码也就下边这几行:

override fun intercept(chain: Interceptor.Chain): Response {
    val initialRequest = chain.request()
    val strategy = CacheUtil.getCacheStrategy(initialRequest)
    val newRequest = initialRequest.rmCacheHeader()

    if (strategy == null) return chain.proceed(newRequest)// 策略为空,直接返回网络结果

    // ONLY_NETWORK 直接请求网络
    if (strategy.cacheMode == CacheMode.ONLY_NETWORK) return chain.proceed(newRequest)

    // ONLY_CACHE 只读取缓存
    if (strategy.cacheMode == CacheMode.ONLY_CACHE) {
        // 只读缓存模式,缓存为空,返回错误响应
        return (if (CacheManager.useExpiredData) mCache.getCache(strategy.cacheKey, newRequest)
        else redCache(strategy, newRequest)) ?: Response.Builder()
            .request(chain.request())
            .protocol(Protocol.HTTP_1_1)
            .code(HttpURLConnection.HTTP_GATEWAY_TIMEOUT)
            .message("no cached data")
            .body(EMPTY_RESPONSE)
            .sentRequestAtMillis(-1L)
            .receivedResponseAtMillis(System.currentTimeMillis())
            .build()
    }

    //先取缓存再取网络
    if (strategy.cacheMode == CacheMode.READ_CACHE_NETWORK_PUT) {
        val cacheResponse = redCache(strategy, newRequest)
        if (cacheResponse != null) return cacheResponse
    }

    try {
        // 开始请求网络
        val response = chain.proceed(newRequest)
        // 成功后写入缓存
        if (response.isSuccessful) {
            return cacheWritingResponse(mCache.putCache(strategy.cacheKey, response), response)
        }
        if (strategy.cacheMode == CacheMode.NETWORK_PUT_READ_CACHE) {
            return redCache(strategy, newRequest) ?: response
        }
        return response
    } catch (e: Throwable) {
        //请求失败尝试读取缓存,缓存没有或者失效,抛异常
        if (strategy.cacheMode == CacheMode.NETWORK_PUT_READ_CACHE) {
            return redCache(strategy, newRequest) ?: throw e
        }
        throw e
    }
}

设置缓存

这里不得不佩服 Retrofit 在解耦方面做的是真的强啊。我何时能有那样的思路跟想法呢。眼里只有崇拜~~~

言归正传 Retrofit 的请求头是在 Service里边添加的,所以添加缓存策略,直接写在Service里。Retrofit 两种添加请求头的方式 @Headers 是方法注解,@Header 是参数注解。再结合Kotlin 语法可以指定默认参数,如有不同缓存模式就可以在请求的时候,去动态使用不同缓存模式。


/**
* 使用 Header 参数注解
*/
@FormUrlEncoded
@POST("user/login")
suspend fun login(
    @Field("username") username: String,
    @Field("password") password: String,
    @Header(CacheStrategy.CACHE_MODE) cacheMode: String = CacheMode.READ_CACHE_NETWORK_PUT,
    @Header(CacheStrategy.CACHE_TIME) cacheTime: String = "10"// 过期时间,10秒 不过期
): BaseResponse<Any>

/**
* 使用 Headers 方法注解
*/
@Headers(
    "${CacheStrategy.CACHE_TIME}:-1", // 过期时间,-1 不过期
    "${CacheStrategy.CACHE_MODE}:${CacheMode.READ_CACHE_NETWORK_PUT}"
)
@GET("article/list/{page}/json")
suspend fun getPage(@Path("page") page: Any): BaseResponse<Page<ArticleBean>>

缓存的读写

读写操作还是用的OkHttpDiskLruCache类。Okhttp 4.0.0 版本以后 就用 Kotlin 重构了。DiskLruCache 的构造函数被 internal 修饰了。重构后的前几个版本还提供了 静态方法来创建。后边版本直接静态方法都移除了,这是要搞事情啊,不准备给我们用的样子。不过如果用Java写的话就可以直接创建,Java会忽视 internal 关键字直接过编译期。但是 Kotlin 就不行了,会报错。又不想用Java写。还是直接用反射创建吧,没有反射干不了的事情。

internal fun getDiskLruCache(
    fileSystem: FileSystem?,
    directory: File?,
    appVersion: Int,
    valueCount: Int,
    maxSize: Long
): DiskLruCache {
    val cls = DiskLruCache::class.java
    return try {
        val runnerClass = Class.forName("okhttp3.internal.concurrent.TaskRunner")
        val constructor = cls.getConstructor(
            FileSystem::class.java,
            File::class.java,
            Int::class.java,
            Int::class.java,
            Long::class.java,
            runnerClass
        )
        constructor.newInstance(
            fileSystem,
            directory,
            appVersion,
            valueCount,
            maxSize,
            TaskRunner.INSTANCE
        )
    } catch (e: Exception) {
        try {
            val constructor = cls.getConstructor(
                FileSystem::class.java,
                File::class.java,
                Int::class.java,
                Int::class.java,
                Long::class.java,
                Executor::class.java
            )
            val executor = ThreadPoolExecutor(
                0, 1, 60L, TimeUnit.SECONDS,
                LinkedBlockingQueue(), threadFactory("OkHttp DiskLruCache", true)
            )
            constructor.newInstance(
                fileSystem,
                directory,
                appVersion,
                valueCount,
                maxSize,
                executor
            )
        } catch (e: Exception) {
            throw IllegalArgumentException("Please use okhttp 4.0.0 or later")
        }
    }
}

刚好4.0.0 之后的几个版本,构造函数要提供一个线程池,4.3.0 后的版本成了 TaskRunner 了。可以都兼容一下。

具体的读写IO操作在CacheManager.kt 这个类中,这个是根据Okhttp 的 Cache 修改而来的。

全局参数

增加了全局 设置缓存模式、缓存时间。优先级还是 Service 中声明出来的高。

CacheManager.setCacheModel(CacheMode.READ_CACHE_NETWORK_PUT)// 设置全局缓存模式
    .setCacheTime(15 * 1000) // 设置全局 过期时间 (毫秒)
    .useExpiredData(true)// 缓存过期时是否继续使用,仅对 ONLY_CACHE 生效

具体使用

具体使用方式:详见Demo NetCache