DiskLruCache 的使用及源码解析


DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache 得到了 Android 官方文档的推荐,但它不属于 Android SDK 的一部分,它的 源码及网址文末会贴出来。下面分别从 DiskLruCache 的创建、缓存查找和缓存添加这三个方面来介绍 DiskLruCache 的使用方式。

一、DiskLruCache 的使用

如前已知,DiskLruCache 不属于 Android SDK 的一部分,且需要存储权限

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

需要在 build.gradle 中配置

implementation 'com.jakewharton:disklrucache:2.0.2'

然后可以开始使用 DiskLruCache 了。

1、DiskLruCache 的创建

DiskLruCache 并不能通过构造方法来创建,它提供了 open() 方法用于创建自身

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

open() 方法有四个参数,第一个参数表示磁盘缓存在文件系统中的存储路径。缓存路径可以选择 SD 卡上的缓存目录,具体是指 /sdcard/Android/data/<package_name>/cache 目录,其中 <package_name> 表示当前应用的包名,当应用被卸载后,此目录会一并被删除。当然也可以选择 SD 卡上的其他指定目录,还可以选择 data 下的当前应用的目录,具体可根据需要灵活设定。这里给出一个建议:如果应用卸载后就希望删除缓存文件,那么就选择 SD 卡上的缓存目录,如果希望保留缓存数据那就应该选择 SD 卡上的其他特定目录。
第二个参数表示应用的版本号,一般设为 1 即可。当版本号发生改变时 DiskLruCache 会清空之前所有的缓存文件,而这个特性在实际开发中作用并不大,很多情况下即使应用的版本号发生了改变缓存文件却仍然是有效的,因此这个参数设为 1 比较好。
第三个参数表示同一个 key 可以对应多少个缓存文件,一般设为 1 即可。
第四个参数表示缓存的总大小,比如 50MB,当缓存大小超出这个设定值后,DiskLruCache 会清除一些缓存从而保证总大小不大于这个设定值。
下面是一个典型的DiskLruCache的创建过程

private static final long DISK_CACHE_SIZE = 1024102450; //50MB
	File diskCacheDir = getDiskCacheDir(mContext, "bitmap");
    if (! diskCacheDir.exists()) {       	
    	diskCacheDir.mkdirs();
    }
    mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);

其中,getDiskCacheDir() 方法如下

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/<application package>/cache 这个路径,而后者获取到的是 /data/data/<application package>/cache 这个路径。

最后将获取到的路径和一个 uniqueName 进行拼接,作为最终的缓存路径返回。 uniqueName 是对不同类型的数据进行区分而设定的一个唯一值,比如 bitmap、file 等文件夹。

2、DiskLruCache 的添加

DiskLruCache 的缓存添加的操作是通过 Editor 完成的,Editor 表示一个缓存对象的编辑对象。这里仍然以图片缓存举例,首先需要获取图片url 所对应的 key,然后根据 key 就可以通过 edit() 来获取 Editor 对象,如果这个缓存正在被编辑,那么 edit() 会返回 null,即 DiskLruCache 不允许同时编辑一个缓存对象。之所以要把 url 转换成 key,是因为图片的 url 中很可能有特殊字符,这将影响 url 在 Android 中直接使用,一般采用 url 的 md5 值作为 key,如下所示

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;
    }

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

将图片的 url 转成 key 后,就可以获取 Editor 对象了。对于这个 key 来说,如果当前不存在其他 Editor 对象,那么 edit() 就会返回一个新的 Editor 对象,通过它就可以得到一个文件输出流。需要注意的是,由于前面在 DiskLruCache 的 open 方法中设置了一个节点只能有一个数据,因此下面的 DISK_CACHE_INDEX 常量直接设为 0 即可,如下所示

String key = hashKeyFormUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor ! = null) {
   OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
}

当从网络下载图片时,图片就可以通过文件输出流写入到文件系统上,这个过程的实现如下所示

public boolean downloadUrlToStream(String urlString, utputStream 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 (IOException e) {
		Log.e(TAG, "Download bitmap failed. " + e);
	} finally {
		if (urlConnection ! = null) {
		urlConnection.disconnect();
	}			
		MyUtils.close(out);
		MyUtils.close(in);
	}
    return false;
}

