一、前言

前面我们分析了 Volley 的网络请求的相关流程以及图片加载的源码,如果没有看过的话可以阅读一下,接下来我们分析 Volley 的缓存,看看是怎么处理缓存超时、缓存更新以及缓存的整个流程,掌握 Volley 的缓存设计,对整个 Volley 的源码以及细节一个比较完整的认识。

二、 源码分析

我们首先看看 Cache 这个缓存接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public interface Cache {
//通过key获取指定请求的缓存实体
Entry get(String key);

//存入指定的缓存实体
void put(String key, Entry entry);

//初始化缓存
void initialize();

//使缓存中的指定请求实体过期
void invalidate(String key, boolean fullExpire);

//移除指定的请求缓存实体
void remove(String key);

//清空缓存
void clear();


class Entry {
//请求返回的数据
public byte[] data;

//用于缓存验证的http请求头Etag
public String etag;

//Http 请求响应产生的时间
public long serverDate;

//最后修改时间
public long lastModified;

//过期时间
public long ttl;

//新鲜度时间
public long softTtl;

public Map<String, String> responseHeaders = Collections.emptyMap();

public List<Header> allResponseHeaders;

//返回true则过期
public boolean isExpired() {
return this.ttl < System.currentTimeMillis();
}

//需要从原始数据源刷新,则为true
public boolean refreshNeeded() {
return this.softTtl < System.currentTimeMillis();
}
}
}

存储的实体就是响应,以字节数组作为数据的请求URL为键的缓存接口。
接下来我们看看 HttpHeaderParser 中用于解析 http 头的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public static Cache.Entry parseCacheHeaders(NetworkResponse response) {
long now = System.currentTimeMillis();

Map<String, String> headers = response.headers;

long serverDate = 0;
long lastModified = 0;
long serverExpires = 0;
long softExpire = 0;
long finalExpire = 0;
long maxAge = 0;
long staleWhileRevalidate = 0;
boolean hasCacheControl = false;
boolean mustRevalidate = false;

String serverEtag = null;
String headerValue;
//表示收到响应的时间
headerValue = headers.get("Date");
if (headerValue != null) {
serverDate = parseDateAsEpoch(headerValue);
}
//Cache-Control用于定义资源的缓存策略,在HTTP/1.1中,Cache-Control是
最重要的规则,取代了 Expires
headerValue = headers.get("Cache-Control");
if (headerValue != null) {
hasCacheControl = true;
String[] tokens = headerValue.split(",", 0);
for (int i = 0; i < tokens.length; i++) {
String token = tokens[i].trim();
//no-cache:客户端缓存内容,每次都要向服务器重新验证资源是否
被更改,但是是否使用缓存则需要经过协商缓存来验证决定,
no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
这两种情况不缓存返回null
if (token.equals("no-cache") || token.equals("no-store")) {
return null;
//max-age:设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)
} else if (token.startsWith("max-age=")) {
try {
maxAge = Long.parseLong(token.substring(8));
} catch (Exception e) {
}
//stale-while-revalidate:表明客户端愿意接受陈旧的响应,同时
在后台异步检查新的响应。秒值指示客户愿意接受陈旧响应的时间长度
} else if (token.startsWith("stale-while-revalidate=")) {
try {
staleWhileRevalidate = Long.parseLong(token.substring(23));
} catch (Exception e) {
}
//must-revalidate:缓存必须在使用之前验证旧资源的状态,并且不可使用过期资源。
并不是说「每次都要验证」,它意味着某个资源在本地已缓存时长短于 max-age 指定时
长时,可以直接使用,否则就要发起验证
proxy-revalidate:与must-revalidate作用相同,但它仅适用于共享缓存(例如代理),并被私有缓存忽略。
} else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
mustRevalidate = true;
}
}
}
//Expires 是 HTTP/1.0的控制手段,其值为服务器返回该请求结果
缓存的到期时间
headerValue = headers.get("Expires");
if (headerValue != null) {
serverExpires = parseDateAsEpoch(headerValue);
}

// Last-Modified是服务器响应请求时,返回该资源文件在服务器最后被修改的时间
headerValue = headers.get("Last-Modified");
if (headerValue != null) {
lastModified = parseDateAsEpoch(headerValue);
}
//Etag是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成)
serverEtag = headers.get("ETag");

