1 Problem Scenario

A network request interface that is requested when the app starts, when MainActivity enters, and even every time onResume resumes. The actual situation may be different, but there are situations like this where an interface needs to be requested more than once. We need the data of the interface, but the actual data doesn’t change that often. Requesting the network each time may result in wasted network resources and waiting.

2. Solution to the problem

2.1 No actual network request is executed and no data is returned

For example, through the data update interface function, data is the same as there is no need to update the interface. You can directly not call the request interface method during a certain period of time to reduce invalid refresh of the interface and achieve the effect of limiting the request interval

2.2 Caching requested data results

  1. Cache to memory (variable or LRU)
  2. Cache to database
  3. Cache to disk

If the same request is made within the same period of time, the cached data is returned.

This time around, I’m going to look at OkHttp’s built-in cache-to-disk capabilities.

3. Problem solving

3.1 Enabling OkHttp Caching

3.1.1 Initial configuration of OkHttp cache
@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
    // 10m
    val diskCacheSize = 10L shl 20

    return OkHttpClient.Builder()
        .readTimeout(60, TimeUnit.SECONDS)
        .connectTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        // Set the cache function for OkHttp
        .cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
        .build()
}
Copy the code

Okhttpclient.builder (). Cache(cache(File(app.externalcachedir, “net”), diskCacheSize) Whether or not caching is actually triggered depends on the configuration of the request header that returns the data.

3.1.2 Manually setting an effective cache duration for the request header that returns data

Interceptor set for OkHttp

private fun Request.isCachePostRequest(a): Boolean = run {
    url.toString().contains(APP_INFO_URL, true)}class CachePostResponseInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        var request = chain.request()

        // Initiate a request
        var response = chain.proceed(request)

        // Get the request result
        // Set the cache for this interface
        if (response.request.isCachePostRequest()) {
            response = response.newBuilder()
                .removeHeader("Pragma")
                // Cache for 60 seconds
                .addHeader("Cache-Control"."max-age=60")
                .build()
        }

        return response
    }
}
Copy the code

There are several ways to set the Cache for the interface data. Here, the Cache is set by setting the request header parameter cache-control. The Cache duration is 60 seconds.

Then add the interceptor to OkHttp

@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
    // 10m
    val diskCacheSize = 10L shl 20

    return OkHttpClient.Builder()
        .readTimeout(60, TimeUnit.SECONDS)
        .connectTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        // Set the cache function for OkHttp
        .cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
        // Add an interceptor to the return data Settings cache
        .addNetworkInterceptor(CachePostResponseInterceptor())
        .build()
}
Copy the code

This looks like it should be done, but to verify whether the cache needs to add a log or go through an interceptor, add a temporary interceptor for printing.

@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
    // 10m
    val diskCacheSize = 10L shl 20

    return OkHttpClient.Builder()
        .readTimeout(60, TimeUnit.SECONDS)
        .connectTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        // Set the cache function for OkHttp
        .cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
        // A temporary interceptor for printing logs
        .addInterceptor {
            val request = it.request()
            val response = it.proceed(request)
            Timber.e("cacheResponse: ${response.cacheResponse}    networkResponse: ${response.networkResponse}")
            response
        }
        // Add an interceptor to the return data Settings cache
        .addNetworkInterceptor(CachePostResponseInterceptor())
        .build()
}
Copy the code

Here why CachePostResponseInterceptor interceptor is addNetworkInterceptor way to add, and log print interceptor is by adding the first addInterceptor method does not explain, Explaining how the OkHttp interceptor works and how the chain of responsibility design pattern works can be covered in several other articles.

Let’s run it and see what happens.

E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78) cacheResponse: null networkResponse: The Response {= HTTP / 1.1 protocol, code = 200, message =, url =... } E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78) cacheResponse: Null networkResponse: Response{protocol= HTTP /1.1, code=200, message=, url=... }Copy the code

If the interval between two requests is 60 seconds, cacheResponse is null and networkResponse has a value.

In this case, the request data is not cached successfully. Normally, the first request for cacheResponse is null and networkResponse has a value. The second request for cacheResponse has a value and networkResponse is null.