其中 MyUtils 源码在这里。之后,还必须通过 Editor 的 commit() 来提交写入操作,真正地将图片写入文件系统。如果图片下载过程发生了异常,那么还可以通过 Editor 的 abort() 来回退整个操作,这个过程如下所示

OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)) {
	editor.commit();
} else {
	editor.abort();
}
mDiskLruCache.flush();

经过上面的几个步骤,图片已经被正确地写入到文件系统了,接下来图片获取的操作就不需要请求网络了。

3、DiskLruCache 的查找

和缓存的添加过程类似,缓存查找过程也需要将 url 转换为 key,然后通过 DiskLruCache 的 get() 方法得到一个 Snapshot 对象,接着再通过 Snapshot 对象即可得到缓存的文件输入流,有了文件输入流,自然就可以得到 Bitmap 对象了。为了避免加载图片过程中导致的 OOM 问题,一般不建议直接加载原始图片。可以通过 BitmapFactory.Options 对象来加载一张缩放后的图片,但是那种方法对 FileInputStream 的缩放存在问题,原因是 FileInputStream 是一种有序的文件流,而两次 decodeStream 调用影响了文件流的位置属性,导致了第二次 decodeStream 时得到的是 null。为了解决这个问题,可以通过文件流来得到它所对应的文件描述符,然后再通过 BitmapFactory.decodeFileDescriptor() 方法来加载一张缩放后的图片,这个过程的实现如下所示

Bitmap bitmap = null;
String key = hashKeyFormUrl(url);
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
if (snapShot ! = null) {
	FileInputStream fileInputStream = (FileInputStream)snapShot.getInputStream(DISK_CACHE_INDEX);
	FileDescriptor fileDescriptor = fileInputStream.getFD();
	bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(fileDescriptor, reqWidth, reqHeight);
    if (bitmap ! = null) {
		addBitmapToMemoryCache(key, bitmap);
	}
}

每个实体都是文件,你可以利用 fileInputStream 读取出里面的内容,然后做其他操作。上面介绍了 DiskLruCache 的创建、添加和查找过程,除此之外,DiskLruCache 还提供了 remove() 、delete() 等方法用于磁盘缓存的删除操作。

4、DiskLruCache 的移除

移除缓存主要是借助 DiskLruCache 的 remove() 方法实现的,源码如下所示

    /**
     * Drops the entry for {@code key} if it exists and can be removed. Entries
     * actively being edited cannot be removed.
     *
     * @return true if an entry was removed.
     */
    public synchronized boolean remove(String key) throws IOException {
        checkNotClosed();
        validateKey(key);
        Entry entry = lruEntries.get(key);
        if (entry == null || entry.currentEditor != null) {
            return false;
        }
        for (int i = 0; i < valueCount; i++) {
            File file = entry.getCleanFile(i);
            if (!file.delete()) {
                throw new IOException("failed to delete " + file);
            }
            size -= entry.lengths[i];
            entry.lengths[i] = 0;
        }
        redundantOpCount++;
        journalWriter.append(REMOVE + ' ' + key + '\n');
        lruEntries.remove(key);
        if (journalRebuildRequired()) {
            executorService.submit(cleanupCallable);
        }
        return true;
    }

remove() 方法中要求传入一个 key,然后会删除这个 key 对应的缓存。
示例代码如下

try {
	String imageUrl = "https://chittyo/img.jpg";  
	String key = hashKeyForDisk(imageUrl);  
	mDiskLruCache.remove(key);
} catch (IOException e) {
	e.printStackTrace();
}

remove() 方法我们并不经常去调用它。只有当你确定某个 key 对应的缓存内容已经过期,需要从网络获取最新的数据时才应该调用 remove() 方法来移除缓存。

我们完全不用担心因缓存数据过多而占用太多 SD 卡空间的问题,DiskLruCache 会根据我们在调用 open() 方法时设定的缓存最大值来自动删除多余的缓存。

二、部分源码解析
1、缓存日志 journal

DiskLruCache 能够正常工作是依赖于 journal 文件中的内容,journal 文件中会存储每次读取操作的记录。下面我们打开 oppo 应用市场 APP 的缓存目录来看一下
在这里插入图片描述
可以看到,一个名称很长的文件和一个 journal 文件,其中,名称很长的文件是被缓存的文件,它的名称是 MD5 编码之后的。我们来看看 journal 文件中的内容是什么样的吧,如下所示

