OkHttp 知识梳理(4) - OkHttp 之缓存源码解析

一、基础

1.1 使用缓存的场景

对于一个联网应用来说,当设计网络部分的逻辑时,不可避免的要使用到缓存,目前我们项目中使用缓存的场景如下:

  • 当请求数据的时候,先判断本地是否有缓存,或者本地的缓存是否过期,如果有缓存并且没有过期,那么就直接返回给接口的调用者,这部分称为 客户端缓存 或者 强制缓存
  • 假如不满足第一步的场景,那么就需要发起网络请求,但是服务器为了减少用户的流量,中间的代理服务器也会有自己的一套缓存机制,但这需要客户端和服务器协商好请求头部与缓存相关的字段,也就是我们在 OkHttp 知识梳理(3) - OkHttp 之缓存基础 中提到的缓存相关字段,这部分称为 服务器缓存
  • 假如服务器请求失败或者告知客户端缓存仍然可用,那么为了优化用户的体验,我们可以继续使用客户端的缓存,如果没有缓存,那么可以先展示默认的数据。

1.2 为什么要学习 OkHttp 缓存的实现逻辑

OkHttp中,我们可以通过以下两点来对缓存的策略进行配置:

  • 在创建OkHttpClient的过程中,通过.cache(Cache)配置缓存的位置。
  • 在构造Request的过程中通过.cacheControl(CacheControl)来配置缓存逻辑。

OkHttp的缓存框架并不能完全满足我们的定制需求,我们有必要去了解它内部的实现逻辑,才能知道如何设计出符合1.1中谈到的使用场景。

二、源码解析

对于OkHttp缓存的内部实现,我们分为以下四点来介绍:

  • Cache类:存储部分逻辑的实现,决定了缓存的数据如何保存及查找。
  • CacheControl:单次请求的逻辑实现,决定了在发起请求后,在什么情况下直接返回缓存。
  • CacheInterceptor:在本系列的第一篇文章中,我们分析了OkHttp从调用.call接口到真正发起请求,经过了一系列的拦截器,CacheInterceptor就是其中预置的一个拦截器。
  • CacheStragy:它是CacheInterceptor负责缓存判断的具体实现类,其最终的目的就是构造出networkRequestcacheResponse这两个成员变量。

2.1 Cache 类

Cache类的用法如下:

//分别对应缓存的目录,以及缓存的大小。
Cache mCache = new Cache(new File(CACHE_DIRECTORY), CACHE_SIZE);
//在构造 OkHttpClient 时,通过 .cache 配置。
OkHttpClient client = new OkHttpClient.Builder().cache(mCache).build();

在其内部采用DiskLruCache实现了LRU算法的磁盘缓存,对于一般的使用场景,不需要过多的关心,只需要指定缓存的位置和大小就可以了。

2.2 CacheControl

CacheControl是对HTTPCache-Control头部的描述,通过Builder方法我们可以对其进行配置,下面我们简单地介绍几个常用的配置:

  • noCache():如果出现在 请求头部,那么表示不适用于缓存响应,从网络获取结果;如果出现在 响应头部,表示不允许对响应进行缓存,而是客户端需要与服务器再次验证,进行一个额外的GET请求得到最新的响应。
  • noStore():如果出现在 响应头部,则表明该响应不能被缓存。
  • maxAge(int maxAge, TimeUnit timeUnit):设置缓存的 最大存活时间,假如当前时间与自身的Age时间差不在这个范围内,那么需要发起网络请求。
  • maxStale(int maxStale,TimeUnit timeUnit):设置缓存的 最大过期时间,假如当前时间与自身的Age时间差超过了 最大存活时间,但是超过部分的值小于过期时间,那么仍然可以使用缓存。
  • minFresh(int minFresh,TimeUnit timeUnit):如果当前时间加上minFresh的值,超过了该缓存的过期时间,那么就发起网络请求。
  • onlyIfCached:表示只接受缓存中的响应,如果缓存不存在,那么返回一个状态码为504的响应。

CacheControl的配置项将会影响到我们后面在CacheStragy命中缓存的策略

2.3 CacheInterceptor

