Volley 源碼解析之緩存機制

1、前言

前面咱們分析了volley的網絡請求的相關流程以及圖片加載的源碼,若是沒有看過的話能夠閱讀一下,接下來咱們分析volley的緩存,看看是怎麼處理緩存超時、緩存更新以及緩存的整個流程,掌握volley的緩存設計,對整個volley的源碼以及細節一個比較完整的認識。html

2、 源碼分析

咱們首先看看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是一個靜態內部類,跟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

3、緩存的使用

咱們梳理下整個流程,看這幾個方法的調用時期:

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");
}
複製代碼

4、 總結

volley 的緩存機制分析完畢了,能夠看出volley緩存設計考慮了不少細節,對各類緩存頭的解析,將請求的響應和緩存頭的相關信息緩存到磁盤緩存,緩存頭的信息也緩存到內存緩存,將兩者很好的聯繫起來,便於讀取和查找緩存等一系列操做。緩存命中率、緩存的替換算法、緩存文件名的計算、使用接口抽象等設計都值得咱們認真學習。

參考連接

Volley的緩存機制
Cache-Control
緩存最佳實踐及 max-age 注意事項
Cache-Control擴展
面試後的總結

相關文章
相關標籤/搜索