Android中的缓存策略--DiskLruCache

来源:互联网 发布:企业qq群发软件 编辑:程序博客网 时间:2024/06/09 21:14

LruCache是一种内存缓存策略,但是当存在大量图片的时候,我们指定的缓存内存空间可能很快就会用完,这个时候,LruCache就会频繁的进行trimToSize()操作,不断的将最近最少使用的数据移除,当再次需要该数据时,又得从网络上重新加载。为此,Google提供了一种磁盘缓存的解决方案——DiskLruCache

1 DiskLruCache实现原理

使用了DiskLruCache缓存策略的APP,缓存目录如下图:
这里写图片描述

可以看到,缓存目录中有一堆文件名很长的文件,这些文件就是我们缓存的一张张图片数据,在最后有一个文件名journal的文件,这个journal文件是DiskLruCache的一个日志文件,即保存着每张缓存图片的操作记录,journal文件正是实现DiskLruCache的核心。看到出现了journal文件,基本可以说明这个APP使用了DiskLruCache缓存策略。

根据对LruCache的分析,要实现LRU,最重要的是要有一种数据结构能够基于访问顺序来保存缓存中的对象,LinkedHashMap是一种非常合适的数据结构,为此,DiskLruCache也选择了LinkedHashMap作为维护访问顺序的数据结构,但是,对于DiskLruCache来说,单单LinkedHashMap是不够的,因为我们不能像LruCache一样,直接将数据放置到LinkedHashMap的value中,也就是处于内存当中,在DiskLruCache中,数据是缓存到了本地文件,这里的LinkedHashMap中的value只是保存的是value的一些简要信息Entry,如唯一的文件名称、大小、是否可读等信息,
Entry .class

private final class Entry {    private final String key;    /** Lengths of this entry's files. */    private final long[] lengths;    /** True if this entry has ever been published */    private boolean readable;    /** The ongoing edit or null if this entry is not being edited. */    private Editor currentEditor;    /** The sequence number of the most recently committed edit to this entry. */    private long sequenceNumber;    private Entry(String key) {        this.key = key;        this.lengths = new long[valueCount];    }    public String getLengths() throws IOException {        StringBuilder result = new StringBuilder();        for (long size : lengths) {            result.append(' ').append(size);    }    return result.toString();}    /**     * Set lengths using decimal numbers like "10123".     */    private void setLengths(String[] strings) throws IOException {        if (strings.length != valueCount) {            throw invalidLengths(strings);        }        try {            for (int i = 0; i < strings.length; i++) {                lengths[i] = Long.parseLong(strings[i]);            }        } catch (NumberFormatException e) {            throw invalidLengths(strings);        }    }    private IOException invalidLengths(String[] strings) throws IOException {        throw new IOException("unexpected journal line: " + Arrays.toString(strings));    }    public File getCleanFile(int i) {        return new File(directory, key + "." + i);    }    public File getDirtyFile(int i) {        return new File(directory, key + "." + i + ".tmp");    }}

DiskLruCache中对于LinkedHashMap定义如下:

private final LinkedHashMap<String, Entry> lruEntries  = new LinkedHashMap<String, Entry>(0, 0.75f, true);