libcore.io.DiskLruCache
1
1
1

DIRTY 27c7e00adbacc71dc793e5e7bf02f861
CLEAN 27c7e00adbacc71dc793e5e7bf02f861 1208
READ 27c7e00adbacc71dc793e5e7bf02f861
DIRTY b80f9eec4b616dc6682c7fa8bas2061f
CLEAN b80f9eec4b616dc6682c7fa8bas2061f 1208
READ b80f9eec4b616dc6682c7fa8bas2061f
DIRTY be3fgac81c12a08e89088555d85dfd2b
CLEAN be3fgac81c12a08e89088555d85dfd2b 99
READ be3fgac81c12a08e89088555d85dfd2b
DIRTY 536990f4dbddfghcfbb8f350a941wsxd
REMOVE 536990f4dbddfghcfbb8f350a941wsxd

来看一下源码注释

    /*
     * This cache uses a journal file named "journal". A typical journal file
     * looks like this:
     *     libcore.io.DiskLruCache
     *     1
     *     100
     *     2
     *
     *     CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
     *     DIRTY 335c4c6028171cfddfbaae1a9c313c52
     *     CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
     *     REMOVE 335c4c6028171cfddfbaae1a9c313c52
     *     DIRTY 1ab96a171faeeee38496d8b330771a7a
     *     CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
     *     READ 335c4c6028171cfddfbaae1a9c313c52
     *     READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
     *
     * The first five lines of the journal form its header. They are the
     * constant string "libcore.io.DiskLruCache", the disk cache's version,
     * the application's version, the value count, and a blank line.
     *
     * Each of the subsequent lines in the file is a record of the state of a
     * cache entry. Each line contains space-separated values: a state, a key,
     * and optional state-specific values.
     *   o DIRTY lines track that an entry is actively being created or updated.
     *     Every successful DIRTY action should be followed by a CLEAN or REMOVE
     *     action. DIRTY lines without a matching CLEAN or REMOVE indicate that
     *     temporary files may need to be deleted.
     *   o CLEAN lines track a cache entry that has been successfully published
     *     and may be read. A publish line is followed by the lengths of each of
     *     its values.
     *   o READ lines track accesses for LRU.
     *   o REMOVE lines track entries that have been deleted.
     *
     * The journal file is appended to as cache operations occur. The journal may
     * occasionally be compacted by dropping redundant lines. A temporary file named
     * "journal.tmp" will be used during compaction; that file should be deleted if
     * it exists when the cache is opened.
     */

来看一下前五行:

  1. libcore.io.DiskLruCache 是固定字符串,表明使用的是 DiskLruCache 技术;
  2. DiskLruCache 的版本号,源码中为常量 1;
  3. APP 的版本号,即我们在 open() 方法里传入的版本号;
  4. valueCount,这个值也是在 open() 方法中传入的,指每个 key 对应几个文件,通常情况下都为 1;
  5. 空行

前五行是该文件的文件头,DiskLruCache 初始化的时候,如果该文件存在,就需要校验该文件头。

接下来看下操作记录:以 DIRTY 为前缀开始的行,后面是缓存文件的 key。DIRTY 英文是“脏的” 的意思,此处译为脏数据。每当我们调用一次 DiskLruCache 的 edit() 方法时,都会向 journal 文件中写入一条 DIRTY 记录,表示我们正准备写入一条缓存数据,但不知结果如何。然后调用 commit() 方法表示写入缓存成功,这时会向 journal 中写入一条 CLEAN 记录,意味着这条 “脏” 数据被 “洗干净了” ,调用 abort() 方法表示写入缓存失败,这时会向 journal 中写入一条 REMOVE 记录。也就是说,每一行 DIRTY 的 key,后面都应该有一行对应的 CLEAN 或者 REMOVE 的记录,否则这条数据就是 “脏” 的,会被自动删除掉。

REMOVE 除了上述的情况,当你自己手动调用 remove(key) 方法的时候也会写入一条 REMOVE 记录。

如果你足够细心的话应该还会注意到,第七行的那条记录,除了 CLEAN 前缀和 key 之外,后面还有一个1208,这是什么意思呢?其实,DiskLruCache 会在每一行 CLEAN 记录的最后加上该条缓存数据的大小,以字节为单位。1208 也就是我们缓存文件的字节数了。源码中的 size() 方法可以获取到当前缓存路径下所有缓存数据的总字节数,其实它的工作原理就是把 journal 文件中所有 CLEAN 记录的字节数相加,返回求出的总和。