Why didn’t OkHttp cache our interface data? Let’s take a look at how OkHttp caches data.

3.1.3 OkHttp cache data working logic

The job of caching data in OkHttp is left to the CacheInterceptor interceptor

Looking at the code of the CacheInterceptor class, you can see that the Cache is saved when the network request data is returned and the Cache object reference is present. Cache(Cache(File(app.externalCachedir, “net”), diskCacheSize))).

.valresponse = networkResponse!! .newBuilder() .cacheResponse(stripBody(cacheResponse)) .networkResponse(stripBody(networkResponse)) .build()if(cache ! =null) {
  if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
    // A critical point
    // Offer this request to the cache.
    val cacheRequest = cache.put(response)
    return cacheWritingResponse(cacheRequest, response).also {
      if(cacheResponse ! =null) {
        // This will log a conditional cache miss only.
        listener.cacheMiss(call)
      }
    }
  }
}

...
Copy the code

Take a look at the Cache put method

internal fun put(response: Response): CacheRequest? {
  val requestMethod = response.request.method

  if (HttpMethod.invalidatesCache(response.request.method)) {
    try {
      remove(response.request)
    } catch (_: IOException) {
      // The cache cannot be written.
    }
    return null
  }

  // Only GET requests are supported
  if(requestMethod ! ="GET") {
    // Don't cache non-GET responses. We're technically allowed to cache HEAD requests and some
    // POST requests, but the complexity of doing so is high and the benefit is low.
    return null
  }

  if (response.hasVaryAll()) {
    return null
  }

  val entry = Entry(response)
  var editor: DiskLruCache.Editor? = null
  try{ editor = cache.edit(key(response.request.url)) ? :return null
    entry.writeTo(editor)
    return RealCacheRequest(editor)
  } catch (_: IOException) {
    abortQuietly(editor)
    return null}}Copy the code

As you can see, only cached GET requests are supported, not GET requests that return NULL directly. Look at our request interface, it’s a POST request!

@POST(APP_INFO_URL)
suspend fun appInfo(@Body map: Map<String, String? >): Response<AppInfo>
Copy the code

OkHttp caches data only for GET requests, which is reasonable, but we sometimes encounter POST requests that need to cache data. Such a situation may indicate that the back-end write interface request mode is not appropriate, should the backend change? Not really.

The Cache class cannot inherit.

Change it yourself. How? There are two ideas

  1. copyOkHttpcachedCacheClasses andCacheInterceptorClass, modifyCachetheputMethod support cachingPOSTRequest and then copy inCacheInterceptorThe classCacheClass declaration reference to copy modifiedCacheClass object, will be modifiedCacheInterceptorClass to add objects toOkHttpList of interceptors.

This is something you can find on the web, but I think there’s too much duplication of code, adding classes with similar functionality (old Cache class and new Cache class, old CacheInterceptor class and new CacheInterceptor class).

  1. inOkHttpThe cache interceptor processes data that needs to be cached before it worksPOSTThe request toGETFirst pass the cache level (if there is a valid cache data directly returned to the cache data), and then restore to before making the actual network requestPOSTRequest to request data correctly, wait for the request data back againPOSTThe request toGET(to cache data).

This approach, which requires only two interceptors, is the approach I took.

3.2 Let OkHttp cache Post requests

3.2.1 inOkHttpThe cache interceptor processes data that needs to be cached before it worksPOSTThe request toGETrequest

The problem with this interceptor is to tell the CacheInterceptor that the interface data is cacheable and that if there is a valid cache data, it will return the cache data directly.

/** * POST converts to GET */
class TransformPostRequestInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        var request = chain.request()

        / / cache
        if (request.isCachePostRequest()) {
            val builder = request.newBuilder()
                // Change POST to GET
                .method("GET".null)
                .cacheControl(
                    CacheControl.Builder()
                        .maxAge(60, TimeUnit.SECONDS)
                        .build()
                )

            / / save the body
            saveRequestBody(builder, request.body)

            request = builder.build()
        }

        return chain.proceed(request)
    }
}
Copy the code

