Android Volley 原始碼解析(二),探究快取機制

developerHaoz發表於2018-02-12

前言

在上一篇文章中,帶大家閱讀了 Volley 網路請求的執行流程,算是對 Volley 有了一個比較清晰的認識,從這篇文章開始,我們開始針對 Volley 的某個功能進行深入地分析,慢慢將 Volley 的各項功能進行全面把握。

我們先從快取這一塊的內容開始入手,不過今天的快取分析是是建立在上一篇原始碼分析的基礎上的,還沒有看過上一篇文章的朋友,建議先去閱讀 Android Volley 原始碼解析(一),網路請求的執行流程

一、Volley 快取的總體設計


在開始細節分析之前,我們先來看下 Volley 快取的設計,瞭解這個流程有助於我們對於快取細節的把握。Volley 提供了一個 Cache 作為快取的介面,封裝了快取的實體 Entry,以及一些常規的增刪查操作。

public interface Cache {

    Entry get(String key);

    void put(String key, Entry entry);

    void initialize();

    /**
     * 使快取中的 Entry 失效
     */
    void invalidate(String key, boolean fullExpire);

    void remove(String key);

    void clear();

    /**
     * 使用者快取的實體
     */
    class Entry {

        public byte[] data;

        public String etag;

        public long serverDate;

        public long lastModified;

        public long ttl;

        public long softTtl;

        public Map<String, String> responseHeaders = Collections.emptyMap();

        public List<Header> allResponseHeaders;

        /** 判斷 Entry 是否過期. */
        public boolean isExpired() {
            return this.ttl < System.currentTimeMillis();
        }

        /** 判斷 Entry 是否需要重新整理. */
        public boolean refreshNeeded() {
            return this.softTtl < System.currentTimeMillis();
        }
    }

}
複製程式碼

Entry 裡面主要是放網路響應的原始資料 data、跟快取相關的屬性以及對應的響應頭,作為快取的一個實體。Cache 的具體實現類是 DiskBaseCache,它實現了 Cache 介面,並實現了響應的方法,那我們就來看看 DiskBaseCache 的設計吧,我們先看下 DiskBaseCache 中的一個靜態內部類 CacheHeader.

    static class CacheHeader {

        long size;

        final String key;

        final String etag;

        final long serverDate;

        final long lastModified;

        final long ttl;

        final long softTtl;

        final List<Header> allResponseHeaders;

        private CacheHeader(String key, String etag, long serverDate, long lastModified, long ttl,
                           long softTtl, List<Header> allResponseHeaders) {
            this.key = key;
            this.etag = ("".equals(etag)) ? null : etag;
            this.serverDate = serverDate;
            this.lastModified = lastModified;
            this.ttl = ttl;
            this.softTtl = softTtl;
            this.allResponseHeaders = allResponseHeaders;
        }

        CacheHeader(String key, Entry entry) {
            this(key, entry.etag, entry.serverDate, entry.lastModified, entry.ttl, entry.softTtl,
                    getAllResponseHeaders(entry));
            size = entry.data.length;
        }
    }
複製程式碼

DiskBaseCache 的設計很巧妙,它在內部放入了一個靜態內部類 CacheHeader,我們可以發現這個類跟 Cache 的 Entry 非常像,是不是會覺得好像有點多餘,Volley 之所以要這樣設計,主要是為了快取的合理性。我們知道每一個應用都是有一定記憶體限制的,程式佔用了過高的記憶體就容易出現 OOM(Out of Memory),如果每一個請求都原封不動的把所有的資訊都快取到記憶體中,這樣是非常佔記憶體的。

我們可以發現 CacheHeader 和 Entry 最大的區別,其實就是是否有 byte[] data 這個屬性,data 代表網路響應的後設資料,是返回的內容中最佔地方的東西,所以 DiskBaseCache 重新抽象了一個不包含 data 的 CacheHeader,並將其快取到記憶體中,而 data 部分便儲存在磁碟快取中,這樣就能最大程度的利用有限的記憶體空間。程式碼如下:

    BufferedOutputStream fos = new BufferedOutputStream(createOutputStream(file));
    CacheHeader e = new CacheHeader(key, entry);
    boolean success = e.writeHeader(fos);
    // 將 entry.data 寫入磁碟中
    fos.write(entry.data);
    fos.close();
    // 將 Cache 快取到記憶體中
    putEntry(key, e);
複製程式碼

二、DiskBaseCache 的具體實現


看完了 Volley 的快取設計,我們接著看 DiskBaseCache 的具體實現。