前缀是 READ 的记录,每当我们调用 get() 方法去读取一条缓存数据时,就会向 journal 文件中写入一条 READ 记录。因此,图片和数据量都非常大的 APP 的 journal 文件中就可能会有大量的 READ 记录。那如果我不停地频繁操作的话,就会不断地向 journal 文件中写入数据,那这样 journal 文件岂不是会越来越大?这倒不必担心,DiskLruCache 中使用了一个 redundantOpCount 变量来记录用户操作的次数,每执行一次写入、读取或移除缓存的操作,这个变量值都会加 1,当变量值达到 2000 的时候就会触发重构 journal 的事件,这时会自动把 journal 中一些多余的、不必要的记录全部清除掉,保证 journal 文件的大小始终保持在一个合理的范围内。

2、DiskLruCache 的 open()
  /**
   * Opens the cache in {@code directory}, creating a cache if none exists
   * there.
   *
   * @param directory a writable directory
   * @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
   * @throws IOException if reading or writing the cache directory fails
   */
  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");
    }

    // If a bkp file exists, use it instead.
    File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
    if (backupFile.exists()) {
      File journalFile = new File(directory, JOURNAL_FILE);
      // If journal file also exists just delete backup file.
      if (journalFile.exists()) {
        backupFile.delete();
      } else {
        renameTo(backupFile, journalFile, false);
      }
    }

    // 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;
  }

首先检查 journal 的备份文件是否存在( journal.bkp),如果备份存在,然后检查 journal 文件是否存在,如果 journal 文件存在,备份文件就可以删除了;如果 journal 文件不存在,将备份文件文件重命名为 journal 文件。

然后检查 journal 文件是否存在:

  • 如果不存在,创建 directory,重新构造 DiskLruCache,再调用 rebuildJournal() 建立 journal 文件。
  /**
   * Creates a new journal that omits redundant information. This replaces the
   * current journal if it exists.
   */
  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));
  }

可以看到首先构建一个 journal.tmp 文件,然后写入文件头(5行),然后遍历 lruEntries( LinkedHashMap<String, Entry> lruEntries =
new LinkedHashMap<String, Entry>(0, 0.75f, true); ),此时 lruEntries 里没有任何数据。接下来将 tmp 文件重命名为 journal 文件。这样一个 journal 文件便生成了。

  • 如果存在,那么调用 readJournal()
private void readJournal() throws IOException {
    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) {
        try {
          readJournalLine(reader.readLine());
          lineCount++;
        } catch (EOFException endOfJournal) {
          break;
        }
      }
      redundantOpCount = lineCount - lruEntries.size();

      // If we ended on a truncated line, rebuild the journal before appending to it.
      if (reader.hasUnterminatedLine()) {
        rebuildJournal();
      } else {
        journalWriter = new BufferedWriter(new OutputStreamWriter(
            new FileOutputStream(journalFile, true), Util.US_ASCII));
      }
    } finally {
      Util.closeQuietly(reader);
    }
  }

首先校验文件头,接下来调用 readJournalLine 按行读取内容。

private void readJournalLine(String line) throws IOException {
    int firstSpace = line.indexOf(' ');
    if (firstSpace == -1) {
      throw new IOException("unexpected journal line: " + line);
    }

    int keyBegin = firstSpace + 1;
    int secondSpace = line.indexOf(' ', keyBegin);
    final String key;
    if (secondSpace == -1) {
      key = line.substring(keyBegin);
      if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
        lruEntries.remove(key);
        return;
      }
    } else {
      key = line.substring(keyBegin, secondSpace);
    }

    Entry entry = lruEntries.get(key);
    if (entry == null) {
      entry = new Entry(key);
      lruEntries.put(key, entry);
    }

    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);
    }
  }

journal 日志每一行中的各个部分都是用 ' ' 空格来分割的,所以先用 空格来截取一下。拿到 key,然后判断 firstSpace 是 REMOVE 就会调用 lruEntries.remove(key); ,若不是 REMOVE ,如果该 key 没有加入到 lruEntries ,则创建并且加入。然后继续判断 firstSpace ,若是 CLEAN ,则初始化 entry ,设置 readable=true , currentEditor 为 null ,初始化长度等。若是 DIRTY ,则设置 currentEditor 对象。若是 READ,无操作。

