Volley(六 )—— 从源码带看Volley的缓存机制

时间:2023-12-12 08:20:26

磁盘缓存DiskBasedCache

如果你还不知道volley有磁盘缓存的话,请看一下我的另一篇博客请注意,Volley已默认使用磁盘缓存

DiskBasedCache内部结构

它由两部分组成,一部分是头部,一部分是内容;先得从它的内部静态类CacheHeader(缓存的头部信息)讲起,先看它的内部结构:

static class CacheHeader {
/** 缓存文件的大小 */
public long size; /** 缓存文件的唯一标识 */
public String key; /** 这个是与与http请求缓存相关的标签 */
public String etag; /** 服务器的返回来数据的时间 */
public long serverDate; /** TTL 缓存过期时间. */
public long ttl; /** Soft TTL 缓存新鲜度时间. */
public long softTtl; /** 服务器还回来的头部信息. */
public Map<String, String> responseHeaders;
} //可以看到,头部类里包含的都是一些基本信息。再来看一下内容部分,父类Cache里面的的Entry:
public static class Entry {
/** 服务端返回数据的主要内容. */
public byte[] data; public String etag; public long serverDate; public long ttl; public long softTtl;
}
 

可以看到,Entry里面和CacheHeader里有四个参数是一样的,只是Entry里多了data[],data[]就是用来保存主要数据 的。看到这你可以有点迷糊,Entry和CacheHeader里为什么要有四个参数一样,先简单说一下原因:volley框架里都用到接口编程,所以实 际代码中除了初始化,你只看到cache,而DiskBasedCache是看不到的,所以必须在Entry里先把那些缓存需要用到的参数保留起来,然后 具体实现和封装放在DiskBasedCache里。

DiskBasedCache的使用流程

  • 初始化

    DiskBasedCache的初始化时在RequestQueue新建时就发生的,可以看Volley.newRequestQueue()的源码:

public static RequestQueue newRequestQueue(Context context, HttpStack stack, int maxDiskCacheBytes) {
File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR); .... //maxDiskCacheBytes为缓存的最大容量,不传就默认为5M
if (maxDiskCacheBytes <= -1)
{
// No maximum size specified
queue = new RequestQueue(new DiskBasedCache(cacheDir), network);
}
else
{
// Disk cache size specified
queue = new RequestQueue(new DiskBasedCache(cacheDir, maxDiskCacheBytes), network);
} queue.start();
return queue;
}

可以看到,磁盘缓存的路径为:context.getCacheDir(),如果maxDiskCacheBytes有传入,就以传入的为准,如果为空:

/** 默认的磁盘存放的最大byte */
private static final int DEFAULT_DISK_USAGE_BYTES = 5 * 1024 * 1024;
...
public DiskBasedCache(File rootDirectory) {
this(rootDirectory, DEFAULT_DISK_USAGE_BYTES);
}
 

磁盘默认为5M。所以如果你想设置最大的磁盘缓存值,那么就不能直接向下面那样这样初始化了:

    queue = Volley.newRequestQueue(context);

而是需要这样:

    queue = Volley.newRequestQueue(context, 10 * 1024 * 1024);
  • 存放缓存数据

    第一次缓存的数据是从哪来的呢,当然是从网上来,看NetWorkDispatcher的run方法里:

