Volley源码分析

摘要

Volley 是 Google 推出的 Android 异步网络请求框架和图片加载框架。在 Google I/O 2013 大会上发布。

类关系图


图中红色圈内的部分,组成了 Volley 框架的核心,围绕 RequestQueue 类,将各个功能点以组合的方式结合在了一起。各个功能点也都是以接口或者抽象类的形式提供。
红色圈外面的部分,在 Volley 源码中放在了 toolbox 包中,作为 Volley 为各个功能点提供的默认的具体实现。
通过类图我们看出, Volley 有着非常好的拓展性。通过各个功能点的接口,我们可以给出自定义的,更符合我们需求的具体实现。

核心功能流程图

需要注意的地方

一个网络请求可能会有两次结果回调

当进行网络请求时,先会判断缓存,当缓存还未过期,但是需要刷新时,volley会先将缓存回调(第一次):CacheDispatcher#run()

然后再次发起一个网络请求,若请求到的内容有变化时,会再次回调(第二次)。当然,如果请求到的内容没有变化(the server returned 304),则不会进行第二次回调。因为第一次将缓存回调时已经将请求标记为回调过的:NetworkDispatcher#run()

1
2
3
4
if (networkResponse.notModified && request.hasHadResponseDelivered()) {
request.finish("not-modified");
continue;
}

缓存规则

volley默认使用内存缓存(但是内存缓存只针对图片加载,使用LruCache)和磁盘缓存。

判断缓存是否过期、是否需要刷新

在NetworkDispatcher#run()中,存在以下调用

1
Response<?> response = request.parseNetworkResponse(networkResponse);

在CacheDispatcher#run()中,存在以下调用

1
2
Response<?> response = request.parseNetworkResponse(
new NetworkResponse(entry.data, entry.responseHeaders));

该调用request#parseNetworkResponse(…)会调用HttpHeaderParser#parseCacheHeaders(response)方法构建缓存实体response.cacheEntry。