一般正常操作下 DIRTY 不会单独出现,会和 REMOVE or CLEAN 成对出现;经过上面这个流程,基本上加入到 lruEntries 里面的只有 CLEAN 且没有被 REMOVE 的 key。

然后我们回到 readJournal 方法,在我们按行读取的时候,会记录一下 lineCount ,然后赋值给 redundantOpCount ,该变量记录的是多余的记录条数( redundantOpCount = lineCount - lruEntries.size(); 文件的行数-真正可以的 key 的行数)。最后,如果读取过程中发现 journal 文件有问题,则重建 journal 文件。没有问题的话,初始化下 journalWriter,关闭 reader。

我们再回到 open() 方法中,readJournal 完成了,会继续调用 processJournal() 这个方法

  /**
   * Computes the initial size and collects garbage as a part of opening the
   * cache. Dirty entries are assumed to be inconsistent and will be deleted.
   */
  private void processJournal() throws IOException {
    deleteIfExists(journalFileTmp);
    for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) {
      Entry entry = i.next();
      if (entry.currentEditor == null) {
        for (int t = 0; t < valueCount; t++) {
          size += entry.lengths[t];
        }
      } else {
        entry.currentEditor = null;
        for (int t = 0; t < valueCount; t++) {
          deleteIfExists(entry.getCleanFile(t));
          deleteIfExists(entry.getDirtyFile(t));
        }
        i.remove();
      }
    }
  }

计算缓存初始大小,赋值给 size,并收集垃圾作为打开缓存的一部分。对于所有非法 DIRTY 状态(就是 DIRTY 单独出现的)的 entry,如果存在 文件则删除,并且从 lruEntries 中移除。此时,剩下的就只有 CLEAN 状态的 key 记录了。

到此 DiskLruCache 就初始化完毕了,为了方便记忆,我们来捋一下流程:

根据我们传入的 directory,去找 journal 文件,如果没找到,则创建一个,只写入文件头 (5行) 。如果找到,则遍历该文件,将里面所有的 CLEAN 记录的 key,存到 lruEntries 中。
经过 open 以后,journal 文件肯定存在了,lruEntries 里面肯定有值了,size 为当前所有实体占据的容量。

3、DiskLruCache 的 edit()
  /**
   * Returns an editor for the entry named {@code key}, or null if another
   * edit is in progress.
   */
  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;
  }
  
 private void validateKey(String key) {
    Matcher matcher = LEGAL_KEY_PATTERN.matcher(key);
    if (!matcher.matches()) {
      throw new IllegalArgumentException("keys must match regex "
              + STRING_KEY_PATTERN + ": \"" + key + "\"");
    }
 }

private void checkNotClosed() {
    if (journalWriter == null) {
      throw new IllegalStateException("cache is closed");
    }
  }

首先验证 key,必须是由字母、数字、下划线、横线(-) 组成,且长度在 1-120 之间。然后通过 key 获取实体 Entry ,如果 Entry 不存在,则创建一个 Entry 加入到 lruEntries 中;如果存在且不是正在编辑的实体,则直接使用。然后为 entry.currentEditor 进行赋值为 new Editor(entry); ,最后在 journal 文件中写入一条 DIRTY 记录,代表这个文件正在被操作。拿到 editor 对象以后,就是去调用 newOutputStream 去获得一个文件输入流了。

    /**
     * Returns a new unbuffered output stream to write the value at
     * {@code index}. If the underlying output stream encounters errors
     * when writing to the filesystem, this edit will be aborted when
     * {@link #commit} is called. The returned output stream does not throw
     * IOExceptions.
     */
    public OutputStream newOutputStream(int index) throws IOException {
      if (index < 0 || index >= valueCount) {
        throw new IllegalArgumentException("Expected index " + index + " to "
                + "be greater than 0 and less than the maximum value count "
                + "of " + valueCount);
      }
      synchronized (DiskLruCache.this) {
        if (entry.currentEditor != this) {
          throw new IllegalStateException();
        }
        if (!entry.readable) {
          written[index] = true;
        }
        File dirtyFile = entry.getDirtyFile(index);
        FileOutputStream outputStream;
        try {
          outputStream = new FileOutputStream(dirtyFile);
        } catch (FileNotFoundException e) {
          // Attempt to recreate the cache directory.
          directory.mkdirs();
          try {
            outputStream = new FileOutputStream(dirtyFile);
          } catch (FileNotFoundException e2) {
            // We are unable to recover. Silently eat the writes.
            return NULL_OUTPUT_STREAM;
          }
        }
        return new FaultHidingOutputStream(outputStream);
      }
    }

