Android 图片缓存

LruCache 内存缓存

LruCache基本使用
//初始化 
mLruCache = new LruCache(100){
@Override
protected int sizeOf(@NonNull String key, @NonNull Bitmap value) {
     return value.getByteCount() / 1024/1024;
   }
};
//存
 mLruCache.put("key","value");
 //取
 mLruCache.get("key");
LruCache是如何实现最优算法的
public class LruCache {
private final LinkedHashMap map;
......
}

LruCache类第一行我们就可以知道原来LruCache是通过LinkedHashMap类来进行数据存储的。而LinkedHashMap类是HashMap的子类,通过学习我们知道LinkedHashMap在HashMap的基础上额外维护了一个链表,其内部已经内置了最优算法策略。只需要在初始化LinkedHashMap的时候(int initialCapacity,float loadFactor, boolean accessOrder)将accessOrder设置为true就可以开启最优算法策略(保持访问顺序)。
其中需要注意的是初始化时候LruCache中参数为最大存放的值,而重写的sizeOf需要和这个值是同一个单位。

那么LruCache是如何将LinkedHashMap的最优算法和缓存大小结合起来的呢?
我们来看一看put方法:

     public final V put(K key, V value) {
        if (key == null || value == null) {
            throw new NullPointerException("key == null || value == null");
        }

        V previous;
        synchronized (this) {
            putCount++;
            size += safeSizeOf(key, value);
            previous = map.put(key, value);
            if (previous != null) {
                size -= safeSizeOf(key, previous);
            }
        }

        if (previous != null) {
            entryRemoved(false, key, previous, value);
        }

        trimToSize(maxSize);
        return previous;
    }

put方法中首先针对key、value进行判空,然后将值put进map中,依据put的返回值(为空成功、不为空失败)进行处理,最后调用trimToSize方法。

   private void trimToSize(int maxSize) {
       while (true) {
           K key;
           V value;
           synchronized (this) {
               if (size < 0 || (map.isEmpty() && size != 0)) {
                   throw new IllegalStateException(getClass().getName()
                           + ".sizeOf() is reporting inconsistent results!");
               }

               if (size <= maxSize) {
                   break;
               }

               // BEGIN LAYOUTLIB CHANGE
               // get the last item in the linked list.
               // This is not efficient, the goal here is to minimize the changes
               // compared to the platform version.
               Map.Entry toEvict = null;
               for (Map.Entry entry : map.entrySet()) {
                   toEvict = entry;
               }
               // END LAYOUTLIB CHANGE

               if (toEvict == null) {
                   break;
               }

               key = toEvict.getKey();
               value = toEvict.getValue();
               map.remove(key);
               size -= safeSizeOf(key, value);
               evictionCount++;
           }

           entryRemoved(true, key, value, null);
       }
   }

从trimToSize源码中来看,当size小于最大值时跳过,而大于最大值时通过map.entrySet()获取链表的最后一项,遍历移除。从而达到最优的效果。

DiskLruCache磁盘缓存

我们在日常开发中经常会遇到加载多图OOM问题,而解决OOM问题最常用的技术就是使用LruCache技术。LruCache技术主要是管理内存中图片的存储与释放,一旦图片从内存中移除,那么再次使用的话就需要再次从网络上面加载,这非常耗费资源。针对此Google提供了一套磁盘缓存技术DiskLruCache(非Google官方编写,但获得官方认证),下面我们来学习下DiskLruCache相关技术。

DiskLruCache的使用

下面我们来学习一下DiskLruCache的使用步骤。首先我们要知道,DiskLruCache的构造方法是私有的,所以他不能直接new出来,如果我们需要创建它的实例,我们需要调用它的open方法:

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

open方法参数有四个,分别为缓存路径当前程序的版本号同一个key可以缓存的数量(通常是1)和缓存的最大字节数

首先是缓存路径通常存放在/sdcard/Android/data//cache这个路径下面,此路径是在sdk中既不影响内置存储又能够随apk卸载而删除避免脏数据的产生。手机有可能没有存储卡或者存储卡没有挂载,针对这种情况我们需要将缓存存放到内置存储上面。下面是兼容的代码:

public File getDiskCacheDir(Context context, String uniqueName) {
    String cachePath;
    if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
            || !Environment.isExternalStorageRemovable()) {
        cachePath = context.getExternalCacheDir().getPath();
    } else {
        cachePath = context.getCacheDir().getPath();
    }
    return new File(cachePath + File.separator + uniqueName);

可以看到,当SD卡存在或者SD卡不可被移除的时候,就调用getExternalCacheDir()方法来获取缓存路径,否则就调用getCacheDir()方法来获取缓存路径。前者获取到的就是/sdcard/Android/data//cache 这个路径,而后者获取到的是 /data/data//cache这个路径。
然后将获取的路径和uniqueName进行拼接。uniqueName是针对不同类型进行区分,比如bitmap或者其他缓存类型。
其次是版本号,我们可以通过PackageManager获取版本号代码如下:

 public static synchronized int getVersionCode(Context context) {
        try {
            PackageManager packageManager = context.getPackageManager();
            PackageInfo packageInfo = packageManager.getPackageInfo(
                    context.getPackageName(), 0);
            return packageInfo.versionCode;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return 0;
    }

需要注意的是,每当版本号改变,缓存路径下存储的所有数据都会被清除掉,因为DiskLruCache认为当应用程序有版本更新的时候,所有的数据都应该从网上重新获取。

第三个参数默认是1,最后一个参数通常传个10M就行,可依据自身情况来设置。
因此一个完整的open方法调用应该是:

File cacheDir = getDiskCacheDir(this,"bitmap");
if (!cacheDir.exists()){
   cacheDir.mkdirs();
}
try {
  DiskLruCache diskLrucache = DiskLruCache.open(cacheDir,getVersionCode(this),1,10*1024*1024);
} catch (IOException e) {
  e.printStackTrace();
}

首先获取存储路径,路径不存在则创建,然后将参数分别传入到open方法中。
此时我们有了DiskLruCache实例,就可以对缓存的数据进行操作了,操作包含读取 、写入还有删除等等,我们一一来学习。

写入操作

首先我们来看一下写入操作,为了将网络图片写入到磁盘中,我们需要先将其下载到本地,那么我们来写一下下载的操作:

public boolean downloadImage(String path, OutputStream outputStream){
        HttpURLConnection httpURLConnection = null;
        BufferedOutputStream bufferedOutput = null;
        BufferedInputStream bufferedInput = null;
        try {
            URL url = new URL(path);
            httpURLConnection = (HttpURLConnection) url.openConnection();
            InputStream inputStream = httpURLConnection.getInputStream();
            bufferedInput = new BufferedInputStream(inputStream);
            bufferedOutput = new BufferedOutputStream(outputStream);
            int  read;
            while ((read = bufferedInput.read()) != -1){
                bufferedOutput.write(read);
            }
            return true;
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != httpURLConnection) {
                httpURLConnection.disconnect();
            }

            try {
                if (null != bufferedInput) {
                    bufferedInput.close();
                }
                if (null != bufferedOutput) {
                    bufferedOutput.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

        }
        return false;
    }

下载的操作是访问网络路径的网址,将其通过OutputStream写入到本地。有了这个方法后,我们就可以通过DiskLruCache进行写入了,写入的操作通过DiskLruCache.Editor这个类完成。DiskLruCache.Editor的构造方式是私有的,所以它是通过DiskLruCache的edit方法来获取的:

DiskLruCache.Editor editor = mDiskLruCache.edit(MD5Util.encodeKey(path));

考虑到特殊字符等情况路径URL不能直接作为key来使用,通常情况下我们将对URL采用MD5加密的方式来获得最终的key。如下图所示:

    /**
     * 将key进行MD5编码
     */
    public static String encodeKey(String key) {
        String cacheKey;
        try {
            final MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(key.getBytes());
            cacheKey = bytesToHexString(mDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            cacheKey = String.valueOf(key.hashCode());
        }
        return cacheKey;
    }

    private static 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();
    }

拿到DiskLruCache.Editor实例,我们就可以通过newOutputStream获取到OutputStream,这时候我们调用之前的下载方法完成下载保存工作,示例代码如下:


OutputStream outputStream = editor.newOutputStream(0);
if (downloadImage(path, outputStream)){
 editor.commit();
} else {
 editor.abort();
}

我们之前调用open方法传入的第三个参数valueCount为1,也就是说一个key对应一条数据,因此newOutputStream参数传0,意思就是获取key对应值的列表的第一个数据。
downloadImage返回值为true说明下载成功了,因此我们需要commit一下,返回false则代表失败要abort掉。
所以最终保存下载资源的的代码为:

public void download(){
      new Thread(new Runnable() {
          @Override
          public void run() {
              try {
                  DiskLruCache.Editor editor = mDiskLruCache.edit(MD5Util.encodeKey(path));
                  if (null != editor) {
                      OutputStream outputStream = editor.newOutputStream(0);
                      if (downloadImage(path, outputStream)){
                          editor.commit();
                      } else {
                          editor.abort();
                      }
                  }
                  mDiskLruCache.flush();
              } catch (IOException e) {
                  e.printStackTrace();
              }
          }
      }).start();
    }
读取操作

文件下载成功了,现在我们来看一下读取的操作。读取操作是通过Snapshot这个类来完成,那么我们来看一下获取这个类实例的代码:

String key = MD5Util.encodeKey(path);
 DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);

获取到MD5加密过后的Key,然后通过DiskLruCache的get方法就可以获取。获取到Snapshot 对象后通过getInputStream方法获取到InputStream流,然后使用 BitmapFactory.decodeStream获取到Bitmap对象,拿到
Bitmap对象后可以直接设置给ImageView。读取的操作完成,下面来看看读取的完整代码:

String key = MD5Util.encodeKey(path);
DiskLruCache.Snapshot snapShot = null;
try {
      snapShot = mDiskLruCache.get(key);
      if (snapShot != null) {
      InputStream is = snapShot.getInputStream(0);
      Bitmap bitmap = BitmapFactory.decodeStream(is);
      mImg.setImageBitmap(bitmap);
    }
 } catch (IOException e) {
     e.printStackTrace();
}
删除操作

学完了存储和读取后,我们来看一看删除操作。删除操作是通过DiskLruCache的remove方法来完成的:

public synchronized boolean remove(String key) throws IOException

代码很熟悉了,依据路径生成一个key,然后调用remove方法完成删除操作。

String key = MD5Util.encodeKey(path);
try {
 mDiskLruCache.remove(key);
} catch (IOException e) {
 e.printStackTrace();
}

其实大多是情况并不需要我们自己去调用这个方法,因为在我们调用open方法的时候传入了最大值,当存储大小达到最大值时DiskLruCache最自己删除掉不活跃的资源,其内部最优算法和LruCache一样都是依据LinkedHashMap来实现的。只有在资源过期的时候如果我们需要加载最新的资源,这时候我们才会主动去调用这个方法。

其余操作

除了存储、读取和删除外,DiskLruCache还提供一些其他的api。下面我们来简单的过一下。

1.size()

获取存储路径下的总字节数,以byte为单位,如果应用需要展示缓存大小,可以通过此方法展示出来。

2.flush()

这个方法用于将内存中的操作记录同步到日志文件(也就是journal文件)当中。这个方法非常重要,因为DiskLruCache工作的核心就是要依赖journal文件中的内容。之前写入缓存操作的时候有调用过一次这个方法,但其实并不是每次写入缓存都要调用一次flush()方法的,频繁地调用只会额外增加同步journal文件的时间。比较标准的做法就是在Activity的onPause()方法中进行一次调用就行了。

3.close()

该方法是关闭DiskLruCache的方法,和open方法相对应。一旦调用了该方法,后续将不能再调用DiskLruCache任何操作数据的方法,所以改方法应该在销毁方法中执行。

4.delete()

删除所有数据的方法,如果需要清除缓存功能,可以调用此方法。

journal文件学习

journal文件是DiskLruCache的核心,路径在缓存目录下。所有DiskLruCache相关的操作都绕不开它。下面我们来看下该文件的数据:

libcore.io.DiskLruCache
1
1
1

DIRTY 9ada4c71e7d4e9dadd46620ea05a83d7
CLEAN 9ada4c71e7d4e9dadd46620ea05a83d7 31335
READ 9ada4c71e7d4e9dadd46620ea05a83d7
REMOVE 9ada4c71e7d4e9dadd46620ea05a83d7

第一行libcore.io.DiskLruCache代表我们使用DiskLruCache技术。
第二行1代码缓存的版本。
第三行1代表我们的app版。
第四行1是open传入的第三个参数,代表一个key对应几个值
第五行是空白行

以上就是journal文件的头信息。

在往后文件中的每行都是缓存条目状态的记录。每行包含以空格分隔的值:状态,键和可选的数据大小值。
下面我们来看下各个状态的含义:
DIRTY:DIRTY行代表正在创建和更新的条目,每个DIRTY动作后面都应该有CLEAN行为或者REMOVE行为,如果没有则代表可能是需要删除的临时文件。
CLEAN :CLEAN行是记录已成功发布并可以读取的缓存条目,后面会加入每个值的长度。
READ :READ行是记录Lru访问的行为。
REMOVE :REMOVE行记录已删除的条目。

DiskLruCache源码解读

学完了journal文件相关信息,那么我们来学习一下DiskLruCache是如何结合journal完成缓存处理的。
DiskLruCache通过open方法获取实例,那么我们来看一下open方法:

open方法
    /**
     * Opens the cache in {@code directory}, creating a cache if none exists
     * there.
     *
     * @param directory  a writable directory
     * @param appVersion appVersion
     * @param valueCount the number of values per cache entry. Must be positive.
     * @param maxSize    the maximum number of bytes this cache should use to store
     * @return DiskLruCache
     * @throws IOException if reading or writing the cache directory fails
     */
    public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
            throws IOException {
        ...
        // Prefer to pick up where we left off.
        DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        if (cache.journalFile.exists()) {
            try {
                cache.readJournal();
                cache.processJournal();
                return cache;
            } catch (IOException journalIsCorrupt) {
                System.out
                        .println("DiskLruCache "
                                + directory
                                + " is corrupt: "
                                + journalIsCorrupt.getMessage()
                                + ", removing");
                cache.delete();
            }
        }

        // Create a new empty cache.
        directory.mkdirs();
        cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        cache.rebuildJournal();
        return cache;
    }

open方法的目前就是初始化DiskLruCache实例,当缓存路径journal文件存在时,代表已经缓存过资源,调用readJournal方法加载journal文件数据,不存在时则通过rebuildJournal方法初始化journal文件。
我们来看一下这两个方法做了什么操作:

  StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII);
        try {
            String magic = reader.readLine();
            String version = reader.readLine();
            String appVersionString = reader.readLine();
            String valueCountString = reader.readLine();
            String blank = reader.readLine();
            if (!MAGIC.equals(magic)
                    || !VERSION_1.equals(version)
                    || !Integer.toString(appVersion).equals(appVersionString)
                    || !Integer.toString(valueCount).equals(valueCountString)
                    || !"".equals(blank)) {
                throw new IOException("unexpected journal header: [" + magic + ", " + version + ", "
                        + valueCountString + ", " + blank + "]");
            }

            int lineCount = 0;
            while (true) {
            ...
            readJournalLine(reader.readLine());
            lineCount++;
            ...
            }
           ...
        }