// Cache-Control 优先 Expires 字段,请求头包含 Cache-Control,计算缓存的ttl和softTtl
if (hasCacheControl) {
//新鲜度时间只跟maxAge有关
softExpire = now + maxAge * 1000;
// 最终过期时间分两种情况:如果mustRevalidate为true,即需要验证新鲜度,
那么直接跟新鲜度时间一样的,另一种情况是新鲜度时间 + 陈旧的响应时间 * 1000
finalExpire = mustRevalidate ? softExpire : softExpire + staleWhileRevalidate * 1000;
// 如果不包含Cache-Control头
} else if (serverDate > 0 && serverExpires >= serverDate) {
// 缓存失效时间的计算
softExpire = now + (serverExpires - serverDate);
// 最终过期时间跟新鲜度时间一致
finalExpire = softExpire;
}

Cache.Entry entry = new Cache.Entry();
entry.data = response.data;
entry.etag = serverEtag;
entry.softTtl = softExpire;
entry.ttl = finalExpire;
entry.serverDate = serverDate;
entry.lastModified = lastModified;
entry.responseHeaders = headers;
entry.allResponseHeaders = response.allHeaders;

return entry;
}

这里主要是对请求头的缓存字段进行解析,并对缓存的相关字段赋值,特别是过期时间的计算要考虑到不同缓存头部的区别,以及每个缓存请求头的含义;
上面讲到的 stale-while-revalidate 这个字段举个例子:

Cache-Control: max-age=600, stale-while-revalidate=30

这个响应表明当前响应内容新鲜时间为 600 秒,以及额外的 30 秒可以用来容忍过期缓存,服务器会将 max-agestale-while-revalidate 的时间加在一起作为潜在最长可容忍的新鲜度时间,所有的响应都由缓存提供;不过在容忍过期缓存时间内,先直接从缓存中获取响应返回给调用者,然后在静默的在后台向原始服务器发起一次异步请求,然后在后台静默的更新缓存内容。

这部分代码都是关于HTTP缓存的相关知识,我下面给出一些我参考引用的链接,大家可以去学习相关知识。

我们接下来继续看缓存的实现类 DiskBasedCache,将缓存文件直接缓存到指定目录下的硬盘上,我们首先看看构造方法:

1
2
3
4
5
6
7
8
public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes) {
mRootDirectory = rootDirectory;
mMaxCacheSizeInBytes = maxCacheSizeInBytes;
}

public DiskBasedCache(File rootDirectory) {
this(rootDirectory, DEFAULT_DISK_USAGE_BYTES);
}

构造方法做了两件事,指定硬盘缓存的文件夹以及缓存的大小,默认5M。
我们首先看看初始化方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public synchronized void initialize() {
//如果缓存文件夹不存在则创建文件夹
if (!mRootDirectory.exists()) {
if (!mRootDirectory.mkdirs()) {
VolleyLog.e("Unable to create cache dir %s", mRootDirectory.getAbsolutePath());
}
return;
}
File[] files = mRootDirectory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
try {
long entrySize = file.length();
CountingInputStream cis =
new CountingInputStream(
new BufferedInputStream(createInputStream(file)), entrySize);
try {
CacheHeader entry = CacheHeader.readHeader(cis);
// 初始化的时候更新缓存大小为文件大小
entry.size = entrySize;
// 将已经存在的缓存存入到映射表中
putEntry(entry.key, entry);
} finally {
// Any IOException thrown here is handled by the below catch block by design.
//noinspection ThrowFromFinallyBlock
cis.close();
}
} catch (IOException e) {
//noinspection ResultOfMethodCallIgnored
file.delete();
}
}
}
//将 key 和 CacheHeader 存入到 map 对象当中,然后更新当前字节数
private void putEntry(String key, CacheHeader entry) {
if (!mEntries.containsKey(key)) {
mTotalSize += entry.size;
} else {
CacheHeader oldEntry = mEntries.get(key);
mTotalSize += (entry.size - oldEntry.size);
}
mEntries.put(key, entry);
}

初始化这里首先判断了缓存文件夹是否存在,不存在就要新建文件夹,这个很好理解。如果存在了就会将原来的已经存在的文件夹依次读取并存入一个缓存映射表中,方便后续判断有无缓存,不用直接从磁盘缓存中去查找文件名判断有无缓存。一般每个请求都会有一个 CacheHeader,然后将存在的缓存头里的 size 重新赋值,初始化时大小为文件大小,存入数据为数据的大小。这里提一下 CacheHeader 是一个静态内部类,跟 CacheEntry 有点像,少了一个 byte[] data 数组,其中维护了缓存头部的相关字段,这样设计的原因是方便快速读取,合理利用内存空间,因为缓存的相关信息需要频繁读取,内存占用小,可以缓存到内存中,但是网络请求的响应数据是非常占地方的,很容易就占满空间了,需要单独存储到硬盘中。
我们看下存入的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
public synchronized void put(String key, Entry entry) {
//首先进行缓存剩余空间的大小判断
pruneIfNeeded(entry.data.length);
File file = getFileForKey(key);
try {
BufferedOutputStream fos = new BufferedOutputStream(createOutputStream(file));
CacheHeader e = new CacheHeader(key, entry);
//CacheHeader 写入到磁盘
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());
}
}

