Android:跟著實戰專案學快取策略之DiskLruCache詳談

iamxiarui_發表於2018-07-02

##寫在前面

之前花費大心思更新了一篇《Android:跟著實現專案學快取策略之LruCache詳談》,本來是準備用專案實戰的方式分享一下快取策略的使用。但是由於篇幅過長,DiskLruCache也比較複雜,所以決定把DiskLruCache抽取出來單獨講。本文仍然是在上一篇文章中新聞小專案基礎上來說明DiskLurCache的用法,以及與LruCache的不同。文章的目錄如下:

  • 寫在前面
  • 遺留問題
  • DiskLruCache詳解
    • 基本介紹
    • 實戰運用
  • 快取策略對比與總結
  • 結語
  • 專案原始碼

##遺留問題

上一篇文章中已經將圖片成功的快取到記憶體中,當所有圖片快取完成後,再次滑動就已經不需要重新載入圖片了。但是注意看下面這張圖的現象:

存回收,快取隨之回收

可以看到,成功快取後確實在應用內再次滑動就不需要載入了,但是如果此時我們kill掉APP,重新開啟的話,仍然是需要載入的。這是為什麼呢?

答案很顯然,因為LruCache是將檔案型別快取到記憶體中,隨著APP中Activity的銷燬,記憶體也會隨之回收。也就將記憶體中的快取回收掉,再次開啟APP的時候,記憶體中找不到快取,當然需要重新載入了。

所以如何才能快取到儲存裝置中呢?下面就來詳細說說。

##DiskLruCache詳解

###基本介紹

DiskLruCache與LruCache不同,它不是Android中已經封裝好的類,所以 想要使用的話需要從網上下載。關於下載這個類,我也是費了不少功夫,大家如果想嘗試的話,可以直接Copy我這個專案中的 com.libcore.io 包下的所有檔案即可,這個就不多說了。下面這是它的一個基本定義,也是開發藝術探索中任老師說的:

DiskLruCache用於實現儲存裝置快取,即磁碟快取,它通過將快取物件寫入檔案系統從而實現快取的效果。

注意,重點是將快取物件寫入檔案系統,大家可能不太理解,不過不用擔心,後面會說到。先來它的建立、新增、獲取方法。

####1、建立

與LruCache不同的是,它不能通過構造方法的方式來建立,它的建立方法是通過DiskLruCache類的一個靜態方法 open 來建立。具體如下:

public static DiskLruCache open(File directory,int appVersion,int valueCount,long maxSize)

其中有四個引數,很好理解:

  • File directory:這是快取檔案在磁碟中的儲存路徑,這是必須要指定的,一般來說是選擇SD卡上的快取目錄,APP解除安裝後自動刪除快取。
  • int appVersion:這個是版本號,用處不大,正常設定為1即可。
  • int valueCount:這個是單個節點所對應的資料個數,其實就是一個key對應多少個value,正常設定為1即可,這樣key和value一一對應,方便查詢。
  • long maxSize:這個就是快取的總大小,很好理解。

這樣看來,建立一個DiskLruCache就至少要指定檔案的目錄與快取大小。所以建立方式如下:

//DiskLruCache
private DiskLruCache mDiskCache;
//指定磁碟快取大小
private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50;//50MB
//得到快取檔案
File diskCacheDir = getDiskCacheDir(mContext, "diskcache");
//如果檔案不存在 直接建立
if (!diskCacheDir.exists()) {
	diskCacheDir.mkdirs();
}
mDiskCache = DiskLruCache.open(diskCacheDir, 1, 1,DISK_CACHE_SIZE);
複製程式碼
/**
 * 建立快取檔案
 *
 * @param context  上下文物件
 * @param filePath 檔案路徑
 * @return 返回一個檔案
 */