方法开头的操作读取journal文件的头信息进行异常判断。接着就是通过readJournalLine循环遍历每一行,然后将数据填充LinkedHashMap中。代码如下:

    private void readJournalLine(String line) throws IOException {
        ...
        if (secondSpace == -1) {
            key = line.substring(keyBegin);
            if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
                lruEntries.remove(key);
                return;
            }
        } else {
            key = line.substring(keyBegin, secondSpace);
        }
        if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
            String[] parts = line.substring(secondSpace + 1).split(" ");
            entry.readable = true;
            entry.currentEditor = null;
            entry.setLengths(parts);
        } else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
            entry.currentEditor = new Editor(entry);
        } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
            // This work was already done by calling lruEntries.get().
        } else {
            throw new IOException("unexpected journal line: " + line);
        }
    }

可以看到填充的过程中会根据状态行的状态进行不同的操作。接着看下rebuildJournal方法:

    private synchronized void rebuildJournal() throws IOException {
        if (journalWriter != null) {
            journalWriter.close();
        }

        Writer writer = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII));
        try {
            writer.write(MAGIC);
            writer.write("\n");
            writer.write(VERSION_1);
            writer.write("\n");
            writer.write(Integer.toString(appVersion));
            writer.write("\n");
            writer.write(Integer.toString(valueCount));
            writer.write("\n");
            writer.write("\n");

            for (Entry entry : lruEntries.values()) {
                if (entry.currentEditor != null) {
                    writer.write(DIRTY + ' ' + entry.key + '\n');
                } else {
                    writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
                }
            }
        } finally {
            writer.close();
        }

        if (journalFile.exists()) {
            renameTo(journalFile, journalFileBackup, true);
        }
        renameTo(journalFileTmp, journalFile, false);
        journalFileBackup.delete();

        journalWriter = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII));
    }