这个方法比较简单就是将响应数据以及缓存的头部信息写入到磁盘并且将头部缓存到内存中,我们看下当缓存空间不足,是怎么考虑缓存替换的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
 private void pruneIfNeeded(int neededSpace) {
//缓存当前已经使用的空间总字节数 + 待存入的文件字节数是否大于缓存的最大大小,
默认为5M,也可以自己指定,如果大于就要进行删除以前的缓存
if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes) {
return;
}

long before = mTotalSize;
// 删除的文件数量
int prunedFiles = 0;
long startTime = SystemClock.elapsedRealtime();

Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator();
//遍历mEntries中所有的缓存
while (iterator.hasNext()) {
Map.Entry<String, CacheHeader> entry = iterator.next();
CacheHeader e = entry.getValue();
//因为mEntries是一个访问有序的LinkedHashMap,经常访问的会被移动到末尾,
所以这里的思想就是 LRU 缓存算法
boolean deleted = getFileForKey(e.key).delete();
if (deleted) {
//删除成功过后减少当前空间的总字节数
mTotalSize -= e.size;
} else {
VolleyLog.d(
"Could not delete cache entry for key=%s, filename=%s",
e.key, getFilenameForKey(e.key));
}
iterator.remove();
prunedFiles++;
//最后判断当前的空间是否满足新存入申请的空间大小,满足就跳出循环
if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) {
break;
}
}
}

这里的缓存替换策略也很好理解,如果不加以限制,那么岂不是一直写入数据到磁盘,有很多不用的数据很快就把磁盘写满了,所以使用了 LRU 缓存替换算法。
接下来我们看看存储的 key,即缓存文件名生成方法:

1
2
3
4
5
6
private String getFilenameForKey(String key) {
int firstHalfLength = key.length() / 2;
String localFilename = String.valueOf(key.substring(0, firstHalfLength).hashCode());
localFilename += String.valueOf(key.substring(firstHalfLength).hashCode());
return localFilename;
}

首先将请求的 url 分成两部分,然后两部分分别求 hashCode,最后拼接起来。如果我们使用 volley 来请求数据,那么通常是同一个地址中后面的不一样,很多字符一样,那么这样做可以避免 hashCode 重复造成文件名重复,创造更多的差异,因为hashJava 中不是那么可靠,关于这个问题我们可以在这篇文章中找到解答面试后的总结
然后我们看下 get 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override
public synchronized Entry get(String key) {
CacheHeader entry = mEntries.get(key);
// 如果entry不存在,则返回null
if (entry == null) {
return null;
}
//获取缓存的文件
File file = getFileForKey(key);
try {
//这个类的作用是通过bytesRead记录已经读取的字节数
CountingInputStream cis =
new CountingInputStream(
new BufferedInputStream(createInputStream(file)), file.length());
try {
//从磁盘获取缓存的CacheHeader
CacheHeader entryOnDisk = CacheHeader.readHeader(cis);
//如果传递进来的key和磁盘缓存中CacheHeader的key不相等,那么从内存缓存中
移除这个缓存
if (!TextUtils.equals(key, entryOnDisk.key)) {
removeEntry(key);
return null;
}
//读取缓存文件中的http响应体内容,然后创建一个entry返回
byte[] data = streamToBytes(cis, cis.bytesRemaining());
return entry.toCacheEntry(data);
} finally {
cis.close();
}
} catch (IOException e) {
remove(key);
return null;
}
}

取数据首先从内存缓存中取出 CacheHeader,如果为 null 那么直接返回,接下来如果取到了缓存,那么直接从磁盘里读取 CacheHeader,如果存在两个 key 映射一个文件,那么就从内存缓存中移除这个缓存,最后将读取的文件组装成一个entry 返回。这里有个疑问就是什么时候存在两个 key 映射一个文件呢?我们知道每个内存缓存中的 key 是我们请求的 url,而磁盘缓存的文件名则是根据 keyhash 值计算得出,那么个人猜测有可能算出的文件名重复了,那么就会出现两个 key 对应一个文件,那么为了避免这种情况,需要先判断,出现了先从内存缓存移除,一般来说这种情况很少。

我们看看从 CountingInputStream 读取字节的方法