在LruCache中,由于数据是直接缓存中内存中,map中数据的建立是在使用LruCache缓存的过程中逐步建立的,而对于DiskLruCache,由于数据是缓存在本地文件,相当于是持久保存下来的一个文件,即使程序退出文件还在,因此,map中数据的建立,除了在使用DiskLruCache过程中建立外,map还应该包括之前已经存在的缓存文件,因此,在获取DiskLruCache的实例时,DiskLruCache会去读取journal这个日志文件,根据这个日志文件中的信息,建立map的初始数据,同时,会根据journal这个日志文件,维护本地的缓存文件。构造DiskLruCache的方法如下:

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)        throws IOException {    if (maxSize <= 0) {        throw new IllegalArgumentException("maxSize <= 0");    }    if (valueCount <= 0) {        throw new IllegalArgumentException("valueCount <= 0");    }    // 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();            cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true),                    IO_BUFFER_SIZE);            return cache;        } catch (IOException journalIsCorrupt) {            cache.delete();        }    }    // create a new empty cache    directory.mkdirs();    cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);    cache.rebuildJournal();    return cache;}

其中,
cache.readJournal();
cache.processJournal();
正是去读取journal日志文件,建立起map中的初始数据,同时维护缓存文件。
那journal日志文件到底保存了什么信息呢,一个标准的journal日志文件信息如下:

libcore.io.DiskLruCache  // MAGIC       固定内容,声明1                        // VERSION_1   cache的版本号,恒为11                        // appVersion  APP的版本号1                        // valueCount  一个key,可以存放多少条数据DIRTY 335c4c6028171cfddfbaae1a9c313c52CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934REMOVE 335c4c6028171cfddfbaae1a9c313c52DIRTY 1ab96a171faeeee38496d8b330771a7aCLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234READ 335c4c6028171cfddfbaae1a9c313c52READ 3400330d1dfc7f3f7f4b8d4d803dfcf6

前五行称为journal日志文件的头,下面部分的每一行会以四种前缀之一开始:DIRTY、CLEAN、REMOVE、READ。
以一个DIRTY前缀开始的,后面紧跟着缓存图片的key。以DIRTY这个这个前缀开头,意味着这是一条脏数据。每当我们调用一次DiskLruCache的edit()方法时,都会向journal文件中写入一条DIRTY记录,表示我们正准备写入一条缓存数据,但不知结果如何。然后调用commit()方法表示写入缓存成功,这时会向journal中写入一条CLEAN记录,意味着这条“脏”数据被“洗干净了”,调用abort()方法表示写入缓存失败,这时会向journal中写入一条REMOVE记录。也就是说,每一行DIRTY的key,后面都应该有一行对应的CLEAN或者REMOVE的记录,否则这条数据就是“脏”的,会被自动删除掉。

在CLEAN前缀和key后面还有一个数值,代表的是该条缓存数据的大小。

因此,我们可以总结DiskLruCache中的工作流程:

1)初始化:通过open()方法,获取DiskLruCache的实例,在open方法中通过readJournal(); 方法读取journal日志文件,根据journal日志文件信息建立map中的初始数据;然后再调用processJournal();方法对刚刚建立起的map数据进行分析,分析的工作,一个是计算当前有效缓存文件(即被CLEAN的)的大小,一个是清理无用缓存文件;

2)数据缓存与获取缓存:上面的初始化工作完成后,我们就可以在程序中进行数据的缓存功能和获取缓存的功能了;

缓存数据的操作是借助DiskLruCache.Editor这个类完成的,这个类也是不能new的,需要调用DiskLruCache的edit()方法来获取实例,如下所示:

public Editor edit(String key) throws IOException

在写入完成后,需要进行commit()。如下一个简单示例:

new Thread(new Runnable() {      @Override      public void run() {          try {              String imageUrl = "http://img.my.csdn.net/uploads/201309/01/1378037235_7476.jpg";              String key = hashKeyForDisk(imageUrl);  //MD5对url进行加密,这个主要是为了获得统一的16位字符            DiskLruCache.Editor editor = mDiskLruCache.edit(key);  //拿到Editor,往journal日志中写入DIRTY记录            if (editor != null) {                  OutputStream outputStream = editor.newOutputStream(0);                  if (downloadUrlToStream(imageUrl, outputStream)) {  //downloadUrlToStream方法为下载图片的方法,并且将输出流放到outputStream                    editor.commit();  //完成后记得commit(),成功后,再往journal日志中写入CLEAN记录                } else {                      editor.abort();  //失败后,要remove缓存文件,往journal文件中写入REMOVE记录                }              }              mDiskLruCache.flush();  //将缓存操作同步到journal日志文件,不一定要在这里就调用        } catch (IOException e) {              e.printStackTrace();          }      }  }).start(); 

注意每次调用edit()时,会向journal日志文件写入DIRTY为前缀的一条记录;文件保存成功后,调用commit()时,也会向journal日志中写入一条CLEAN为前缀的一条记录,如果失败,需要调用abort(),abort()里面会向journal文件写入一条REMOVE为前缀的记录。

获取缓存数据是通过get()方法实现的,如下一个简单示例:

try {      String imageUrl = "http://img.my.csdn.net/uploads/201309/01/1378037235_7476.jpg";      String key = hashKeyForDisk(imageUrl);  //MD5对url进行加密,这个主要是为了获得统一的16位字符     //通过get拿到value的Snapshot,里面封装了输入流、key等信息,调用get会向journal文件写入READ为前缀的记录    DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);     if (snapShot != null) {          InputStream is = snapShot.getInputStream(0);          Bitmap bitmap = BitmapFactory.decodeStream(is);          mImage.setImageBitmap(bitmap);      }  } catch (IOException e) {      e.printStackTrace();  } 

snapshot.getInputStream(0)得到clean文件的输入流,然后通过Bitmap bitmap = BitmapFactory.decodeStream(is);方法得到缓存在硬盘的图片。

3)合适的地方进行flush()
在上面进行数据缓存或获取缓存的时候,调用不同的方法会往journal中写入不同前缀的一行记录,记录写入是通过IO下的Writer写入的,要真正生效,还需要调用writer的flush()方法,而DiskLruCache中的flush()方法中封装了writer.flush()的操作,因此,我们只需要在合适地方调用DiskLruCache中的flush()方法即可。其作用也就是将操作记录同步到journal文件中,这是一个消耗效率的IO操作,我们不用每次一往journal中写数据后就调用flush,这样对效率影响较大,可以在Activity的onPause()中调用一下即可。

注:

写入缓存

1、 然后是这个代码

DiskLruCache.Editor editor  = mDiskLruCache.edit(key);
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;}

其中的lruEntries是一个LinkedHashMap,用于记录资源的key与value。当第一次edit时,lruEntries.get(key)返回的是空。这里会创建一个Entry对象,并在日志文件中写入DIRTY + ’ ’ + key + ‘\n’内容。最后返回包含这个entry的Editor。

3.

OutputStream outputStream = editor.newOutputStream(0);downloadUrlToStream(imageUrl, outputStream);

得到文件输出流,将从网络上请求到的资源写入到文件中。这里关注下outputStream,它是由newOutputStream方法得到的:

public OutputStream newOutputStream(int index) throws IOException {    synchronized (DiskLruCache.this) {        if (entry.currentEditor != this) {            throw new IllegalStateException();        }        return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index)));    }}