该方法是判断缓存是否过期、是否需要刷新的关键代码。(详见注释)

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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
public class HttpHeaderParser {
/** 通过网络响应中的缓存控制 Header 和 Body 内容,构建缓存实体。
*
* 1.没有处理Last-Modify首部,而是处理存储了Date首部,并在后续的新鲜度验证时,
* 使用Date来构建If-Modified-Since。 这与 Http 1.1 的语义有些违背。
* 2.计算过期时间,Cache-Control 首部优先于 Expires 首部。
* Extracts a {@link Cache.Entry} from a {@link NetworkResponse}.
*
* @param response The network response to parse headers from
* @return a cache entry for the given response, or null if the response is not cacheable.
*/
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) {
// 根据 Date 首部,获取响应生成时间
serverDate = parseDateAsEpoch(headerValue);
}
// 获取响应提的Cache缓存策略
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();
// 如果 Header 的 Cache-Control 字段含有no-cache或no-store表示不缓存
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.startsWith("stale-while-revalidate=")) {
// 是过了缓存时间后还可以继续使用缓存的时间,单位是秒
// 所以真正的缓存时间是“max-age=” + “stale-while-revalidate=”的总时间
// 但如果有“must-revalidate”或者“proxy-revalidate”字段则过了缓存时间缓存就立即请求服务器
try {
staleWhileRevalidate = Long.parseLong(token.substring(23));
} catch (Exception e) {
}
} else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
// 过了缓存时间就立刻请求服务器
mustRevalidate = true;
}
}
}
// 缓存有效期的时间点,和Cache-Control意思一致,为兼容HTTP1.0和HTTP1.1才会使用该字段
// 如果有Cache-Control,优先使用Cache-Control
headerValue = headers.get("Expires");
if (headerValue != null) {
serverExpires = parseDateAsEpoch(headerValue);
}
// 服务器最后修改的时间
headerValue = headers.get("Last-Modified");
if (headerValue != null) {
lastModified = parseDateAsEpoch(headerValue);
}
// 根据 ETag 首部,获取响应实体标签。
// 该字段是服务器资源的唯一标识符,与"Last-Modified"配合使用
// 因为"Last-Modified"只能精确到秒,如果"ETag"与服务器一致,再判断"Last-Modified"
// 防止一秒内服务器多次修改而导致数据不准确的问题
serverEtag = headers.get("ETag");
// Cache-Control takes precedence over an Expires header, even if both exist and Expires
// is more restrictive.
// 根据 Cache-Control 和 Expires 首部,计算出缓存的过期时间,和缓存的新鲜度时间
if (hasCacheControl) {
// 最精确的缓存过期时间softExpire=现在的时间+缓存可使用的最大时间
softExpire = now + maxAge * 1000;
// 最终缓存过期时间finalExpire,还要判断mustRevalidate参数
// 如果mustRevalidate=false
// 最终缓存过期时间finalExpire=最精确的缓存过期时间softExpire+缓存过期后还可以继续使用的时间
finalExpire = mustRevalidate
? softExpire
: softExpire + staleWhileRevalidate * 1000;
} else if (serverDate > 0 && serverExpires >= serverDate) {
// Default semantic for Expire header in HTTP specification is softExpire.
// 如果 响应生成时间点>0 && 缓存有效期的时间点>=响应生成时间点
// 最精确的缓存过期时间softExpire=现在的时间+(缓存有效期的时间点-响应生成时间点)
softExpire = now + (serverExpires - serverDate);
// 最终缓存过期时间finalExpire=最精确的缓存过期时间softExpire
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;
return entry;
}
/** 解析时间,将 RFC1123 的时间格式,解析成 epoch 时间
* Parse date in RFC1123 format, and return its value as epoch
*/
public static long parseDateAsEpoch(String dateStr) {
try {
// Parse date in RFC1123 format if this header contains one
return DateUtils.parseDate(dateStr).getTime();
} catch (DateParseException e) {
// Date in invalid format, fallback to 0
return 0;
}
}
/** 解析编码集,在 Content-Type 首部中获取编码集,如果没有找到,返回defaultCharset
* Retrieve a charset from headers
*
* @param headers An {@link java.util.Map} of headers
* @param defaultCharset Charset to return if none can be found
* @return Returns the charset specified in the Content-Type of this header,
* or the defaultCharset if none can be found.
*/
public static String parseCharset(Map<String, String> headers, String defaultCharset) {
String contentType = headers.get(HTTP.CONTENT_TYPE);
if (contentType != null) {
String[] params = contentType.split(";");
for (int i = 1; i < params.length; i++) {
String[] pair = params[i].trim().split("=");
if (pair.length == 2) {
if (pair[0].equals("charset")) {
return pair[1];
}
}
}
}
return defaultCharset;
}
/** 解析编码集,在 Content-Type 首部中获取编码集,如果没有找到,默认返回 ISO-8859-1
* Returns the charset specified in the Content-Type of this header,
* or the HTTP default (ISO-8859-1) if none can be found.
*/
public static String parseCharset(Map<String, String> headers) {
return parseCharset(headers, HTTP.DEFAULT_CONTENT_CHARSET);
}
}
针对”Expires”、”Last-Modified”、”ETag”的详细解释
  1. Expires:缓存过期时间,用来指定资源到期的时间,是服务器端的具体的时间点。
  2. Last-Modified:服务器端文件的最后修改时间,需要和cache-control共同使用,是检查服务器端资源是否更新的一种方式。当浏览器再次进行请求时,会向服务器传送If-Modified-Since报头,询问Last-Modified时间点之后资源是否被修改过。如果没有修改,则返回码为304,使用缓存;如果修改过,则再次去服务器请求资源,返回码和首次请求相同为200,资源为服务器最新资源。
  3. ETag:根据实体内容生成一段hash字符串,标识资源的状态,由服务端产生。浏览器会将这串字符串传回服务器,验证资源是否已经修改,如果没有修改,过程如下:

    使用ETag可以解决Last-modified存在的一些问题:
    a、某些服务器不能精确得到资源的最后修改时间,这样就无法通过最后修改时间判断资源是否更新
    b、如果资源修改非常频繁,在秒以下的时间内进行修改,而Last-modified只能精确到秒
    c、一些资源的最后修改时间改变了,但是内容没改变,使用ETag就认为资源还是没有修改的。

硬盘缓存的缓存实现&替换策略

缓存实体类
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
public static class Entry {
/** 请求返回的数据(Body实体)
* The data returned from cache. */
public byte[] data;
/** Http响应首部中用于缓存新鲜度验证的 ETag
* ETag for cache coherency. */
public String etag;
/** Http响应首部中的响应产生时间
* Date of this response as reported by the server. */
public long serverDate;
/** 缓存内容最后一次修改的时间
* The last modified date for the requested object. */
public long lastModified;
/** 缓存的过期时间
* TTL for this record. */
public long ttl;
/** 缓存的新鲜时间
* Soft TTL for this record. */
public long softTtl;
/** 响应的Headers
* Immutable response headers as received from server; must be non-null. */
public Map<String, String> responseHeaders = Collections.emptyMap();
/** 判断缓存是否过期,过期缓存不能继续使用
* True if the entry is expired. */
public boolean isExpired() {
return this.ttl < System.currentTimeMillis();
}
/** 判断缓存是否新鲜,不新鲜的缓存需要发到服务端做新鲜度的检测
* True if a refresh is needed from the original data source. */
public boolean refreshNeeded() {
return this.softTtl < System.currentTimeMillis();
}
}