首先校验 index 是否在 (0, valueCount] 范围内,一般我们使用都是一个 key 对应一个文件,所以传入的基本都是 0。接下来就是通过 entry.getDirtyFile(index); 拿到一个 dirtyFile 对象,其实就是个中转文件,文件格式为 key.index.tmp ,将这个文件的 FileOutputStream 通过 FaultHidingOutputStream 封装下传给我们。

最后,我们通过 outputStream 写入数据以后,需要调用 commit 方法

    /**
     * Commits this edit so it is visible to readers.  This releases the
     * edit lock so another edit may be started on the same key.
     */
    public void commit() throws IOException {
      if (hasErrors) {
        completeEdit(this, false);
        remove(entry.key); // The previous entry is stale.
      } else {
        completeEdit(this, true);
      }
      committed = true;
    }

首先通过 hasErrors 判断,是否有错误发生,如果有就调用 completeEdit(this, false); 和 remove(entry.key); ;如果没有就调用 completeEdit(this, true); 。

那么这里这个 hasErrors 哪来的呢?还记得上面 newOutputStream 的时候,返回了一个 outputStream,这个 outputStream 是 FileOutputStream ,但是经过了 FaultHidingOutputStream 的封装,这个类实际上就是重写了 FilterOutputStream 的 write 相关方法,将所有的 IOException 给屏蔽了,如果发生 IOException 就将 hasErrors 赋值为 true。这样的设计的好处是不直接将 OutputStream 返回给用户,如果出错可以检测到,不需要用户手动去调用一些操作。
下面看看 completeEdit 方法

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 (!editor.written[i]) {
          editor.abort();
          throw new IllegalStateException("Newly created entry didn't create value for index " + i);
        }
        if (!entry.getDirtyFile(i).exists()) {
          editor.abort();
          return;
        }
      }
    }

    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');
    }
    journalWriter.flush();

    if (size > maxSize || journalRebuildRequired()) {
      executorService.submit(cleanupCallable);
    }
  }

首先判断 if (success && !entry.readable) 是否成功,且是第一次写入(如果以前这个记录有值,则 readable = true ),内部的判断,我们都不会走,因为 written[i] 在 newOutputStream 的时候被写入 true 了。而且正常情况下,getDirtyFile 是存在的。

然后如果成功,将 dirtyFile 进行重命名为 cleanFile,文件名为:key.index。然后刷新 size 的长度。如果失败,则删除 dirtyFile .

然后,如果成功或者 readable 为 true ,将 readable 设置为 true ,写入一条 CLEAN 记录。如果第一次提交且失败,那么就会从 lruEntries.remove(key) ,写入一条 REMOVE 记录。

写入缓存,要判断是否超过了最大 size ,或者需要重建 journal 文件

  /**
   * We only rebuild the journal when it will halve the size of the journal
   * and eliminate at least 2000 ops.
   */
  private boolean journalRebuildRequired() {
    final int redundantOpCompactThreshold = 2000;
    return redundantOpCount >= redundantOpCompactThreshold //
        && redundantOpCount >= lruEntries.size();
  }

如果 redundantOpCount 达到 2000,且超过了 lruEntries.size() 就会重建,这里就可以看到 redundantOpCount 的作用了。防止 journal 文件过大。到此我们的存入缓存就分析完成了。

总结:首先调用 editor,拿到指定的 dirtyFile 的 OutputStream ,然后开始写操作,写完后,记得调用 commit .
commit 中会检测你是否发生 IOException ,若无,则将 dirtyFile -> cleanFile ,将 readable = true ,写入 CLEAN 记录。如若发生错误,则删除 dirtyFile ,从 lruEntries 中移除,然后写入一条 REMOVE 记录。