@Override
public void run() {
while (true) {
...
try {
....
请求解析http的返回信息
....
if (request.shouldCache() && response.cacheEntry != null) {
mCache.put(request.getCacheKey(), response.cacheEntry);
request.addMarker("network-cache-written");
} ....
}
}
 

其中request.getCacheKey()默认为请求的url,response.cacheEntry是Cache.Entry,里面已存 放好解析完的httpResponse数据,request.shouldCache()默认是需要缓存,如果不需要可调用 request.setShouldCache(false)来去掉缓存功能。

我们把请求和处理的http的返回略过,留下几行关键代码,如果这个请求需要缓存(默认需要)和缓存信息不为空,那么就保存缓存信息。接下来看,DiskBaseCache是怎么保存缓存的:

 /**
* 把缓存数据Entry写进磁盘里
*/
@Override
public synchronized void put(String key, Entry entry) {
//判断是否有足够的缓存空间来缓存新的数据
pruneIfNeeded(entry.data.length); File file = getFileForKey(key);
try {
FileOutputStream fos = new FileOutputStream(file);
//用enry里面的数据,再封装成一个CacheHeader
CacheHeader e = new CacheHeader(key, entry);
//先写头部缓存信息
boolean success = e.writeHeader(fos);
if (!success) {
fos.close();
VolleyLog.d("Failed to write header for %s", file.getAbsolutePath());
throw new IOException();
}
//成功后再写缓存内容
fos.write(entry.data);
fos.close();
//把头部信息先暂时保存在一个容器里
putEntry(key, e);
return;
} catch (IOException e) {
}
boolean deleted = file.delete();
if (!deleted) {
VolleyLog.d("Could not clean up file %s", file.getAbsolutePath());
}
}

可以看到,每次写入缓存之前,都先调用pruneIfNeeded()检查对象的大小,当缓冲区空间足够新对象的加入时就直接添加进来,否则会删除 部分对象,一直到新对象添加进来后还会有10%的空间剩余时为止,文件引用以LinkHashMap保存。添加时,首先以URL为key,经过个文本转换 后,以转换后的文本为名称,获取一个file对象。首先向这个对象写入缓存的头文件,然后是真正有用的网络返回数据。最后是当前内存占有量数值的更新,这 里需要注意的是真实数据被写入磁盘文件后,在内存中维护的应用,存的只是数据的相关属性。

  • 从缓存数据里取缓存

我们知道队列创建后就会有一个缓存线程在后台一直运行等待着缓存请求进来,但在等待线程前,会先调用mCache.initialize(),把缓存数据的头部信息放进一个Map类型mEntries里,这样以后要用到就先用mEntries判断,速度更快。

如果请求进来即调用Cache.Entry entry = mCache.get(request.getCacheKey()),那我们就看DiskBaseCache。get方法里做了什么:

 @Override
public synchronized Entry get(String key) {
CacheHeader entry = mEntries.get(key);
// 如果entry不为空,就直接返回
if (entry == null) {
return null;
} File file = getFileForKey(key);
CountingInputStream cis = null;
try {
cis = new CountingInputStream(new FileInputStream(file));
CacheHeader.readHeader(cis); // eat header
byte[] data = streamToBytes(cis, (int) (file.length() - cis.bytesRead));
return entry.toCacheEntry(data);
} catch (IOException e) {
VolleyLog.d("%s: %s", file.getAbsolutePath(), e.toString());
remove(key);
return null;
} finally {
if (cis != null) {
try {
cis.close();
} catch (IOException ioe) {
return null;
}
}
}
}

从方法里可以看到,先从文件里获得字节数输入流,从中减去头部文件的字节数,最后把真正内容的data[]数据拿到再组装成一个Cache.Entry返回。不得不说,Volley这真是精打细算啊。

从上面的分析可见,cache在做一些基础判断时都会先用到缓存的头部数据,如果确定头部信息没问题了,再真正读写内容,原因是头部数据比较小,放在内存中也不占地方,但处理速度会快很多。而真正的数据内容,可能会比较大,处理的开销也大,只在真正需要的地方读写。

Volley对304的处理

http的304状态码的含义是:

如果服务器端的资源没有变化,则自动返回 HTTP 304 (Not Changed.)状态码,内容为空,这样就节省了传输数据量。当服务器端代码发生改变或者重启服务器时,则重新发出资源,返回和第一次请求时类似。从而 保证不向客户端重复发出资源,也保证当服务器有变化时,客户端能够得到最新的资源。

完整的过程如下:

  1. 客户端请求一个页面(A)。
  2. 服务器返回页面A,并在给A加上一个Last-Modified/ETag。(Last-Modified为标记此文件在服务期端最后被修改的时间,ETag是这个请求的token)
  3. 客户端展现该页面,并将页面连同Last-Modified/ETag一起缓存。
  4. 客户再次请求页面A,并将上次请求时服务器返回的Last-Modified/ETag一起传递给服务器。
  5. 服务器检查该Last-Modified或ETag,并判断出该页面自上次客户端请求之后还未被修改,直接返 回响应304和一个空的响应体。

介绍完304,我们接下来来看看volley是怎么运用304来重用缓存的。

Volley对于头部的解析

首先我们来看一下对于response.header的处理,在每一个request里,都必须继承 parseNetworkResponse(NetworkResponse response)方法,然后在里面用 HttpHeaderParser.parseCacheHeaders()解析类来解析头部数据,具体如下:

public static Cache.Entry parseCacheHeaders(NetworkResponse response) {
long now = System.currentTimeMillis(); Map<String, String> headers = response.headers; long serverDate = 0;
long serverExpires = 0;
long softExpire = 0;
long maxAge = 0;
boolean hasCacheControl = false; String serverEtag = null;
String headerValue; headerValue = headers.get("Date");
if (headerValue != null) {
serverDate = parseDateAsEpoch(headerValue);
} headerValue = headers.get("Cache-Control");
if (headerValue != null) {
hasCacheControl = true;
String[] tokens = headerValue.split(",");
for (int i = 0; i < tokens.length; i++) {
String token = tokens[i].trim();
//如果Cache-Control里为no-cache和no-store则表示不需要缓存,返回null
if (token.equals("no-cache") || token.equals("no-store")) {
return null;
} else if (token.startsWith("max-age=")) {
try {
maxAge = Long.parseLong(token.substring(8));
} catch (Exception e) {
}
} else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
maxAge = 0;
}
}
} headerValue = headers.get("Expires");
if (headerValue != null) {
serverExpires = parseDateAsEpoch(headerValue);
}
serverEtag = headers.get("ETag");
// Cache-Control takes precedence over an Expires header, even if both exist and Expires
// is more restrictive.
if (hasCacheControl) {
softExpire = now + maxAge * 1000;
} else if (serverDate > 0 && serverExpires >= serverDate) {
// Default semantic for Expire header in HTTP specification is softExpire.
softExpire = now + (serverExpires - serverDate);
} Cache.Entry entry = new Cache.Entry();
entry.data = response.data;
entry.etag = serverEtag;
entry.softTtl = softExpire;
entry.ttl = entry.softTtl;
entry.serverDate = serverDate;
entry.responseHeaders = headers;
return entry;
}

