Volley 原始碼解析之快取機制

哆啦miss_A夢發表於2019-01-06

一、前言

前面我們分析了volley的網路請求的相關流程以及圖片載入的原始碼,如果沒有看過的話可以閱讀一下,接下來我們分析volley的快取,看看是怎麼處理快取超時、快取更新以及快取的整個流程,掌握volley的快取設計,對整個volley的原始碼以及細節一個比較完整的認識。

二、 原始碼分析

我們首先看看Cache這個快取介面:

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頭的方法:

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,將快取檔案直接快取到指定目錄下的硬碟上,我們首先看看構造方法:

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是一個靜態內部類,跟CacheEntry有點像,少了一個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重複造成檔名重複,創造更多的差異,因為hashJava中不是那麼可靠,關於這個問題我們可以在這篇文章中找到解答面試後的總結
然後我們看下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,而磁碟快取的檔名則是根據keyhash值計算得出,那麼個人猜測有可能算出的檔名重複了,那麼就會出現兩個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那麼所有快取都過期,否則只是快取新鮮度過期,這裡對softTtlttl值置為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。 然後在CacheDispatcherrun方法裡面實現了呼叫:

 @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擴充套件
面試後的總結

相關文章