转:
DiskLruCache 的使用及源码解析
目录
- 一、DiskLruCache 的使用
- 1、DiskLruCache 的创建
- 2、DiskLruCache 的添加
- 3、DiskLruCache 的查找
- 4、DiskLruCache 的移除
- 二、部分源码解析
- 1、缓存日志 journal
- 2、DiskLruCache 的 open()
- 3、DiskLruCache 的 edit()
- 4、DiskLruCache 的 get()
- 5、DiskLruCache 的 remove()
- 6、DiskLruCache 的 close()
- 7、DiskLruCache 的 delete()
- 8、DiskLruCache 的 size()
- 9、DiskLruCache 的 flush()
- 三、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/
目录,其中
表示当前应用的包名,当应用被卸载后,此目录会一并被删除。当然也可以选择 SD 卡上的其他指定目录,还可以选择 data 下的当前应用的目录,具体可根据需要灵活设定。这里给出一个建议:如果应用卸载后就希望删除缓存文件,那么就选择 SD 卡上的缓存目录,如果希望保留缓存数据那就应该选择 SD 卡上的其他特定目录。
第二个参数表示应用的版本号,一般设为 1 即可。当版本号发生改变时 DiskLruCache 会清空之前所有的缓存文件,而这个特性在实际开发中作用并不大,很多情况下即使应用的版本号发生了改变缓存文件却仍然是有效的,因此这个参数设为 1 比较好。
第三个参数表示同一个 key 可以对应多少个缓存文件,一般设为 1 即可。
第四个参数表示缓存的总大小,比如 50MB,当缓存大小超出这个设定值后,DiskLruCache 会清除一些缓存从而保证总大小不大于这个设定值。
下面是一个典型的DiskLruCache的创建过程
private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50; //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/
这个路径,而后者获取到的是 /data/data/
这个路径。
最后将获取到的路径和一个 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.
*/
来看一下前五行:
-
libcore.io.DiskLruCache
是固定字符串,表明使用的是 DiskLruCache 技术; - DiskLruCache 的版本号,源码中为常量 1;
- APP 的版本号,即我们在 open() 方法里传入的版本号;
- valueCount,这个值也是在 open() 方法中传入的,指每个 key 对应几个文件,通常情况下都为 1;
- 空行
前五行是该文件的文件头,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 文件便生成了。