2.1 初始化快取

  // 記憶體快取的目錄
  private final File mRootDirectory;

  public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes) {
      mRootDirectory = rootDirectory;
      mMaxCacheSizeInBytes = maxCacheSizeInBytes;
  }

  @Override
  public synchronized void initialize() {
      // 如果 mRootDirectroy 不存在,則進行建立
      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;
      }
      // 遍歷 mRootDirectory 中的所有檔案
      for (File file : files) {
          try {
              long entrySize = file.length();
              CountingInputStream cis = new CountingInputStream(
                      new BufferedInputStream(createInputStream(file)), entrySize);
              // 將對應的檔案快取到記憶體中
              CacheHeader entry = CacheHeader.readHeader(cis);
              entry.size = entrySize;
              putEntry(entry.key, entry);
          } catch (IOException e) {
              file.delete();
          }
      }
  }
複製程式碼

通過外部傳入的 rootDirectory 和 maxCacheSizeInBytes 構造 DiskBaseCache 的例項,mRootDirectory 代表我們記憶體快取的目錄,maxCacheSizeInBytes 代表磁碟快取的大小,預設是 5M。如果 mRootDirectory 為 null,則進行建立,然後將 mRootDirectory 中的所有檔案進行記憶體快取。

2.2 put() 方法的實現

    @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);
            boolean success = e.writeHeader(fos);
            fos.write(entry.data);
            fos.close();
            putEntry(key, e);
            return;
        } catch (IOException e) {
        }
    }

    private void pruneIfNeeded(int neededSpace) {
        // 如果記憶體還夠用,就直接 return.
        if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes) {
            return;
        }

        long before = mTotalSize;
        int prunedFiles = 0;

        Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator();
        // 遍歷所有的檔案,開始進行刪除檔案
        while (iterator.hasNext()) {
            Map.Entry<String, CacheHeader> entry = iterator.next();
            CacheHeader e = entry.getValue();
            boolean deleted = getFileForKey(e.key).delete();
            if (deleted) {
                mTotalSize -= e.size;
            } 
            iterator.remove();
            prunedFiles++;
            
            // 如果刪除檔案後,儲存空間已經夠用了,就停止迴圈
            if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) {
                break;
            }
        }
    }
複製程式碼

可以看到 Volley 的程式碼實現是相當完善的,在新增快取之前,先呼叫 pruneIfNeed() 方法進行記憶體空間的判斷和處理,如果不進行限制的話,記憶體佔用將無限制的增大,最後到達 SD 卡容量時,會發生無法寫入的異常(因為儲存空間滿了)。

這裡有一點要補充一下,Volley 在快取方面,主要是使用了 LRU(Least Recently Used)演算法,LRU 演算法是最近最少使用演算法,它的核心思想是當快取滿時,優先淘汰那些近期最少使用的快取物件。主要的演算法原理是把最近使用的物件用強引用的方式(即我們平常使用的物件引用方式)儲存在 LinkedHashMap 中,當快取滿時,把最近最少使用的物件從記憶體中移除。有關 LRU 演算法,可以看下這篇文章:徹底解析 Android 快取機制 —— LruCache

在進行記憶體空間的判斷之後,便將 entry.data 儲存在磁碟中,將 CacheHeader 快取在記憶體中,這樣 DiskBaseCache 的 put() 方法就完成了。

2.3 get() 方法的實現

既然是快取功能,必然有用於進行快取的 key,我們來看下 Volley 的快取 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;
    }
複製程式碼

Volley 的快取 key 的生成方法還是很騷的,將網路請求的 Url 分成兩半,然後將這兩部分的 hashCode 拼接成快取 key。Volley 之所以要這樣做,主要是為了儘量避免 hashCode 重複造成的檔名重複,求兩次 hashCode 都與另外一個 Url 相同的概率比只求一次要小很多,不過概率小不代表不存在,但是 Java 在計算 hashCode 的速度是非常快的,這應該是 Volley 在權衡了安全性和效率之後做出的決定,這個思想是很值得我們學習的。

    @Override
    public synchronized Entry get(String key) {
        CacheHeader entry = mEntries.get(key);
        if (entry == null) {
            return null;
        }
        File file = getFileForKey(key);
        try {
            CountingInputStream cis = new CountingInputStream(
                    new BufferedInputStream(createInputStream(file)), file.length());
            try {
                CacheHeader entryOnDisk = CacheHeader.readHeader(cis);
                if (!TextUtils.equals(key, entryOnDisk.key)) {
                    // 一個檔案可能對映著兩個不同的 key,儲存在不同的 Entry 中
                    removeEntry(key);
                    return null;
                }
                byte[] data = streamToBytes(cis, cis.bytesRemaining());
                return entry.toCacheEntry(data);
            } finally {
                cis.close();
            }
        } catch (IOException e) {
            remove(key);
            return null;
        }
    }
複製程式碼