缓存抽象接口Cache,DiskBasedCache才是最终的实现类。

硬盘缓存DiskBasedCache实现

DiskBasedCache通过构造方法指定硬盘缓存的目录,指定硬盘缓存的大小(默认为5M)。
讲几个重要方法:

  • initialize()方法
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
/** 初始化,遍历Disk缓存系统,将缓存文件中的CacheHeader和key存储到Map对象(内存)中
* Initializes the DiskBasedCache by scanning for all files currently in the
* specified root directory. Creates the root directory if necessary.
*/
@Override
public synchronized void initialize() {
if (!mRootDirectory.exists()) {
// 硬盘缓存目录不存在直接返回即可
if (!mRootDirectory.mkdirs()) {
VolleyLog.e("Unable to create cache dir %s", mRootDirectory.getAbsolutePath());
}
return;
}
// 获取硬盘缓存目录所有文件集合.每个HTTP请求结果对应一个文件.
File[] files = mRootDirectory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
BufferedInputStream fis = null;
try {
fis = new BufferedInputStream(new FileInputStream(file));
// 进行对象反序列化
CacheHeader entry = CacheHeader.readHeader(fis);
// 将文件的大小赋值给entry.size,单位字节
entry.size = file.length();
// 在内存中维护一张硬盘<key,value>映射表
putEntry(entry.key, entry);
} catch (IOException e) {
if (file != null) {
file.delete();
}
} finally {
try {
if (fis != null) {
fis.close();
}
} catch (IOException ignored) { }
}
}
}
  • put()方法
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
/** 将数据存入缓存内。先检查缓存文件是否会满,会则先删除缓存中部分数据,然后再新建缓存文件。
* Puts the entry with the specified key into the cache.
*/
@Override
public synchronized void put(String key, Entry entry) {
pruneIfNeeded(entry.data.length);
// 根据hash值生成的文件名
File file = getFileForKey(key);
try {
BufferedOutputStream fos = new BufferedOutputStream(new FileOutputStream(file));
// 创建一个新的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());
}
}
  • get()方法

    先从Map对象中根据key判断是否缓存过,若缓存过,从缓存文件中得到数据,否则返回null。

硬盘缓存DiskBasedCache的替换策略

在put()方法中我们可以看到调用pruneIfNeeded()方法(详见注释)

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
/** 缓存替换策略:删除硬盘缓存中的一些内容以达到小于规定的mMaxCacheSizeInBytes
* Prunes the cache to fit the amount of bytes specified.
* @param neededSpace The amount of bytes we are trying to fit into the cache. 尝试指定的字节数
*/
private void pruneIfNeeded(int neededSpace) {
if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes) {
return;
}
if (VolleyLog.DEBUG) {
VolleyLog.v("Pruning old cache entries.");
}
long before = mTotalSize;
int prunedFiles = 0;
long startTime = SystemClock.elapsedRealtime();
Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CacheHeader> entry = iterator.next();
CacheHeader e = entry.getValue();
// 根据一定条件缓存
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++;
// 当硬盘大小满足可以存放新的HTTP请求结果时,停止删除操作
if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) {
break;
}
}
if (VolleyLog.DEBUG) {
VolleyLog.v("pruned %d files, %d bytes, %d ms",
prunedFiles, (mTotalSize - before), SystemClock.elapsedRealtime() - startTime);
}
}

重点是getFilenameForKey()方法(详见注释)

1
2
3
4
5
6
7
8
9
10
11
12
/** 因为hashcode并不唯一。把一个key分成两部分,目的是为了尽可能避免hashcode重复造成的文件名重复。
* 求两次hashcode与另一个url的hashcode重复的概率比求一次hashcode重复的概率小。
* Creates a pseudo-unique filename for the specified cache key.
* @param key The key to generate a file name for.
* @return A pseudo-unique filename.
*/
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;
}

想看更详细的注释可以参考2016年11月24日 GMT+8 下午1:20:57->Volley缓存机制注释

参考

  1. Volley 源码解析
  2. 浅谈Web缓存
  3. Volley 源码解析
  4. Volley HTTP 缓存规则
  5. Volley缓存说明——一个请求两次回调