前面咱们分析了volley
的网络请求的相关流程以及图片加载的源码,若是没有看过的话能够阅读一下,接下来咱们分析volley
的缓存,看看是怎么处理缓存超时、缓存更新以及缓存的整个流程,掌握volley
的缓存设计,对整个volley
的源码以及细节一个比较完整的认识。html
咱们首先看看Cache
这个缓存接口:git
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
头的方法:github
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-age 和 stale-while-revalidate 的时间加在一块儿做为潜在最长可容忍的新鲜度时间,全部的响应都由缓存提供;不过在容忍过时缓存时间内,先直接从缓存中获取响应返回给调用者,而后在静默的在后台向原始服务器发起一次异步请求,而后在后台静默的更新缓存内容。数组
这部分代码都是关于HTTP缓存的相关知识,我下面给出一些我参考引用的连接,你们能够去学习相关知识。缓存
咱们接下来继续看缓存的实现类DiskBasedCache
,将缓存文件直接缓存到指定目录下的硬盘上,咱们首先看看构造方法:bash
public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes) {
mRootDirectory = rootDirectory;
mMaxCacheSizeInBytes = maxCacheSizeInBytes;
}
public DiskBasedCache(File rootDirectory) {
this(rootDirectory, DEFAULT_DISK_USAGE_BYTES);
}
复制代码
构造方法作了两件事,指定硬盘缓存的文件夹以及缓存的大小,默认5M。
咱们首先看看初始化方法:服务器
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
是一个静态内部类,跟Cache
的Entry
有点像,少了一个byte[] data
数组,其中维护了缓存头部的相关字段,这样设计的缘由是方便快速读取,合理利用内存空间,由于缓存的相关信息须要频繁读取,内存占用小,能够缓存到内存中,可是网络请求的响应数据是很是占地方的,很容易就占满空间了,须要单独存储到硬盘中。
咱们看下存入的方法:网络
@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());
}
}
复制代码
这个方法比较简单就是将响应数据以及缓存的头部信息写入到磁盘而且将头部缓存到内存中,咱们看下当缓存空间不足,是怎么考虑缓存替换的:
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
,即缓存文件名生成方法:
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
重复形成文件名重复,创造更多的差别,由于hash
在Java
中不是那么可靠,关于这个问题咱们能够在这篇文章中找到解答面试后的总结
而后咱们看下get
方法:
@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
,而磁盘缓存的文件名则是根据key
的hash
值计算得出,那么我的猜想有可能算出的文件名重复了,那么就会出现两个key
对应一个文件,那么为了不这种状况,须要先判断,出现了先从内存缓存移除,通常来讲这种状况不多。
咱们看看从CountingInputStream
读取字节的方法
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
字段,最后剩下的就是响应体了,读取和写入的顺序要一致。
看一看缓存的清除方法:
@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
无效的方法
@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
那么全部缓存都过时,不然只是缓存新鲜度过时,这里对softTtl
和ttl
值置为0,判断缓存过时的时候天然就小于当前时间返回true
,达到了过时的目的,最后存入内存缓存和磁盘缓存当中。
接下来咱们看看一个特殊的类NoCache
:
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
。
咱们梳理下整个流程,看这几个方法的调用时期:
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
。 而后在CacheDispatcher
的run
方法里面实现了调用:
@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
里发起请求以前首先会从磁盘缓存获取缓存的内容:
Cache.Entry entry = mCache.get(request.getCacheKey());
复制代码
而后在NetworkDispatcher
的请求到数据并缓存到根据url
生成的缓存键的磁盘缓存中,缓存键默认是url
。
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扩展
面试后的总结