rebuildJournal方法中主要是针对journaljournal.tmpjournal.bkp的处理。先在journal.tmp中写入头信息,如果journal存在的话将journal改名为journal.bkp,然后将journal.tmp改名为journal,最后删除journal.bkp文件,并初始化BufferedWriter对象。

看完了open方法后我们来看下存储操作,存储操作通过DiskLruCache的edit方法完成,那么我们看一下该方法:

    public Editor edit(String key) throws IOException {
        return edit(key, ANY_SEQUENCE_NUMBER);
    }

    private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
        checkNotClosed();
        validateKey(key);
        Entry entry = lruEntries.get(key);
        if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
                || entry.sequenceNumber != expectedSequenceNumber)) {
            return null; // Snapshot is stale.
        }
        if (entry == null) {
            entry = new Entry(key);
            lruEntries.put(key, entry);
        } else if (entry.currentEditor != null) {
            return null; // Another edit is in progress.
        }

        Editor editor = new Editor(entry);
        entry.currentEditor = editor;

        // Flush the journal before creating files to prevent file leaks.
        journalWriter.write(DIRTY + ' ' + key + '\n');
        journalWriter.flush();
        return editor;
    }

可以看到该方法创建了Editor对象,并写入DIRTY状态行。通过Editor对象,可以直接获取到对应的文件路径,数据下载完成后调用editor.commit()方法,下载失败调用editor.abort()方法。定位发现这两个方法最终调用的都是completeEdit方法,只不过是否成功的参数不一样。

    private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
        ...
        if (entry.readable | success) {
            entry.readable = true;
            journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
            if (success) {
                entry.sequenceNumber = nextSequenceNumber++;
            }
        } else {
            lruEntries.remove(entry.key);
            journalWriter.write(REMOVE + ' ' + entry.key + '\n');
        }
        journalWriter.flush();
        if (size > maxSize || journalRebuildRequired()) {
            executorService.submit(cleanupCallable);
        }
    }