从上面代码可以看出缓存头部是根据 Cache-Control 和 Expires 首部,计算出缓存的过期时间(ttl),和缓存的新鲜度时间(softTtl,默认softTtl和ttl相同),如果有Cache-Control标签 以它为准,没有就以Expires标签里的内容为准。

需要注意的是:Volley没有处理Last-Modify首部,而是处理存储了Date首部,并在后续的新鲜度验证时,使用Date来构建If-Modified-Since。 这与 Http 1.1 的语义有些违背。

Volley对于新鲜度和过期的验证

在使用缓存数据前,Volley会先对验证缓存数据是否过期,是否需要更新等属性,然后一一处理,代码在CacheDispatcher的run方法里:

 @Override
public void run() {
if (DEBUG) VolleyLog.v("start new dispatcher");
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
// 初始化缓存,里面会先把磁盘缓存里的头部数据缓存进内存里,增加处理速度
mCache.initialize(); while (true) {
try {
// 阻塞线程直到有请求加入,才开始运行
final Request request = mCacheQueue.take();
request.addMarker("cache-queue-take"); //请求是否取消
if (request.isCanceled()) {
request.finish("cache-discard-canceled");
continue;
} // 得到缓存数据entry
Cache.Entry entry = mCache.get(request.getCacheKey());
//如果缓存不存在,就把请求交给网络队列取处理
if (entry == null) {
request.addMarker("cache-miss");
mNetworkQueue.put(request);
continue;
} // 如果请求过期,也需要到网络重新获取数据
if (entry.isExpired()) {
request.addMarker("cache-hit-expired");
request.setCacheEntry(entry);
mNetworkQueue.put(request);
continue;
} // 到这里就表明缓存数据是可用的,解析缓存
request.addMarker("cache-hit");
Response<?> response = request.parseNetworkResponse(
new NetworkResponse(entry.data, entry.responseHeaders));
request.addMarker("cache-hit-parsed"); //验证缓存的新鲜度
if (!entry.refreshNeeded()) {
//新鲜的
mDelivery.postResponse(request, response);
} else {
// 不新鲜,虽然把缓存数据分发出去,但还是需要到网络上验证缓存是否需要更新
request.addMarker("cache-hit-refresh-needed");
//请求带上缓存属性
request.setCacheEntry(entry); response.intermediate = true; // 分发完缓存数据后,将请求加入网络请求队列,判断是否需要更新缓存数据
mDelivery.postResponse(request, response, new Runnable() {
@Override
public void run() {
try {
mNetworkQueue.put(request);
} catch (InterruptedException e) {
// Not much we can do about this.
}
}
});
} } catch (InterruptedException e) {
// We may have been interrupted because it was time to quit.
if (mQuit) {
return;
}
continue;
}
}
}

上面代码都已经加了注释,相信不难理解,那我们继续看,网络请求是怎么判断是否需要更新缓存的,在BasicNetwork.performRequest()里:

@Override
public NetworkResponse performRequest(Request<?> request) throws VolleyError {
....
while (true) {
...
try {
Map<String, String> headers = new HashMap<String, String>();
//如果请求有带属性,就将etag和If-Modified-Since属性加上
addCacheHeaders(headers, request.getCacheEntry());
httpResponse = mHttpStack.performRequest(request, headers);
StatusLine statusLine = httpResponse.getStatusLine();
int statusCode = statusLine.getStatusCode(); responseHeaders = convertHeaders(httpResponse.getAllHeaders());
//如果304就直接用缓存数据返回
if (statusCode == HttpStatus.SC_NOT_MODIFIED) {
return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED,
request.getCacheEntry().data, responseHeaders, true);
}
.....
return new NetworkResponse(statusCode, responseContents, responseHeaders, false);
} catch (SocketTimeoutException e) {
...
}
}
}