getDirtyFile文件是一个临时文件,也就是网络文件写入到这个临时文件当中:

public File getDirtyFile(int i) {    return new File(directory, key + "." + i + ".tmp");}

4.接着执行到了editor.commit()方法,如果没有出现错误的话:创建一个key + “.” + i的文件,将上述的dirty文件重命名为key + “.” + i的文件。更新已经缓存的大小,并且删除上述的dirty文件。

private synchronized void completeEdit(Editor editor, boolean success) throws IOException {    Entry entry = editor.entry;    if (entry.currentEditor != editor) {        throw new IllegalStateException();    }    // if this edit is creating the entry for the first time, every index must have a value    if (success && !entry.readable) {        for (int i = 0; i < valueCount; i++) {            if (!entry.getDirtyFile(i).exists()) {                editor.abort();                throw new IllegalStateException("edit didn't create file " + i);            }        }    }    for (int i = 0; i < valueCount; i++) {        File dirty = entry.getDirtyFile(i);        if (success) {            if (dirty.exists()) {                File clean = entry.getCleanFile(i);                dirty.renameTo(clean);                long oldLength = entry.lengths[i];                long newLength = clean.length();                entry.lengths[i] = newLength;                size = size - oldLength + newLength;            }        } else {            deleteIfExists(dirty);        }    }    redundantOpCount++;    entry.currentEditor = null;    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');    }    if (size > maxSize || journalRebuildRequired()) {        executorService.submit(cleanupCallable);    }}

5.当执行完写入之后,日志文件如下内容:abb7d9d7add6b9fba5314aec6e60c9e6就是上述MD5生成的key,15417是代表缓存的大小。并生成一个abb7d9d7add6b9fba5314aec6e60c9e6.0文件.

libcore.io.DiskLruCache111DIRTY abb7d9d7add6b9fba5314aec6e60c9e6CLEAN abb7d9d7add6b9fba5314aec6e60c9e6 15417

小结&注意:
(1)我们可以在在UI线程中检测内存缓存,即主线程中可以直接使用LruCache;

(2)使用DiskLruCache时,由于缓存或获取都需要对本地文件进行操作,因此需要另开一个线程,在子线程中检测磁盘缓存、保存缓存数据,磁盘操作从来不应该在UI线程中实现;

(3)LruCache内存缓存的核心是LinkedHashMap,而DiskLruCache的核心是LinkedHashMap和journal日志文件,相当于把journal看作是一块“内存”,LinkedHashMap的value只保存文件的简要信息,对缓存文件的所有操作都会记录在journal日志文件中。

DiskLruCache可能的优化方案:
DiskLruCache是基于日志文件journal的,这就决定了每次对缓存文件的操作都需要进行日志文件的记录,我们可以不用journal文件,在第一次构造DiskLruCache的时候,直接从程序访问缓存目录下的缓存文件,并将每个缓存文件的访问时间作为初始值记录在map的value中,每次访问或保存缓存都更新相应key对应的缓存文件的访问时间,这样就避免了频繁的IO操作,这种情况下就需要使用单例模式对DiskLruCache进行构造了,上面的Acache轻量级的数据缓存类就是这种实现方式。

原创粉丝点击