public File getDiskCacheDir(Context context, String filePath) {
    boolean externalStorageAvailable = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
    final String cachePath;
    if (externalStorageAvailable) {
        cachePath = context.getExternalCacheDir().getPath();
    } else {
        cachePath = context.getCacheDir().getPath();
    }
    return new File(cachePath + File.separator + filePath);
}
複製程式碼

注意,下面的方法是一個工具方法,用來返回一個檔案,難度不大。這樣就建立了一個DiskLruCache。

####2、設定key

一般來說,需要用到快取的地方都是需要聯網下載的,所以這個key最好的就是需要下載的檔案的Url。但是Url中可能有一些特殊字元,所以最好的方式就是將其轉換成MD5值。

MD5是電腦保安領域廣泛使用的一種雜湊函式,用以提供訊息的完整性保護。

說簡單點,就是一種加密演算法,將一串資訊轉成定長的一串字元。這裡只是防止Url中的特殊字元影響正常使用。下面給出如何轉成MD5,這是《Android開發藝術探索》中的原始碼,可以當成工具方法,直接用即可。

/**
 * 將URL轉換成key
 *
 * @param url 圖片的URL
 * @return
 */
private String hashKeyFormUrl(String url) {
    String cacheKey;
    try {
        final MessageDigest mDigest = MessageDigest.getInstance("MD5");
        mDigest.update(url.getBytes());
        cacheKey = bytesToHexString(mDigest.digest());
    } catch (NoSuchAlgorithmException e) {
        cacheKey = String.valueOf(url.hashCode());
    }
    return cacheKey;
}

/**
 * 將Url的位元組陣列轉換成雜湊字串
 *
 * @param bytes URL的位元組陣列
 * @return
 */
private String bytesToHexString(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < bytes.length; i++) {
        String hex = Integer.toHexString(0xFF & bytes[i]);
        if (hex.length() == 1) {
            sb.append('0');
        }
        sb.append(hex);
    }
    return sb.toString();
}
複製程式碼

####3、新增

與LruCache不同的是,LruCache內部實現是Map,新增直接用put即可;而DiskLruCache是將檔案儲存到檔案中,所以需要通過檔案輸出流的形式將檔案寫入到檔案系統中。但是僅僅寫入是不夠的,必須通過Editor物件來提交。它是快取物件的編輯物件。它是根據檔案的Url對應的key的 edit() 方法獲取。

值得注意的是,如果返回的Editor物件正在被編輯,那麼返回的結果不為null。反之如果返回null,表示編輯物件可用。所以我們在使用前必須判斷一下返回的Editor物件是否為空。如果不為空的話,那就通過Editor物件的 commi 方法來提交寫入操作,當然你也可以通過 abort 方法來撤銷寫入操作。

說了這麼多,歸納來說DiskLruCache的新增操作分為三步:

  • 通過檔案的Url將檔案寫入檔案系統
  • 通過Url對應的key來得到一個不為空的Editor物件
  • 通過這個Editor物件來對寫入操作進行提交或者撤銷操作

好了,現在來看具體的實現程式碼,程式碼邏輯應該很清晰:

/**
 * 將URL中的圖片儲存到輸出流中
 *
 * @param urlString    圖片的URL地址
 * @param outputStream 輸出流
 * @return 輸出流
 */
private boolean downloadUrlToStream(String urlString, OutputStream outputStream) {
    HttpURLConnection urlConnection = null;
    BufferedOutputStream out = null;
    BufferedInputStream in = null;
    try {
        final URL url = new URL(urlString);
        urlConnection = (HttpURLConnection) url.openConnection();
        in = new BufferedInputStream(urlConnection.getInputStream(), IO_BUFFER_SIZE);
        out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);
        int b;
        while ((b = in.read()) != -1) {
            out.write(b);
        }
        return true;
    } catch (final IOException e) {
        e.printStackTrace();
    } finally {
        if (urlConnection != null) {
            urlConnection.disconnect();
        }
        try {
            if (out != null) {
                out.close();
            }
            if (in != null) {
                in.close();
            }
        } catch (final IOException e) {
            e.printStackTrace();
        }
    }
    return false;
}
複製程式碼
/**
 * 將Bitmap寫入快取
 *
 * @param url
 * @return
 * @throws IOException
 */
