Okhttp解析(二)-- 缓存

本文深入剖析OkHttp的缓存实现,从构建OkhttpClient时设置缓存开始,讲解Cache类、DiskLruCache的工作原理,以及缓存策略在CacheInterceptor中的应用。通过源码分析,展示了从请求到响应的过程中,如何利用缓存提高效率并减少网络请求。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在上一篇Okhttp解析(一)– 源码中,我们从源码角度分析了同步请求和异步请求的流程,这一篇我们主要是分析Okhttp的缓存实现。那么Okhttp的缓存是如何使用的呢,下面是简单的示例代码:

OkHttpClient okHttpClient = new OkHttpClient.Builder()
.cache(new Cache(文件对象,1024*1024*5))
.build();

其实很简单就是在构建OkhttpClient的时候连缀了一个cache函数,并传入一个Cache实例,Cache构造要求两个参数,第一个是缓存的文件对象;第二个是缓存的大小(以字节为单位)。下面我们来看下Cache类。

我们从它的构造开始:

  public Cache(File directory, long maxSize) {
    this(directory, maxSize, FileSystem.SYSTEM);
  }

  Cache(File directory, long maxSize, FileSystem fileSystem) {
    this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize);
  }

我们发现两个参数的构造会调用三个参数的构造,在该构造中会实例化一个DiskLruCache硬盘缓存对象,那么这个硬盘缓存对象是什么呢?

它是一个基于Lru算法的有限存储空间的文件系统,每个缓存实体都是以key-value的形式存在,key必须能够匹配正则[a-z0-9_-]{1,64},value是以字节序列的形式存在的,长度在0-Integer.MAX_VALUE之间。

这里我们介绍一下Lru算法吧,然后再继续。 Lru是Least Recently Used的缩写,即最近最少使用,假设我们的缓存一共可以存储1000条数据,当实际存储量小于1000时随便添加,但是当存储量超过1000呢?它会从之前缓存的数据中删除最老的一条,从而腾出空间保存新的。不过需要注意一下,所谓的最老并不是说就是第一条保存的数据,比如:第一次缓存第一条数据,在第1000次我们又请求了该数据,那么它就会保存在最前面(即最新),其它的所有保存的数据都会被顶到后面去。具体的实现,感兴趣的话可以自己去了解一下。

好了缓存现在已经配置了,那么它会在哪里使用呢?答案就是CacheInterceptor,我们去看一下它的intercept函数:

@Override public Response intercept(Chain chain) throws IOException {
    //判断缓存类是否为null,按照上面的示范代码,默认是null,因为在初始化CacheInterceptor
    //类时会传入一个InternalCache对象,而在使用空构造函数创建OkhttpClient时,
    //默认的Builder是没有默认的InternalCache对象的
    Response cacheCandidate = cache != null
        ? cache.get(chain.request())
        : null;

    ...

  }

第一句就是我们想要的,其它的暂时省略。判断cache是否为null,不为null传入请求获取Response对象,认真的朋友可能会发现这个cache好像不是我们实例化的Cache对象,而是一个InternalCache对象,瞬间凌乱了。。。不要急,我们看下在初始化CacheInterceptor时传入的是什么?

//RealCall的getResponseWithInterceptorChain函数
Response getResponseWithInterceptorChain() throws IOException {

    ... 

    interceptors.add(new CacheInterceptor(client.internalCache()));

    ...
  }

原来是调用OkhttpClient的internalCache函数:

InternalCache internalCache() {
    return cache != null ? cache.internalCache : internalCache;
  }

原来就是传入我们初始化的cache的internalCache 参数。我们进到Cache看看它:

final InternalCache internalCache = new InternalCache() {
    @Override public Response get(Request request) throws IOException {
      return Cache.this.get(request);
    }

    @Override public CacheRequest put(Response response) throws IOException {
      return Cache.this.put(response);
    }

    @Override public void remove(Request request) throws IOException {
      Cache.this.remove(request);
    }

    @Override public void update(Response cached, Response network) {
      Cache.this.update(cached, network);
    }

    @Override public void trackConditionalCacheHit() {
      Cache.this.trackConditionalCacheHit();
    }

    @Override public void trackResponse(CacheStrategy cacheStrategy) {
      Cache.this.trackResponse(cacheStrategy);
    }
  };