4、DiskLruCache 的 get()
  /**
   * Returns a snapshot of the entry named {@code key}, or null if it doesn't
   * exist is not currently readable. If a value is returned, it is moved to
   * the head of the LRU queue.
   */
  public synchronized Snapshot get(String key) throws IOException {
    checkNotClosed();
    validateKey(key);
    Entry entry = lruEntries.get(key);
    if (entry == null) {
      return null;
    }

    if (!entry.readable) {
      return null;
    }

    // Open all streams eagerly to guarantee that we see a single published
    // snapshot. If we opened streams lazily then the streams could come
    // from different edits.
    InputStream[] ins = new InputStream[valueCount];
    try {
      for (int i = 0; i < valueCount; i++) {
        ins[i] = new FileInputStream(entry.getCleanFile(i));
      }
    } catch (FileNotFoundException e) {
      // A file must have been deleted manually!
      for (int i = 0; i < valueCount; i++) {
        if (ins[i] != null) {
          Util.closeQuietly(ins[i]);
        } else {
          break;
        }
      }
      return null;
    }

    redundantOpCount++;
    journalWriter.append(READ + ' ' + key + '\n');
    if (journalRebuildRequired()) {
      executorService.submit(cleanupCallable);
    }

    return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
  }

如果根据 key 取到的 Entry 为 null ,或者 readable = false ,则返回 null,否则将 cleanFile 的 FileInputStream 进行封装返回 Snapshot,且写入一条 READ 语句。
然后 getInputStream 就是返回该 FileInputStream 了。

5、DiskLruCache 的 remove()
  /**
   * Drops the entry for {@code key} if it exists and can be removed. Entries
   * actively being edited cannot be removed.
   *
   * @return true if an entry was removed.
   */
  public synchronized boolean remove(String key) throws IOException {
    checkNotClosed();
    validateKey(key);
    Entry entry = lruEntries.get(key);
    if (entry == null || entry.currentEditor != null) {
      return false;
    }

    for (int i = 0; i < valueCount; i++) {
      File file = entry.getCleanFile(i);
      if (file.exists() && !file.delete()) {
        throw new IOException("failed to delete " + file);
      }
      size -= entry.lengths[i];
      entry.lengths[i] = 0;
    }

    redundantOpCount++;
    journalWriter.append(REMOVE + ' ' + key + '\n');
    lruEntries.remove(key);

    if (journalRebuildRequired()) {
      executorService.submit(cleanupCallable);
    }

    return true;
  }

如果实体存在且不是正在被编辑,就可以直接进行删除,然后写入一条 REMOVE 记录。

remove() 与 open() 对应,在使用完成 cache 后可以手动关闭。

6、DiskLruCache 的 close()
	/**
     * Closes this cache. Stored values will remain on the filesystem.
     */
    public synchronized void close() throws IOException {
        if (journalWriter == null) {
            return; // already closed
        }
        for (Entry entry : new ArrayList<Entry>(lruEntries.values())) {
            if (entry.currentEditor != null) {
                entry.currentEditor.abort();
            }
        }
        trimToSize();
        journalWriter.close();
        journalWriter = null;
    }

这个方法用于将 DiskLruCache 关闭掉,关闭前,会判断所有正在编辑的实体,调用 abort() 方法,最后关闭 journalWriter 。close() 是和 open() 方法对应的一个方法。关闭之后就不能再调用 DiskLruCache 中任何操作缓存数据的方法,通常只应该在 Activity 的 onDestroy() 方法中去调用 close() 方法

其中 abort() 方法是存储失败时的逻辑,中止此编辑。这将释放编辑锁,以便其他操作可以获取编辑锁,操作同一个 key。

    /**
     * Aborts this edit. This releases the edit lock so another edit may be
     * started on the same key.
     */
    public void abort() throws IOException {
      completeEdit(this, false);
    }
7、DiskLruCache 的 delete()
	/**
     * Closes the cache and deletes all of its stored values. This will delete
     * all files in the cache directory including files that weren't created by the cache.
     */
    public void delete() throws IOException {
        close();
        IoUtils.deleteContents(directory);
    }

这个方法用于将所有的缓存数据全部删除。比如 APP 中手动清理缓存功能,只需要调用一下 DiskLruCache 的 delete() 方法就可以实现了。

8、DiskLruCache 的 size()
 	/**
     * Returns the number of bytes currently being used to store the values in
     * this cache. This may be greater than the max size if a background
     * deletion is pending.
     */
    public synchronized long size() {
        return size;
    }