private Bitmap addBitmapToDiskCache(String url) throws IOException {
    //如果當前執行緒是在主執行緒 則異常
    if (Looper.myLooper() == Looper.getMainLooper()) {
        throw new RuntimeException("can not visit network from UI Thread.");
    }
    if (mDiskCache == null) {
        return null;
    }

    //設定key,並根據URL儲存輸出流的返回值決定是否提交至快取
    String key = hashKeyFormUrl(url);
    DiskLruCache.Editor editor = mDiskCache.edit(key);
    if (editor != null) {
        OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
        if (downloadUrlToStream(url, outputStream)) {
            editor.commit();
        } else {
            editor.abort();
        }
        mDiskCache.flush();
    }
    return getBitmapFromDiskCache(url);
}
複製程式碼

####4、獲取

相比較於新增操作,獲取操作很簡單。當然還是通過key來獲取。有了key,可以通過DiskLruCache的get方法獲取到一個 Snapshot 物件,再通過這個物件的 getInputStream 方法得到檔案的輸入流,得到了輸出流當然可以獲取流中的檔案了。

所以概括起來,獲取快取中檔案的步驟也有三個:

  • 通過key來得到一個Snapshot物件
  • 通過Snapshot得到一個檔案輸入流
  • 通過檔案輸入流得到檔案物件

具體的程式碼實現如下:

/**
 * 從快取中取出Bitmap
 *
 * @param url 圖片的URL
 * @return 返回Bitmap物件
 * @throws IOException
 */
private Bitmap getBitmapFromDiskCache(String url) throws IOException {
    //如果當前執行緒是主執行緒 則異常
    if (Looper.myLooper() == Looper.getMainLooper()) {
        Log.w(TAG, "load bitmap from UI Thread, it's not recommended!");
    }
    //如果快取中為空  直接返回為空
    if (mDiskCache == null) {
        return null;
    }

    //通過key值在快取中找到對應的Bitmap
    Bitmap bitmap = null;
    String key = hashKeyFormUrl(url);
    //通過key得到Snapshot物件
    DiskLruCache.Snapshot snapShot = mDiskCache.get(key);
    if (snapShot != null) {
        //得到檔案輸入流
        FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(DISK_CACHE_INDEX);
        FileDescriptor fileDescriptor = fileInputStream.getFD();
        bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    }
    return bitmap;
}
複製程式碼

####5、補充

如果大家仔細看了上面的程式碼會發現不管是快取的新增還是獲取方法中,都有下面這段程式碼:

//如果當前執行緒是主執行緒 則異常
    if (Looper.myLooper() == Looper.getMainLooper()) {
        Log.w("DiskLruCache", "load bitmap from UI Thread, it's not recommended!");
    }	
複製程式碼

這是因為這兩個方法都不能在主執行緒中呼叫,所以需要檢查一下,如果不是主執行緒的話,直接丟擲異常。這也算是一個細節吧。

###實戰運用

好了,通過上面的分塊講解,大家應該對DiskLruCache有了基本的認識了。現在我們就對上一個專案新增這樣的快取策略。同樣的,為了方便大家對比檢視,我仍然把這些方法封裝到DiskCacheUtil類。

給出程式碼之前,我們也大致梳理一下思路:

  • 首先要初始化DiskLruCache,這個毋庸置疑
  • 其次就需要提供DiskLruCache的新增、獲取方法。
  • 而這個新增獲取方法需要用到key值,所以要將Url轉成MD5值。
  • 剩下的就是通過AsyncTask來展示圖片了,並在展示過程中新增到快取中。
  • 當然不要忘了,前一篇所說的ListView滑動停止載入,靜止才能載入的優化。