CacheInterceptor的源码地址为 CacheInterceptor ,正如我们在 OkHttp 知识梳理(1) - OkHttp 源码解析之入门 中分析过的,它是内置拦截器。下面,我们先来看一下主要的流程,它在CacheInterceptorintercept方法中:

  @Override public Response intercept(Chain chain) throws IOException {
    //1.通过 cache 找到之前缓存的响应,但是该缓存如他的名字一样,仅仅是一个候选人。
    Response cacheCandidate = cache != null
        ? cache.get(chain.request())
        : null;
    //2.获取当前的系统时间。
    long now = System.currentTimeMillis();
    //3.通过 CacheStrategy 的工厂方法构造出 CacheStrategy 对象,并通过 get 方法返回。
    CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    //4.在 CacheStrategy 的构造过程中,会初始化 networkRequest 和 cacheResponse 这两个变量,分别表示要发起的网络请求和确定的缓存。
    Request networkRequest = strategy.networkRequest;
    Response cacheResponse = strategy.cacheResponse;

    if (cache != null) {
      cache.trackResponse(strategy);
    }
    //5.如果曾经有候选的缓存,但是经过处理后 cacheResponse 不存在,那么关闭候选的缓存资源。
    if (cacheCandidate != null && cacheResponse == null) {
      closeQuietly(cacheCandidate.body());
    }

    //6.如果要发起的请求为空,并且没有缓存,那么直接返回 504 给调用者。
    if (networkRequest == null && cacheResponse == null) {
      return new Response.Builder()
          .request(chain.request())
          .protocol(Protocol.HTTP_1_1)
          .code(504)
          .message("Unsatisfiable Request (only-if-cached)")
          .body(Util.EMPTY_RESPONSE)
          .sentRequestAtMillis(-1L)
          .receivedResponseAtMillis(System.currentTimeMillis())
          .build();
    }

    //7.如果不需要发起网络请求,那么直接将缓存返回给调用者。
    if (networkRequest == null) {
      return cacheResponse.newBuilder()
          .cacheResponse(stripBody(cacheResponse))
          .build();
    }

    Response networkResponse = null;
    try {
      //8.继续调用链的下一个步骤,按常理来说,走到这里就会真正地发起网络请求了。
      networkResponse = chain.proceed(networkRequest);
    } finally {
      //9.保证在发生了异常的情况下,候选的缓存可以正常关闭。
      if (networkResponse == null && cacheCandidate != null) {
        closeQuietly(cacheCandidate.body());
      }
    }

    //10.网络请求完成之后,假如之前有缓存,那么首先进行一些额外的处理。
    if (cacheResponse != null) {
      //10.1 假如是 304,那么根据缓存构造出返回的结果给调用者。
      if (networkResponse.code() == HTTP_NOT_MODIFIED) {
        Response response = cacheResponse.newBuilder()
             //结合两者的头部字段。
            .headers(combine(cacheResponse.headers(), networkResponse.headers()))
             //更新发送和接收请求的时间。
            .sentRequestAtMillis(networkResponse.sentRequestAtMillis())
            .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
             //更新缓存和请求的返回结果。
            .cacheResponse(stripBody(cacheResponse))
            .networkResponse(stripBody(networkResponse))
            .build();
        networkResponse.body().close();

        // Update the cache after combining headers but before stripping the
        // Content-Encoding header (as performed by initContentStream()).
        cache.trackConditionalCacheHit();
        cache.update(cacheResponse, response);
        return response;
      } else {
        //10.2 关闭缓存。
        closeQuietly(cacheResponse.body());
      }
    }
    //11.构造出返回结果。
    Response response = networkResponse.newBuilder()
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build();

    if (cache != null) {
      if (HttpHeaders.hasBody(response) && CacheStrategy.isCacheable(response, networkRequest)) {
        //12.如果符合缓存的要求,那么就缓存该结果。
        CacheRequest cacheRequest = cache.put(response);
        return cacheWritingResponse(cacheRequest, response);
      }
      //13.对于某些请求方法,需要移除缓存,例如 PUT/PATCH/POST/DELETE/MOVE
      if (HttpMethod.invalidatesCache(networkRequest.method())) {
        try {
          cache.remove(networkRequest);
        } catch (IOException ignored) {
          // The cache cannot be written.
        }
      }
    }
    return response;
  }

调用的流程图如下所示:


CacheInterceptor 调用流程图

2.4 CacheStrategy

通过上面的这段代码,我们可以对OkHttp整个缓存的实现有一个大概的了解,其实关键的实现还是在于这句,因为它决定了过滤的缓存和最终要发起的请求究竟是怎么样的:

CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    public Factory(long nowMillis, Request request, Response cacheResponse) {
      this.nowMillis = nowMillis;
      this.request = request;
      //1.从磁盘中直接读取出来的原始缓存,没有对头部的字段进行校验。
      this.cacheResponse = cacheResponse;

      if (cacheResponse != null) {
        //读取发送请求和收到结果的时间。
        this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
        this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
        //遍历头部字段,解析完毕后赋值给成员变量。
        Headers headers = cacheResponse.headers();
        for (int i = 0, size = headers.size(); i < size; i++) {
          String fieldName = headers.name(i);
          String value = headers.value(i);
          if ("Date".equalsIgnoreCase(fieldName)) {
            servedDate = HttpDate.parse(value);
            servedDateString = value;
          } else if ("Expires".equalsIgnoreCase(fieldName)) {
            expires = HttpDate.parse(value);
          } else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
            lastModified = HttpDate.parse(value);
            lastModifiedString = value;
          } else if ("ETag".equalsIgnoreCase(fieldName)) {
            etag = value;
          } else if ("Age".equalsIgnoreCase(fieldName)) {
            ageSeconds = HttpHeaders.parseSeconds(value, -1);
          }
        }
      }
    }

    public CacheStrategy get() {
      //接下来的重头戏就是通过 getCandidate 方法来对 networkRequest 和 cacheResponse 赋值。
      CacheStrategy candidate = getCandidate();
      //如果网络请求不为空,但是 request 设置了 onlyIfCached 标志位,那么把两个请求都赋值为空。
      if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
        return new CacheStrategy(null, null);
      }
      return candidate;
    }

    private CacheStrategy getCandidate() {
      //1.如果缓存为空,那么直接返回带有网络请求的策略。
      if (cacheResponse == null) {
        return new CacheStrategy(request, null);
      }

      //2.请求是 Https 的,但是 cacheResponse 的 handshake 为空。
      if (request.isHttps() && cacheResponse.handshake() == null) {
        return new CacheStrategy(request, null);
      }

      //3.根据缓存的状态判断是否需要该缓存,在规则一致的时候一般不会在这一步返回。
      if (!isCacheable(cacheResponse, request)) {
        return new CacheStrategy(request, null);
      }

      //4.获得当前请求的 cacheControl,如果配置了不缓存,或者当前的请求配置了 If-Modified-Since/If-None-Match 字段。
      CacheControl requestCaching = request.cacheControl();
      if (requestCaching.noCache() || hasConditions(request)) {
        return new CacheStrategy(request, null);
      }

      //5.获取缓存的 cacheControl,如果是可变的,那么就直接返回该缓存。
      CacheControl responseCaching = cacheResponse.cacheControl();
      if (responseCaching.immutable()) {
        return new CacheStrategy(null, cacheResponse);
      }

      //6.1 计算缓存的年龄。
      long ageMillis = cacheResponseAge();
      //6.2 计算刷新的时机。
      long freshMillis = computeFreshnessLifetime();
      
      //7.请求所允许的最大年龄。
      if (requestCaching.maxAgeSeconds() != -1) {
        freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
      }
      
      //8.请求所允许的最小年龄。
      long minFreshMillis = 0;
      if (requestCaching.minFreshSeconds() != -1) {
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
      }
      
      //9.最大的 Stale() 时间。
      long maxStaleMillis = 0;
      if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
      }
      
      //10.根据几个时间点确定是否返回缓存,并且去掉网络请求,如果客户端需要强行去掉网络请求,那么就是修改这个条件。
      if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        if (ageMillis + minFreshMillis >= freshMillis) {
          builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
        }
        long oneDayMillis = 24 * 60 * 60 * 1000L;
        if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
          builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
        }
        return new CacheStrategy(null, builder.build());
      }

      //填入条件请求的字段。
      String conditionName;
      String conditionValue;
      if (etag != null) {
        conditionName = "If-None-Match";
        conditionValue = etag;
      } else if (lastModified != null) {
        conditionName = "If-Modified-Since";
        conditionValue = lastModifiedString;
      } else if (servedDate != null) {
        conditionName = "If-Modified-Since";
        conditionValue = servedDateString;
      } else {
        //如果不是条件请求,那么去掉原始缓存。
        return new CacheStrategy(request, null); // No condition! Make a regular request.
      }

      Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
      Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);

      Request conditionalRequest = request.newBuilder()
          .headers(conditionalRequestHeaders.build())
          .build();
      //返回带有条件请求的 conditionalRequest,和原始的缓存,这样在出现 304 的时候就可以处理。
      return new CacheStrategy(conditionalRequest, cacheResponse);
    }

下图是这个请求的流程图,为了方便大家理解,采用四种颜色标志了(networkRequest, cacheResponse)的四种情况:

  • 红色:networkRequest为原始requestcacheResponsenull
  • 绿色:networkRequest为原始requestcacheResponsecacheCandicate
  • 紫色:networkRequest为原始request加上缓存相关的头部,cacheResponsecacheCandicate
  • 棕色:networkRequestcacheResponse都为null
CacheStragy 流程图

三、小结

经过我们对于以上代码的分析,可以知道,当我们基于OkHttp来实现定制的缓存逻辑的时候,需要处理以下三个方面的问题:

  • 客户端缓存 进行设计,调整cacheControlmaxStaleminFresh的参数,我们在下一篇文章中,将根据cacheControl来完成缓存的设计。
  • 服务器缓存 进行设计,那么就需要服务端去处理If-None-MatchIf-Modified-SinceIf-Modified-Since这三个字段。当返回304的时候,OkHttp这边已经帮我们处理好了,所以客户端这边并不需要做什么。
  • 异常情况 的处理,通过CacheInterceptor的源码,我们可以发现,当发生504或者缓存没有命中,但是网络请求失败的时候,其实是得不到任何的返回结果的,如果我们需要在这种情况下返回缓存,那么还需要额外的处理逻辑。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容