从上面的注释可以看到,如果是返回304就直接用缓存数据返回。那来看NetworkDispatcher的run()里:

public void run() {
...
NetworkResponse networkResponse = mNetwork.performRequest(request);
request.addMarker("network-http-complete");
// 如果是304并且已经将缓存分发出去里,就直接结束这个请求
if (networkResponse.notModified && request.hasHadResponseDelivered()) {
request.finish("not-modified");
continue;
}
...
}
}

现在流程比较清晰了,在有缓存的情况下,如果已经过期,但是返回304,就复用缓存。如果不新鲜了,就先将缓存分发出去,然后再进行网络请求,看是否需要更新缓存。

不过眼尖的读者一定有个疑惑,在解析头部数据时,默认不是新鲜度和过期事件是一样的吗?那新鲜度不是一定运行不到吗?确实是这样,我也有这个疑惑, 网上也找不到确切的资料来解释这一点。不过按照正常的逻辑,新鲜度时间一定比过期时间短,这样我们就可以根据实际需要更改Volley的源码。例如,我们 可以直接把新鲜度的验证时间设为3分钟,而过期时间设为一天,代码如下:

public static Cache.Entry parseIgnoreCacheHeaders(NetworkResponse response) {
long now = System.currentTimeMillis();
Map<String, String> headers = response.headers;
long serverDate = 0;
String serverEtag = null;
String headerValue;
headerValue = headers.get("Date"); if (headerValue != null) {
serverDate = HttpHeaderParser.parseDateAsEpoch(headerValue);
} serverEtag = headers.get("ETag");
final long cacheHitButRefreshed = 3 * 60 * 1000;
final long cacheExpired = 24 * 60 * 60 * 1000;
final long softExpire = now + cacheHitButRefreshed;
final long ttl = now + cacheExpired; Cache.Entry entry = new Cache.Entry();
entry.data = response.data;
entry.etag = serverEtag;
entry.softTtl = softExpire;
entry.ttl = ttl;
entry.serverDate = serverDate;
entry.responseHeaders = headers;
return entry;
}

然后使用的时候:

public class MyRequest extends com.android.volley.Request<MyResponse> {
...
@Override
protected Response<MyResponse> parseNetworkResponse(NetworkResponse response) {
String jsonString = new String(response.data);
MyResponse MyResponse = gson.fromJson(jsonString, MyResponse.class);
return Response.success(MyResponse, HttpHeaderParser.parseIgnoreCacheHeaders(response));
}
}

这样的话,在3分钟后就不新鲜,24小时后就会过期。

图片的自定义内存缓存

我们使用ImageLoader时会传入一个ImageCache,它是个接口,里面定义了两个方法:

public interface ImageCache {
public Bitmap getBitmap(String url);
public void putBitmap(String url, Bitmap bitmap);
}

那他们是什么时候使用的呢,可以从开始请求数据ImageLoader.get()方法看起:

 public ImageContainer get(String requestUrl, ImageListener imageListener,
int maxWidth, int maxHeight) {
//请求只能在主线程里,不然会报错
throwIfNotOnMainThread();
//用url和宽高组成key
final String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight); //从内存缓存里获取数据
Bitmap cachedBitmap = mCache.getBitmap(cacheKey);
if (cachedBitmap != null) {
// 如果内存不为空,直接返回图片信息
ImageContainer container = new ImageContainer(cachedBitmap, requestUrl, null, null);
imageListener.onResponse(container, true);
return container;
} ...
// 如果为空,就正常请求网络数据,下面用的是ImageRequest取请求网络数据
Request<?> newRequest =
new ImageRequest(requestUrl, new Listener<Bitmap>() {
@Override
public void onResponse(Bitmap response) {
//请求成功后,在这个方法里,把图片放进内存缓存中
onGetImageSuccess(cacheKey, response);
}
}, maxWidth, maxHeight,
Config.RGB_565, new ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onGetImageError(cacheKey, error);
}
});
...
} private void onGetImageSuccess(String cacheKey, Bitmap response) {
//把图片放进内存里
mCache.putBitmap(cacheKey, response);
...
}

从上面的代码注释中已经能比较清晰的看出,每次调用ImageLoader.get()方法,会先从内存缓存里先看有没有数据,有就直接返回,没有 就走正常的网络流程,先查看磁盘缓存,不存在或过期再去请求网络。图片比普通数据多一层缓存的原因也很简单,因为图片较大,读取和网络成本都大,能用缓存 就用缓存,能省一点是一点。

下面来看看具体的流程图
Volley(六 )—— 从源码带看Volley的缓存机制

以上就是Volley框架所使用到的所有缓存机制,如有遗漏请留言指出,多谢阅读。

参考链接:
Volley网络请求源码解析——击溃6大疑虑
Last-Modified和If-Modified-Since
Volley 源码解析