下面直接給出程式碼,程式碼比較長,但是冷靜下來,按照前面說的邏輯來看是不是很清晰呢?

/**
 * 利用DiskLruCache來快取圖片
 */
public class DiskCacheUtil {
    private Context mContext;

    private ListView mListView;
    private Set<NewsAsyncTask> mTaskSet;

    //定義DiskLruCache
    private DiskLruCache mDiskCache;
    //指定磁碟快取大小
    private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50;//50MB
    //IO快取流大小
    private static final int IO_BUFFER_SIZE = 8 * 1024;
    //快取個數
    private static final int DISK_CACHE_INDEX = 0;
    //快取檔案是否建立
    private boolean mIsDiskLruCacheCreated = false;

    public DiskCacheUtil(Context context, ListView listView) {
        this.mListView = listView;
        mTaskSet = new HashSet<>();
        mContext = context.getApplicationContext();
        //得到快取檔案
        File diskCacheDir = getDiskCacheDir(mContext, "diskcache");
        //如果檔案不存在 直接建立
        if (!diskCacheDir.exists()) {
            diskCacheDir.mkdirs();
        }
        if (getUsableSpace(diskCacheDir) > DISK_CACHE_SIZE) {
            try {
                mDiskCache = DiskLruCache.open(diskCacheDir, 1, 1,
                        DISK_CACHE_SIZE);
                mIsDiskLruCacheCreated = true;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 通過非同步任務的方式載入資料
     *
     * @param iv  圖片的控制元件
     * @param url 圖片的URL
     */
    public void showImageByAsyncTask(ImageView iv, final String url) throws IOException {
        //從快取中取出圖片
        Bitmap bitmap = getBitmapFromDiskCache(url);
        //如果快取中沒有,則需要從網路中下載
        if (bitmap == null) {
            iv.setImageResource(R.mipmap.ic_launcher);
        } else {
            //如果快取中有 直接設定
            iv.setImageBitmap(bitmap);
        }
    }

    /**
     * 將一個URL轉換成bitmap物件
     *
     * @param urlStr 圖片的URL
     * @return
     */
    public Bitmap getBitmapFromURL(String urlStr) {
        Bitmap bitmap;
        InputStream is = null;

        try {
            URL url = new URL(urlStr);
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            is = new BufferedInputStream(connection.getInputStream(), IO_BUFFER_SIZE);
            bitmap = BitmapFactory.decodeStream(is);
            connection.disconnect();
            return bitmap;
        } catch (java.io.IOException e) {
            e.printStackTrace();
        } finally {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * 將URL中的圖片儲存到輸出流中
     *
     * @param urlString    圖片的URL地址
     * @param outputStream 輸出流
     * @return 輸出流
     */
    private boolean downloadUrlToStream(String urlString, OutputStream outputStream) {
        HttpURLConnection urlConnection = null;
        BufferedOutputStream out = null;
        BufferedInputStream in = null;
        try {
            final URL url = new URL(urlString);
            urlConnection = (HttpURLConnection) url.openConnection();
            in = new BufferedInputStream(urlConnection.getInputStream(), IO_BUFFER_SIZE);
            out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);
            int b;
            while ((b = in.read()) != -1) {
                out.write(b);
            }
            return true;
        } catch (final IOException e) {
            e.printStackTrace();
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }
            try {
                if (out != null) {
                    out.close();
                }
                if (in != null) {
                    in.close();
                }
            } catch (final IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    /**
     * 載入從start到end的所有的Image
     *
     * @param start
     * @param end
     */
    public void loadImages(int start, int end) throws IOException {
        for (int i = start; i < end; i++) {
            String url = NewsAdapter.urls[i];
            //從快取中取出圖片
            Bitmap bitmap = getBitmapFromDiskCache(url);
            //如果快取中沒有,則需要從網路中下載
            if (bitmap == null) {
                NewsAsyncTask task = new NewsAsyncTask(url);
                task.execute(url);
                mTaskSet.add(task);
            } else {
                //如果快取中有 直接設定
                ImageView imageView = (ImageView) mListView.findViewWithTag(url);
                imageView.setImageBitmap(bitmap);
            }
        }
    }

    /**
     * 停止所有當前正在執行的任務
     */
    public void cancelAllTask() {
        if (mTaskSet != null) {
            for (NewsAsyncTask task : mTaskSet) {
                task.cancel(false);
            }
        }
    }

    /*--------------------------------DiskLruCaChe的實現-----------------------------------------*/

    /**
     * 建立快取檔案
     *
     * @param context  上下文物件
     * @param filePath 檔案路徑
     * @return 返回一個檔案
     */
    public File getDiskCacheDir(Context context, String filePath) {
        boolean externalStorageAvailable = Environment
                .getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
        final String cachePath;
        if (externalStorageAvailable) {
            cachePath = context.getExternalCacheDir().getPath();
        } else {
            cachePath = context.getCacheDir().getPath();
        }

        return new File(cachePath + File.separator + filePath);
    }

    /**
     * 得到當前可用的空間大小
     *
     * @param path 檔案的路徑
     * @return
     */
    private long getUsableSpace(File path) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
            return path.getUsableSpace();
        }
        final StatFs stats = new StatFs(path.getPath());
        return (long) stats.getBlockSize() * (long) stats.getAvailableBlocks();
    }

    /**
     * 將URL轉換成key
     *
     * @param url 圖片的URL
     * @return
     */
    private String hashKeyFormUrl(String url) {
        String cacheKey;
        try {
            final MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(url.getBytes());
            cacheKey = bytesToHexString(mDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            cacheKey = String.valueOf(url.hashCode());
        }
        return cacheKey;
    }

    /**
     * 將Url的位元組陣列轉換成雜湊字串
     *
     * @param bytes URL的位元組陣列
     * @return
     */
    private String bytesToHexString(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(0xFF & bytes[i]);
            if (hex.length() == 1) {
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString();
    }

    /**
     * 將Bitmap寫入快取
     *
     * @param url
     * @return
     * @throws IOException
     */
    private Bitmap addBitmapToDiskCache(String url) throws IOException {
        //如果當前執行緒是在主執行緒 則異常
        if (Looper.myLooper() == Looper.getMainLooper()) {
            throw new RuntimeException("can not visit network from UI Thread.");
        }
        if (mDiskCache == null) {
            return null;
        }

        //設定key,並根據URL儲存輸出流的返回值決定是否提交至快取
        String key = hashKeyFormUrl(url);
        //得到Editor物件
        DiskLruCache.Editor editor = mDiskCache.edit(key);
        if (editor != null) {
            OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
            if (downloadUrlToStream(url, outputStream)) {
                //提交寫入操作
                editor.commit();
            } else {
                //撤銷寫入操作
                editor.abort();
            }
            mDiskCache.flush();
        }
        return getBitmapFromDiskCache(url);
    }
複製程式碼
    /**
     * 從快取中取出Bitmap
     *
     * @param url 圖片的URL
     * @return 返回Bitmap物件
     * @throws IOException
     */
    private Bitmap getBitmapFromDiskCache(String url) throws IOException {
        //如果當前執行緒是主執行緒 則異常
        if (Looper.myLooper() == Looper.getMainLooper()) {
            Log.w("DiskLruCache", "load bitmap from UI Thread, it's not recommended!");
        }
        //如果快取中為空  直接返回為空
        if (mDiskCache == null) {
            return null;
        }

        //通過key值在快取中找到對應的Bitmap
        Bitmap bitmap = null;
        String key = hashKeyFormUrl(url);
        //通過key得到Snapshot物件
        DiskLruCache.Snapshot snapShot = mDiskCache.get(key);
        if (snapShot != null) {
            //得到檔案輸入流
            FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(DISK_CACHE_INDEX);
            //得到檔案描述符
            FileDescriptor fileDescriptor = fileInputStream.getFD();
            bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor);
        }
        return bitmap;
    }

     /*--------------------------------DiskLruCaChe的實現-----------------------------------------*/
複製程式碼
    /*--------------------------------非同步任務AsyncTask的實現--------------------------------------*/
	    /**
	     * 非同步任務類
	     */
	    private class NewsAsyncTask extends AsyncTask<String, Void, Bitmap> {
	        private String url;
        public NewsAsyncTask(String url) {
            this.url = url;
        }

        @Override
        protected Bitmap doInBackground(String... params) {

            Bitmap bitmap = getBitmapFromURL(params[0]);
            //儲存到快取中
            if (bitmap != null) {
                try {
                    //寫入快取
                    addBitmapToDiskCache(params[0]);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return bitmap;
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            super.onPostExecute(bitmap);
            ImageView imageView = (ImageView) mListView.findViewWithTag(url);
            if (imageView != null && bitmap != null) {
                imageView.setImageBitmap(bitmap);
            }
            mTaskSet.remove(this);
        }
    }

    /*--------------------------------非同步任務AsyncTask的實現--------------------------------------*/
}
複製程式碼

最後不要忘了在自定義Adapter中呼叫DiskCache這個工具類,並把圖片載入方法換成DiskLruCache方式:

//第三種方式 通過非同步任務方式設定 且利用DiskLruCache儲存到磁碟快取中
    try {
        mDiskCacheUtil.showImageByAsyncTask(viewHolder.iconImage, iconUrl);
    } catch (IOException e) {
        e.printStackTrace();
    }
複製程式碼

好了,現在來看效果圖吧:

DiskLruCaChe

從圖中可以看出儘管退出了APP,但是重新開啟的時候,仍然不需要載入圖片,大功告成!

##快取策略對比與總結

好了,DiskLruCache也講完了。回顧之前的LruCache,同樣是Android中的快取策略。那它們之間有什麼不同呢?

  1. LruCache是Android中的已經封裝好的類,可以直接用。但是DiskLruCache需要匯入對應的包後,才能使用。
  2. LruCache實現的是記憶體快取,當APP被kill的時候,快取也隨之消失。而DiskLruCache實現的是磁碟快取,當APP被kill的時候,快取仍然不會消失。
  3. LruCache的內部實現是LinkedHashMap,也就是集合。所以新增獲取方式通過put與get就行了。而DiskLruCache是通過檔案流的形式來快取,所以新增獲取是通過輸入輸出流來實現。

大體也就也上三種主要的區別。

最後我想說的是,本專案是為了大家看起來方便,有對比性,所以把普通執行緒載入、LruCache載入、DiskLruCache載入分別封裝了不同的類。

但是在日常開發中,需要Bitmap的壓縮類與這幾種載入方式在一起封裝成一個大的類。就是大家常提到的 ImageLoader 。它專門用來處理Bitmap的載入。

這樣做的好處就是將三種載入方式結合,也就是大家常聽說的 三級快取機制 ,網上也有很多優秀的ImageLoader,當然大家也可以嘗試嘗試,自己寫出一個ImageLoader。

##結語

通過兩篇文章中的一個小小的實戰專案,終於把快取策略說完了。寫文章的過程中自己也是回顧了整個專案,受益匪淺。有些時候把一個東西用自己的話分享出來並且讓別人能聽懂,比自己學一個東西要難很多。所以覺得經常寫部落格,還是對知識的消化有點幫助的。

最後由於我水平有限,專案和文章中難免會有錯誤,歡迎大家指正與交流。

##專案原始碼

IamXiaRui - MoocNewsDemo


個人部落格:www.iamxiarui.com

原文連結:http://www.iamxiarui.com/?p=719

相關文章