Volley网络缓存详解
我为何会研究Volley中的网络缓存?
因为我这里做一个网络操作,由于服务器端实现实在是太垃圾,一个接口获取数据需要一两秒,然后产品需求是一次性需要查七次,那么一次弄下来就会耗时10秒左右,但是如果有看过Volley代码的朋友都会知道,Volley是默认4个线程同时从队列中take出Request进行网络请求的,理论上肯定是7个请求,只用分两次就会全部发出去的,但是实际操作中会发现只会有一两个线程在串行的进行网络请求。所以这里我花了一天的时间来仔细研究了Volley中的网络缓存操作。颇有收获,与大家分享一二,如有不完善或错误,请指出一起讨论。
如何解决本来应该并行但是确串行的网络请求?
先说一下解决方案,一共两种方法解决:
第一种:给Request对象设置不允许缓存,调用setShouldCache(boolean shouldCache)方法,Request中mShouldCache值默认为true,设置为false即可。
第二种:重写Request中的getCacheKey()方法,为每一个请求设置不同的CacheKey,因为如果不重写的话,这个方法默认返回url作为CacheKey,而我之前每一个请求都是访问的同一个url,所以这里修改之后能够解决这个问题,当然为什么需要修改,请继续往下看。
这里还有问题
上面说到的解决方法,第一种是不允许缓存,第二种是允许缓存,但是你实际上如果使用第二种方法的话,你会发现实际上你的缓存数据根本没用的,也就是说,其实Volley给你写到本地缓存上面了,但是读取出来是无效的。这里因为Cache.Entry.ttl值为0,这个值是缓存有效期,单位为毫秒,Entry中对缓存是否有效的判断实现代码如下:
public boolean isExpired() {
return this.ttl < System.currentTimeMillis();
}
如果小于当前系统时间,则为失效。这个ttl的值是Volley根据发出网络请求的时间加上Response中header里面cache-control: max-age的值相加得出的有效期,原本来说这里的cache-control应该是从服务器返回的,但是实际应用中,很难找到后台开发人员专门为前端返回相应的字段,所以这里本地也应该要有解决方法,其实你应该也猜到需要怎么做了,我们这里只要在本地得到Response后去写入缓存这里的过程中改变Response的header值即可。具体方法最简单应该是在初始化Volley队列的时候传入自定义的HttpStack,继承BasicNetwork,重写方法如下:
@Override
public NetworkResponse performRequest(Request<?> request) throws VolleyError {
long maxAge = ;
try {
String age = request.getHeaders().get("max-age");
if (!AntiDataUtils.isEmpty(age)) {
maxAge = Long.parseLong(request.getHeaders().get("max-age"));
}
} catch (Exception e) {
e.printStackTrace();
}
NetworkResponse response = super.performRequest(request);
List<Header> headerList = new ArrayList<>();
headerList.addAll(response.allHeaders);
headerList.add(new Header("Cache-Control", "max-age=" + maxAge));
return new NetworkResponse(response.statusCode, response.data, response.notModified, response.networkTimeMs, headerList);
}
Volley网络缓存流程
首先需要了解Volley整体结构,Volley的整体结构简单而巧妙,通过两个同步队列实现多线程的网络请求。使用过Volley的朋友都应该了解,在使用Volley进行网络请求之前,需要初始化一个RequestQueue,在RequestQueue对象中主要成员如下:
//取缓存的队列,如果当前网络请求需要缓存,则添加到此队列
private final PriorityBlockingQueue<Request<?>> mCacheQueue =
new PriorityBlockingQueue<>();
//取网络结果的队列,如果当前网络请求不需要缓存则直接添加到此队列
private final PriorityBlockingQueue<Request<?>> mNetworkQueue =
new PriorityBlockingQueue<>();
//发送网络请求的线程数组,这代表支持多线程网络,可以通过构造方法传入数组长度,即为支持的线程个数,默认为4
private final NetworkDispatcher[] mDispatchers;
//取缓存的线程
private CacheDispatcher mCacheDispatcher;
如果说是不用缓存的网络请求,在放进队列之后,这里的流程很简单,在NetworkDispatcher中会马上被take出,取得网络结果后给出回调,如果是需要缓存的网络请求,这里就会比较复杂一点。
首先是CacheDispatcher会先从mCacheQueue中take一个request,然后根据CacheKey(如果没有则默认为url)读取本地缓存:
- 如果没有缓存文件,则Entry==null,在这时,会将request直接再放入mNetworkQueue,NetworkDispatcher会马上去take出来进行正常的网络请求。
- 如果有缓存文件:
- 如果缓存有效,则回调缓存结果
- 如果缓存过期,则将request放入 mNetworkQueue。
这里是最简单的逻辑,但是似乎会有问题,如果我同时放10个CacheKey(url)相同的request进来,那按照这样的做法,如果这10个request都是可以缓存的数据,比如说查询一个当日有效的结果,同时查询几次,其实这里除了第一个需要从网络取数据之外,其余的都可以等待第一个request取到结果并写入缓存后直接从缓存中读取,这样效率明显会高很多。这样一个常识性的操作,难道google大佬没想到吗?那答案肯定是否定的,大佬终究是大佬,早就实现这一点了:
//取得缓存数据
Cache.Entry entry = mCache.get(request.getCacheKey());
if (entry == null) {
request.addMarker("cache-miss");
// Cache miss,send off to the network dispatcher.
if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) {
mNetworkQueue.put(request);
}
return;
}
// If it is completely expired, just send it to the network.
if (entry.isExpired()) {
request.addMarker("cache-hit-expired");
request.setCacheEntry(entry);
if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) {
mNetworkQueue.put(request);
}
return;
}
这里是CacheDispatcher中的操作,这里的关键就在于mWaitingRequestManager.maybeAddToWaitingRequests(request)这一句代码,如果之前还有相同CacheKey的request正在请求,那么就把当前request堆积起来,堆积起来之后呢?当然是等待上一个request完成啊。NetworkDispatcher中有如下代码
private void processRequest() throws InterruptedException {
// Take a request from the queue.
Request<?> request = mQueue.take();
long startTimeMs = SystemClock.elapsedRealtime();
try {
request.addMarker("network-queue-take");
// If the request was cancelled already, do not perform the
// network request.
if (request.isCanceled()) {
request.finish("network-discard-cancelled");
request.notifyListenerResponseNotUsable();
return;
}
addTrafficStatsTag(request);
// Perform the network request.
NetworkResponse networkResponse = mNetwork.performRequest(request);
request.addMarker("network-http-complete");
// If the server returned 304 AND we delivered a response already,
// we're done -- don't deliver a second identical response.
if (networkResponse.notModified && request.hasHadResponseDelivered()) {
request.finish("not-modified");
request.notifyListenerResponseNotUsable();
return;
}
// Parse the response here on the worker thread.
Response<?> response = request.parseNetworkResponse(networkResponse);
request.addMarker("network-parse-complete");
// Write to cache if applicable.
// TODO: Only update cache metadata instead of entire record for 304s.
// if (request.shouldCache() && response.cacheEntry != null) {
// mCache.put(request.getCacheKey(), response.cacheEntry);
// request.addMarker("network-cache-written");
// }
// Post the response back.
request.markDelivered();
mDelivery.postResponse(request, response);
request.notifyListenerResponseReceived(response);
} catch (VolleyError volleyError) {
volleyError.setNetworkTimeMs(SystemClock.elapsedRealtime() - startTimeMs);
parseAndDeliverNetworkError(request, volleyError);
request.notifyListenerResponseNotUsable();
} catch (Exception e) {
VolleyLog.e(e, "Unhandled exception %s", e.toString());
VolleyError volleyError = new VolleyError(e);
volleyError.setNetworkTimeMs(SystemClock.elapsedRealtime() - startTimeMs);
mDelivery.postError(request, volleyError);
request.notifyListenerResponseNotUsable();
}
}
注意这一句代码:request.notifyListenerResponseNotUsable();可以看到在很多地方都调用了这一句,那么这个方法的具体实现是怎样的呢?
/**
* Notify NetworkRequestCompleteListener that the network request did not result in a response
* which can be used for other, waiting requests.
*/
/* package */ void notifyListenerResponseNotUsable() {
NetworkRequestCompleteListener listener;
synchronized (mLock) {
listener = mRequestCompleteListener;
}
if (listener != null) {
listener.onNoUsableResponseReceived(this);
}
}
这里回调了listener.onNoUsableResponseReceived(this);这个方法的实现在那里呢?想必很容易能够猜到了,就在CacheDispatcher中,最后来看看CacheDispatcher中比较重要的两个方法:
/**
* No valid response received from network, release waiting requests.
*/
@Override
public synchronized void onNoUsableResponseReceived(Request<?> request) {
String cacheKey = request.getCacheKey();
List<Request<?>> waitingRequests = mWaitingRequests.remove(cacheKey);
if (waitingRequests != null && !waitingRequests.isEmpty()) {
if (VolleyLog.DEBUG) {
VolleyLog.v(
"%d waiting requests for cacheKey=%s; resend to network",
waitingRequests.size(), cacheKey);
}
Request<?> nextInLine = waitingRequests.remove();
mWaitingRequests.put(cacheKey, waitingRequests);
nextInLine.setNetworkRequestCompleteListener(this);
try {
mCacheDispatcher.mNetworkQueue.put(nextInLine);
} catch (InterruptedException iex) {
VolleyLog.e("Couldn't add request to queue. %s", iex.toString());
// Restore the interrupted status of the calling thread (i.e. NetworkDispatcher)
Thread.currentThread().interrupt();
// Quit the current CacheDispatcher thread.
mCacheDispatcher.quit();
}
}
}
/**
* For cacheable requests, if a request for the same cache key is already in flight, add it
* to a queue to wait for that in-flight request to finish.
*
* @return whether the request was queued. If false, we should continue issuing the request
* over the network. If true, we should put the request on hold to be processed when the
* in-flight request finishes.
*/
private synchronized boolean maybeAddToWaitingRequests(Request<?> request) {
String cacheKey = request.getCacheKey();
// Insert request into stage if there's already a request with the same cache key
// in flight.
if (mWaitingRequests.containsKey(cacheKey)) {
// There is already a request in flight. Queue up.
List<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
if (stagedRequests == null) {
stagedRequests = new ArrayList<Request<?>>();
}
request.addMarker("waiting-for-response");
stagedRequests.add(request);
mWaitingRequests.put(cacheKey, stagedRequests);
if (VolleyLog.DEBUG) {
VolleyLog.d("Request for cacheKey=%s is in flight, putting on hold.", cacheKey);
}
return true;
} else {
// Insert 'null' queue for this cacheKey, indicating there is now a request in
// flight.
mWaitingRequests.put(cacheKey, null);
request.setNetworkRequestCompleteListener(this);
if (VolleyLog.DEBUG) {
VolleyLog.d("new request, sending to network %s", cacheKey);
}
return false;
}
}
这里可以看到在最开始取到Entry的时候,就调用过maybeAddToWaitingRequests()方法进行判断,并且在方法中保存了CacheKey相同的request,然后当第一个request请求完成的时候回调onNoUsableResponseReceived()方法,如果存在有效缓存则取出缓存进行结果回调,如果无有效缓存,则再继续进行网络请求。
最后也就解释了,为何使用Volley对同一个接口同时请求多次的时候网络线程并非并行,而是串行的问题了。
总结
其实每一个开源库里面的实现都是非常具有参考价值的,有时候看起来很简单,但是里面很多技术细节是需要我们注意和学习的,比如说上面代码中有一个叫做PriorityBlockingQueue的类,可能大部分人也不知道这个类的特点是什么,或许根本就没注意过这一个类。而我说来也很惭愧,如果不是遇见这个bug,可能我也不会刨根究底的来查看Volley的源码,以前看了个大概,觉得已经很了解这个库了,觉得这个库的实现是很简单的,然而很多细节的地方是真的没有注意到的。这其实也就暴露出我们绝大部分开发者的一个通病,拿着一个库只会去查看怎么使用,而很少去查看它的功能在代码中是如何实现的。