可以发现它是Cache的一个成员变量,是InternalCache接口的匿名实现类对象,不过认真看它的接口实现,实际上调用的都是Cache的函数,所以我们直接看Cache的即可。现在继续上面的话题,传入Request对象,获取Response对象,即Cache的get函数:

@Nullable Response get(Request request) {
    //将url进行了UTF-8编码、md5,然后转换为16进制字符串
    String key = key(request.url());
    DiskLruCache.Snapshot snapshot;
    Entry entry;
    try {
      //此处cache为DiskLruCache对象,通过DiskLruCache的get函数获取和key对应的value值的快照对象(一种备份)
      snapshot = cache.get(key);
      if (snapshot == null) {
        return null;
      }
    } catch (IOException e) {
      // Give up because the cache cannot be read.
      return null;
    }

    try {
      //根据快照的元信息来实例化一个Entry对象(这里的元信息主要就是一些响应头信息)
      entry = new Entry(snapshot.getSource(ENTRY_METADATA));
    } catch (IOException e) {
      Util.closeQuietly(snapshot);
      return null;
    }

    //获取响应结果
    Response response = entry.response(snapshot);

    //将请求和响应作一下对比,如果不匹配返回null
    if (!entry.matches(request, response)) {
      Util.closeQuietly(response.body());
      return null;
    }

    //返回结果
    return response;
  }

在Cache的get函数中主要做了以下几个操作:
(1)对url进行编码生成key
(2)调用DiskLruCache对象的get函数,获取快照对象
(3)通过响应头信息的标识(索引为0),从快照中获取响应头信息的字节流对象(Source可以理解为流)然后封装成Entry对象
(4)调用Entry对象的response函数,传入快照,获取响应对象(根据快照信息,会通过Okio来读取ResposeBody)
(5)检查请求和响应是否匹配
(6)返回结果
现在对上面比较关键的作一下分析,首先是第二点:

  /**
   * 通过key获取Entry的快照信息
   */
  public synchronized Snapshot get(String key) throws IOException {
    initialize();

    //检查是否关闭
    checkNotClosed();
    //判断key是否有效
    validateKey(key);
    //根据key,从LinkHashMap中获取缓存的Entry实体对象(注意这里的lruEntries缓存的Entry只包含元信息,没有Body信息)
    Entry entry = lruEntries.get(key);
    if (entry == null || !entry.readable) return null;

    //获取快照对象
    Snapshot snapshot = entry.snapshot();
    if (snapshot == null) return null;

    redundantOpCount++;
    journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n');
    if (journalRebuildRequired()) {
      executor.execute(cleanupRunnable);
    }

    return snapshot;
  }

对于第三点,主要是元信息是个什么东西,源代码中是这么注释的:

//获取了响应头的HTTP响应Entry应该是这样的
{
  http://google.com/foo
  GET
  2
  Accept-Language: fr-CA
  Accept-Charset: UTF-8
  HTTP/1.1 200 OK
  3
  Content-Type: image/png
  Content-Length: 100
  Cache-Control: max-age=600
  }

//获取了响应头的HTTPS响应Entry应该是这样的
{
 https://google.com/foo  
 GET   
 2     
 Accept-Language: fr-CA
 Accept-Charset: UTF-8
 HTTP/1.1 200 OK
 3
 Content-Type: image/png
 Content-Length: 100
 Cache-Control: max-age=600

 AES_256_WITH_MD5
 2
 base64-encoded peerCertificate[0]
 base64-encoded peerCertificate[1]
 -1
 TLSv1.2
 }

第四点,通过reponse()来获取Response对象:

public Response response(DiskLruCache.Snapshot snapshot) {
      String contentType = responseHeaders.get("Content-Type");
      String contentLength = responseHeaders.get("Content-Length");
      Request cacheRequest = new Request.Builder()
          .url(url)
          .method(requestMethod, null)
          .headers(varyHeaders)
          .build();
      return new Response.Builder()
          .request(cacheRequest)
          .protocol(protocol)
          .code(code)
          .message(message)
          .headers(responseHeaders)
          .body(new CacheResponseBody(snapshot, contentType, contentLength))
          .handshake(handshake)
          .sentRequestAtMillis(sentRequestMillis)
          .receivedResponseAtMillis(receivedResponseMillis)
          .build();
    }

主要就是拼接请求,然后是拼接响应结果,我们看一下body部分即可:

CacheResponseBody(final DiskLruCache.Snapshot snapshot,
        String contentType, String contentLength) {
      this.snapshot = snapshot;
      this.contentType = contentType;
      this.contentLength = contentLength;

      Source source = snapshot.getSource(ENTRY_BODY);
      bodySource = Okio.buffer(new ForwardingSource(source) {
        @Override public void close() throws IOException {
          snapshot.close();
          super.close();
        }
      });
    }

可以发现就是通过快照获取body的流对象(Source对象),然后通过Okio来读取。到这里我们就获取到了一个Response。

此时我们就从Cache中获取到了一个Response对象。在CacheInterceptor的intercept中,接下来就是把获取到的响应和请求一起传入到CacheStrategy的Factory的构造函数中,然后调用其get()函数获取CacheStrategy对象,我们看一下其实现。

    /**
     * 如果请求需要使用缓存的话,返回一个缓存策略实例
     */
    public CacheStrategy get() {
      //获取缓存实例
      CacheStrategy candidate = getCandidate();

      if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
        // 网络和缓存都无法使用时返回CacheStrategy对象,其后续请求和缓存结果都为null,缓存适配器拿到之后,会直接返回504
        return new CacheStrategy(null, null);
      }

      //返回缓存实例
      return candidate;
    }

CacheStrategy.Factory的构造函数,其实就是为参数赋值,我们就不看了,而get()函数的话会得到一个CacheStrategy对象,然后返回到intercept中,我接下来继续看。其实逻辑大部分在上一篇都讲了:
(1)如果CacheStrategy对象返回的新的请求和缓存结果都为null,说明失败了。直接返回响应,响应码为504
(2)后续请求为null,那么响应结果肯定不为null,不然不会走到这里,因为上一步已经对两个都为null的做了校验。此时说明不需要网络请求,直接返回缓存的响应结果
(3)后续请求不为null,调用下一个拦截器获取网络请求的对象
(4)在获取网络请求响应结果之后,如果缓存的响应也不为null,那么根据网络响应的响应码是否等于304走不同逻辑。等于说明数据未改变,会使用网络响应的头信息来刷新缓存的头信息,Body部分不变。变化的话就将缓存的响应关闭
(5)构建新的响应结果
(6)缓存对象cache不为null,并且有请求体,可缓存,那么就把响应结果添加到cache中。
(7)缓存对象cache不为null,但是是无效的缓存,移除请求
(8)返回结果
上面这8个步骤里面前面就不说了,重点在第6点,即响应和请求符合要求,将其添加到缓存中,我们看一下这里的代码:

 @Nullable CacheRequest put(Response response) {
    //获取请求的方法
    String requestMethod = response.request().method();

    //如果请求的方法是无效的,那么移除请求(POST,PATCH,PUT,DELETE,MOVE都是无效的请求方式)
    if (HttpMethod.invalidatesCache(response.request().method())) {
      try {
        remove(response.request());
      } catch (IOException ignored) {
        // The cache cannot be written.
      }
      return null;
    }

    //如果请求方式不是GET,那么直接返回null,其实HEAD和部分POST请求其实也是可以缓存的,但是成本高,效益低,所以不考虑
    if (!requestMethod.equals("GET")) {
      return null;
    }

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

    //使用响应结果构建Entry对象
    Entry entry = new Entry(response);
    DiskLruCache.Editor editor = null;
    try {
      //获取一个编辑器对象
      editor = cache.edit(key(response.request().url()));
      if (editor == null) {
        return null;
      }
      //写入本地缓存
      entry.writeTo(editor);
      return new CacheRequestImpl(editor);
    } catch (IOException e) {
      abortQuietly(editor);
      return null;
    }
  }

写入本地之后,就返回一个CacheRequest接口的实现类CacheRequestImpl对象,然后通过处理CacheRequestImpl对象和响应结果这两个参数,返回真正的响应结果。

到这里缓存的流程也分析完了。

有一点需要说明一下:有些人可能会觉得奇怪,明明是硬盘缓存,怎么内存里面还有一个LinkHashMap对象来缓存Entry对象,其实在添加缓存的时候我们通过Response对象实例化了一个Entry对象,在它的构造里面,我们发现它并没有为Body部分赋值,只是保存了头信息等元信息,很小。它主要的作用是起到一个类似索引表的功能,提高效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值