这个方法会返回当前缓存路径下所有缓存数据的总字节数,以 byte 为单位,如果应用程序中需要在界面上显示当前缓存数据的总大小,就可以通过调用这个方法计算出来。如果后台删除挂起,则此值可能大于最大大小。

9、DiskLruCache 的 flush()
	/**
     * Force buffered operations to the filesystem.
     */
    public synchronized void flush() throws IOException {
        checkNotClosed();
        trimToSize();
        journalWriter.flush();
    }

这个方法用于将内存中的操作记录同步到日志文件(也就是 journal 文件)当中。这个方法非常重要,因为 DiskLruCache 能够正常工作是依赖于 journal 文件中的内容。其实并不是每次写入缓存都要调用一次 flush() 方法,频繁地调用并不会带来任何好处,只会额外增加同步 journal 文件的时间。比较标准的做法是在 Activity 的 onPause() 方法中调用一次 flush() 方法就可以了。

至此,我们的源码分析就结束了。可以看到 DiskLruCache ,利用一个 journal 文件,保证了 cache 实体的可用性(只有 CLEAN 的可用),且获取文件的长度的时候可以通过在该文件的记录中读取。利用 FaultHidingOutputStream 对 FileOutPutStream 在写入文件过程中是否发生错误进行捕获,而不是让用户手动去调用出错后的处理方法。其内部的很多细节都很值得我们推敲和学习。不过也可以看到,存取的操作不是特别的方便易用,需要我们自己去操作文件流。

三、DiskLruCache 完整源码

DiskLruCache 的完整源码
Github - JakeWharton/DiskLruCache
本站免费下载链接

四、参考资料

任玉刚的《Android 开发艺术探索》一书。
郭霖 CSDN
鸿洋 CSDN

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
LRU Cache (最近最少使用缓存) 和 DiskLruCache (基于磁盘的 LRU 缓存) 是两种常见的缓存技术,它们的使用场景和优点如下: 1. LRU Cache使用场景: - 当需要缓存一些数据时,但是又不能无限制地增加内存消耗时,可以使用 LRU Cache 进行缓存。 - 当需要快速访问某些数据时,而这些数据的访问频率比较高时,可以使用 LRU Cache 进行缓存。 - 当需要保证缓存数据的时效性,避免过期数据对程序造成影响时,可以使用 LRU Cache 进行缓存。 2. DiskLruCache使用场景: - 当需要缓存一些大量的数据时,但是这些数据又不能全部存放在内存中时,可以使用 DiskLruCache 进行缓存。 - 当需要保证数据能够持久化存储时,可以使用 DiskLruCache 进行缓存。 - 当需要对缓存数据进行一些额外的操作时,例如压缩、加密等操作时,可以使用 DiskLruCache 进行缓存。 以下是使用 Kotlin 代码展示 LRU CacheDiskLruCache 的实现方法: ```kotlin // LRU Cache 的实现 import android.util.LruCache // 初始化一个 LRU Cache,设置最大缓存数量为 10 个 val lruCache = LruCache<String, String>(10) // 将数据加入缓存中 lruCache.put("key1", "value1") // 获取缓存中的数据 val value = lruCache.get("key1") // 移除缓存中的数据 lruCache.remove("key1") // 清除缓存中的所有数据 lruCache.evictAll() ``` ```kotlin // DiskLruCache 的实现 import com.jakewharton.disklrucache.DiskLruCache import java.io.File // 初始化一个 DiskLruCache,设置缓存目录和最大缓存数量为 10 个 val directory = File(context.cacheDir, "disk_cache") val diskCacheSize = 10 * 1024 * 1024 // 10MB val diskLruCache = DiskLruCache.open(directory, 1, 1, diskCacheSize.toLong()) // 将数据加入缓存中 val editor = diskLruCache.edit("key1") editor?.newOutputStream(0)?.use { outputStream -> outputStream.write("value1".toByteArray()) } editor?.commit() // 获取缓存中的数据 val snapshot = diskLruCache.get("key1") val value = snapshot?.getInputStream(0)?.bufferedReader().use { reader -> reader?.readText() } // 移除缓存中的数据 diskLruCache.remove("key1") // 清除缓存中的所有数据 diskLruCache.delete() ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值