1
2
3
4
5
6
7
8
9
10
static byte[] streamToBytes(CountingInputStream cis, long length) throws IOException {
long maxLength = cis.bytesRemaining();
// 读取的字节数不能为负数,不能大于当前剩余的字节数,还有不能整型溢出
if (length < 0 || length > maxLength || (int) length != length) {
throw new IOException("streamToBytes length=" + length + ", maxLength=" + maxLength);
}
byte[] bytes = new byte[(int) length];
new DataInputStream(cis).readFully(bytes);
return bytes;
}

这个方法是从 CountingInputStream 读取响应头还有响应体,怎么实现分开读取的呢,因为文件缓存首先是缓存的CacheHeader,接下来会从总的字节数减去已经读取的字节数,那么剩下的字节数就是响应体了。读取响应头是依次读取的,首先会先读取魔数判断是否是写入的缓存,然后依次读取各个 CacheHeader 字段,最后剩下的就是响应体了,读取和写入的顺序要一致。

看一看缓存的清除方法:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public synchronized void clear() {
File[] files = mRootDirectory.listFiles();
if (files != null) {
for (File file : files) {
file.delete();
}
}
mEntries.clear();
mTotalSize = 0;
VolleyLog.d("Cache cleared.");
}

遍历磁盘的每一个缓存文件并删除,清除内存缓存,更新使 size 为0。
接下来看看使某一个缓存 key 无效的方法

1
2
3
4
5
6
7
8
9
10
11
@Override
public synchronized void invalidate(String key, boolean fullExpire) {
Entry entry = get(key);
if (entry != null) {
entry.softTtl = 0;
if (fullExpire) {
entry.ttl = 0;
}
put(key, entry);
}
}

这里主要对传入的 key 使缓存新鲜度无效,然后根据传入的第二个值是否为 true,如果为 true 那么所有缓存都过期,否则只是缓存新鲜度过期,这里对 softTtlttl 值置为0,判断缓存过期的时候自然就小于当前时间返回 true,达到了过期的目的,最后存入内存缓存和磁盘缓存当中。

接下来我们看看一个特殊的类 NoCache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class NoCache implements Cache {
@Override
public void clear() {}

@Override
public Entry get(String key) {
return null;
}

@Override
public void put(String key, Entry entry) {}

@Override
public void invalidate(String key, boolean fullExpire) {}

@Override
public void remove(String key) {}

@Override
public void initialize() {}
}

实现 Cache 接口,不做任何操作的缓存实现类,可将它作为 RequestQueue 的参数实现一个默认不缓存的请求队列,后续取到的缓存都为 null

三、缓存的使用

我们梳理下整个流程,看这几个方法的调用时期:

1
2
3
4
5
6
private static RequestQueue newRequestQueue(Context context, Network network) {
File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR);
RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheDir), network);
queue.start();
return queue;
}

首先这里调用了 DiskBasedCache 的构造方法,缓存默认大小是5M,缓存文件夹为 volley
然后在 CacheDispatcherrun 方法里面实现了调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 @Override
public void run() {
if (DEBUG) VolleyLog.v("start new dispatcher");
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

// Make a blocking call to initialize the cache.
mCache.initialize();

while (true) {
try {
processRequest();
} catch (InterruptedException e) {
// We may have been interrupted because it was time to quit.
if (mQuit) {
Thread.currentThread().interrupt();
return;
}
VolleyLog.e(
"Ignoring spurious interrupt of CacheDispatcher thread; "
+ "use quit() to terminate it");
}
}
}

这个类前面我们分析过,发起网络请求的时候会启动五个线程,一个缓存请求线程,四个网络请求分发的线程。首先在缓存线程里执行了缓存的初始化,如果关闭了应用那么重新发起请求的时候原来的缓存会重新缓存到到内存中。

然后在 CacheDispatcher 里发起请求之前首先会从磁盘缓存获取缓存的内容:

1
Cache.Entry entry = mCache.get(request.getCacheKey());

然后在 NetworkDispatcher 的请求到数据并缓存到根据 url 生成的缓存键的磁盘缓存中,缓存键默认是 url

1
2
3
4
if (request.shouldCache() && response.cacheEntry != null) {
mCache.put(request.getCacheKey(), response.cacheEntry);
request.addMarker("network-cache-written");
}

四、 总结

Volley 的缓存机制分析完毕了,可以看出 Volley 缓存设计考虑了很多细节,对各种缓存头的解析,将请求的响应和缓存头的相关信息缓存到磁盘缓存,缓存头的信息也缓存到内存缓存,将二者很好的联系起来,便于读取和查找缓存等一系列操作。缓存命中率、缓存的替换算法、缓存文件名的计算、使用接口抽象等设计都值得我们认真学习。

参考链接

Volley的缓存机制
Cache-Control
缓存最佳实践及 max-age 注意事项
Cache-Control扩展
面试后的总结