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的源碼,以前看了個大概,覺得已經很了解這個庫了,覺得這個庫的實作是很簡單的,然而很多細節的地方是真的沒有注意到的。這其實也就暴露出我們絕大部分開發者的一個通病,拿着一個庫隻會去檢視怎麼使用,而很少去檢視它的功能在代碼中是如何實作的。