可以看到下载成功会写入CLEAN状态行,下载失败会写入REMOVE行,所以DIRTY行后面必须跟着CLEAN行和REMOVE行。否则的话就是无效行。
其余的方法大差不差。我们略过直接来关注最优算法策略。在completeEdit的最后一行,看到当当前值大于最大值时,触发executorService线程池触发cleanupCallable

new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue());
    private final Callable cleanupCallable = new Callable() {
        public Void call() throws Exception {
            synchronized (DiskLruCache.this) {
                if (journalWriter == null) {
                    return null; // Closed.
                }
                trimToSize();
                if (journalRebuildRequired()) {
                    rebuildJournal();
                    redundantOpCount = 0;
                }
            }
            return null;
        }
    };
    private void trimToSize() throws IOException {
        while (size > maxSize) {
            Map.Entry toEvict = lruEntries.entrySet().iterator().next();
            remove(toEvict.getKey());
        }
    }

cleanupCallable 的call方法中执行trimToSize方法,这个方法和LruCache中的方法作用一样,利用LinkedHashMap完成最优算法策略。

总结

1.LruCache和DiskLruCache都是借助LinkedHashMap来完成最优算法的。
2.DiskLruCache磁盘缓存核心是journal文件
3.journal文件的每一行都是一个操作状态
4.DIRTY状态行后面必须跟随CLEAN行和REMOVE行,否则就是无效行。

你可能感兴趣的:(Android 图片缓存)