我們在上面說道,Volley 將響應的 data 放在磁碟中,將 CacheHeader 快取在記憶體中,而 get() 方法其實就是這個過程的逆過程,先通過 key 從 mEntries 從取出 CacheHeader,如果為 null,就直接返回 null,否則通過 key 來獲取磁碟中的 data,並通過 entry.toCacheEntry(data) 將 CacheHeader 和 data 拼接成完整的 Entry 然後進行返回。

三、DiskBaseCache 在 Volley 中的使用


看完了 DiskBaseCache 的具體實現,我們最後看下 DiskBaseCache 在 Volley 中是怎麼使用的,這樣就能把 Volley 的快取機制全部串聯起來了。

3.1 DiskBaseCache 的構建

    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;
    }
複製程式碼

應該還記得 Volley 的基本使用方法吧,當時我們第一步就是使用 Volley.newRequestQueue() 來建立一個 RequestQueue,這也是一切的起點。可以看到我們先通過 context.getCacheDir() 獲取快取路徑,然後建立我們快取所需的目錄 cacheDir,這其實就是在 DiskBaseCache 中的 mRootDirectory,然後將其傳入 DiskBaseCache 只有一個引數的構造器中,建立了 DiskBaseCache 的例項,預設的記憶體快取空間是 5M.

    private static final int DEFAULT_DISK_USAGE_BYTES = 5 * 1024 * 1024;

    public DiskBasedCache(File rootDirectory) {
        this(rootDirectory, DEFAULT_DISK_USAGE_BYTES);
    }
複製程式碼

3.2 initialize() 方法的呼叫

public class CacheDispatcher extends Thread {

    @Override
    public void run() {
        Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
        mCache.initialize();

        while (true) {
            try {
                processRequest();
            } catch (InterruptedException e) {
            }
        }
    }
}
複製程式碼

initialize() 是在 CacheDispatcher 中的 run 方法進行呼叫的,CacheDispatcher 是處理快取佇列中請求的執行緒。例項化 DiskBaseCache 之後,便在 while(true) 這個無線的迴圈當中,不斷地等請求的到來,然後執行請求。

3.3 put() 方法的呼叫

public class NetworkDispatcher extends Thread {

    private void processRequest() throws InterruptedException {
        Request<?> request = mQueue.take();

        try {
            NetworkResponse networkResponse = mNetwork.performRequest(request);
            Response<?> response = request.parseNetworkResponse(networkResponse);

            if (request.shouldCache() && response.cacheEntry != null) {
                mCache.put(request.getCacheKey(), response.cacheEntry);
                request.addMarker("network-cache-written");
            }
    }
}
複製程式碼

可以看到 put() 方法是在 NetworkDispatcher 中進行呼叫的,NetworkDispatcher 是一個執行網路請求的執行緒,從請求佇列中取出 Request,然後執行請求,如果 Request 是需要被快取的(預設情況下是必須被快取的)而且 response 的 cacheEntry 不為 null,就呼叫 DiskBaseCache 的 put() 方法將 Entry 進行快取。

3.4 get() 方法的呼叫

public class CacheDispatcher extends Thread {

    @Override
    public void run() {
        mCache.initialize();
        while (true) {
            try {
                processRequest();
            } catch (InterruptedException e) {
            }
        }
    }

    private void processRequest() throws InterruptedException {
        final Request<?> request = mCacheQueue.take();
        // 呼叫 get() 方法獲取 Entry
        Cache.Entry entry = mCache.get(request.getCacheKey());

        if (entry.isExpired()) {
            request.setCacheEntry(entry);
            mNetworkQueue.put(request);
            return;
        }

        Response<?> response = request.parseNetworkResponse(
                new NetworkResponse(entry.data, entry.responseHeaders));

        if (!entry.refreshNeeded()) {
            mDelivery.postResponse(request, response);
        } 
}
複製程式碼

我們在上面說到 DiskBaseCache 的 initialize() 方法是在 CacheDispatcher 中的 run() 方法中呼叫,其實 get() 方法也是一樣的,在 while(true) 裡面無限迴圈,當有請求到來時,便先根據請求的 Url 拿出對應的快取在記憶體中的 Entry,然後對 Entry 進行一些判斷和處理,最後將其構建成 Response 回撥出去。

小結

在呼叫 Volley.newRequestQueue() 方法獲取 RequestQueue 的時候,構建 DiskBaseCache 例項,在 CacheDispatcher 的 run() 方法中呼叫 DiskBaseCache 的 initialize() 方法初始化 DiskBaseCache,在 NetworkDispatcher 的 run() 方法中,在執行請求的時候,呼叫 DiskBaseCache 的 put() 方法將其快取到記憶體中,然後在 CaheDispatcher 的 run() 方法中執行請求的時候呼叫 DiskBaseCache 的 get() 方法構建相應的 Response,最後將其分發出去。

相關文章