NewBuilder ().method(“GET”, request.body) to construct a new request is a GET request, but when you run it, you will find that the program crashes

Looking at the Method method, he doesn’t let us set the RequestBody for GET requests

open fun method(method: String, body: RequestBody?).: Builder = apply {
  require(method.isNotEmpty()) {
    "method.isEmpty() == true"
  }
  if (body == null) { require(! HttpMethod.requiresRequestBody(method)) {"method $method must have a request body."}}else {
    require(HttpMethod.permitsRequestBody(method)) {
      "method $method must not have a request body."}}this.method = method
  this.body = body
}
Copy the code
@kotlin.internal.InlineOnly
public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if(! value) {val message = lazyMessage()
        throw IllegalArgumentException(message.toString())
    }
}
Copy the code
@JvmStatic // Despite being 'internal', this method is called by popular 3rd party SDKs.
fun permitsRequestBody(method: String): Boolean = !(method == "GET" || method == "HEAD")
Copy the code

The RequestBody is our request parameter information and must be saved otherwise the request parameters will be lost. What do we do? We can only reflect it to him.

private fun saveRequestBody(builder: Request.Builder, body: RequestBody?). {
    val bodyField = builder.javaClass.getDeclaredField("body")
    bodyField.isAccessible = true
    bodyField.set(builder, body)
}
Copy the code
3.2.2 reduction forPOSTRequest to make the actual request, wait for the request data to come back againPOSTThe request toGETTo cache data

The interceptor needs to handle the problem of making the network request correctly and telling the CacheInterceptor when the network request data comes back that the interface data needs to be cached.

/** * cache of Response */
class CachePostResponseInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        var request = chain.request()

        // Before the actual request
        if (request.isCachePostRequest()) {
            request = request.newBuilder()
                .method("POST", request.body)
                .build()
        }

        // Initiate a request
        var response = chain.proceed(request)

        // Get the request result
        // Cache for this interface
        if (response.request.isCachePostRequest()) {
            val builder = response.request.newBuilder()
                // Change POST to GET
                .method("GET".null)

            / / save the body
            saveRequestBody(builder, request.body)

            response = response.newBuilder()
                .request(builder.build())
                .removeHeader("Pragma")
                // Cache for 60 seconds
                .addHeader("Cache-Control"."max-age=60")
                .build()
        }

        return response
    }
}
Copy the code

Set up an interceptor for OkHttp

@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
    // 10m
    val diskCacheSize = 10L shl 20

    return OkHttpClient.Builder()
        .readTimeout(60, TimeUnit.SECONDS)
        .connectTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        // Set the cache function for OkHttp
        .cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
        .addInterceptor(TransformPostRequestInterceptor())
        .addInterceptor {
            val request = it.request()
            val response = it.proceed(request)
            Timber.e("cacheResponse: ${response.cacheResponse}    networkResponse: ${response.networkResponse}")
            response
        }
        // Add an interceptor to the return data Settings cache
        .addNetworkInterceptor(CachePostResponseInterceptor())
        .build()
}
Copy the code

Run it and see what happens.

E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78) cacheResponse: null networkResponse: The Response {= HTTP / 1.1 protocol, code = 200, message =, url =... } E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78) cacheResponse: The Response {= HTTP / 1.1 protocol, code = 200, message =, url =... } networkResponse: nullCopy the code

If the interval between two requests is 60 seconds, the first request does not cache data, and the actual network request is sent, and the data returned should be cached. CacheResponse is null and networkResponse has a value.

The second request has cached data and returns the cached data directly without making the actual network request. CacheResponse has a value and networkResponse is null.

The actual log print was as expected, and the interface data was successfully cached and returned.

The final conclusion

The idea of having OkHttp cache POST requests using interceptors

The first step is to tell the CacheInterceptor that the interface data is cacheable before the CacheInterceptor works. If there is a valid cache, it will return the cache data directly.

The second step is to revert to a POST request before making the actual network request

Third, when the network request data comes back, tell the CacheInterceptor that the interface data needs to be cached.

All relevant codes have been posted in this article.