在 ListView 滑动时加载大量的图片时,若不对图片做本地缓存,那么下次再进来同一个页面,又是需要从网络中去下载图片,这样是比较消耗流量的,在阅读了 UIL 图片加载框架之后,发现其三级缓存中的 native 缓存就是使用 DiskLruCache 实现的。下面分析的据是 DiskLruCache 的相关代码。
图片磁盘缓存就是将一张从网络下载的图片,通过流的方式写入到本地文件中,然后下次需要使用时,再打开一个指向已缓存的文件的输入流中将图片读取出来然后转化为 Bitmap 对象,然后设置在 ImageView 中,这就是这个 native 缓存的大致思路。
下面的截图中可以看到前面3个文件就是实际缓存的图片文件,名字之所以会这样,是因为使用了 md5 算法,至于为什么这样做,下面于分析。还有一个文件叫做 journal 的文件,这个文件是专门负责对操作的图片进行日志记录的,具体怎么记录下面会进行分析。这个文件目录是在 Android/data/package/cache/bitmap 下。
下面开始进行缓存操作。
1、打开缓存
打开缓存,其实就获取 DiskLruCache 对象,根据这个对象,再进行对文件的读写操作等。通过 open 方法即可获取到指定的对象。
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
参数1:缓存的文件路径
参数2:版本号
参数3:一个 key 可以对应多少个值,这个值就是缓存的文件
参数4:最大的缓存空间
对于参数1需要注意的需要判断外置 sd 卡是否支持:
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);
}
2、写入缓存
通过 Editor 就可以获取到输出流对象,然后往流中写入数据,根据写入数据是否成功,调用Editor.commit/abort 方法
整体过程:
1.打开缓存,也就是获取到 DiskLruCache 对象
2.通过 edit(key) 获取到 Editor 对象
3.通过 editor.newOutputStream(0) 获取输出流对象
4.下载图片,并将其写入到该输出流中
5.操作成功之后,调用 commit 方法表示成功,否则调用 abort() 表示操作不成功。
示例代码
new Thread() {
@Override
public void run() {
super.run();
try {
//打开缓存
DiskLruCache diskLruCache = DiskLruCache.open(getDirectory(MainActivity.this, "bitmap"), getVersion(MainActivity.this), 1, 10 * 1024 * 1024);
String imageUrl = "http://img03.tooopen.com/images/20131111/sy_46708898917.jpg";
//写入缓存
DiskLruCache.Editor editor = diskLruCache.edit(hashKeyForDisk(imageUrl));
OutputStream outputStream = editor.newOutputStream(0);
//下载图片并将图片写入到 outputStream ,返回值表示是否成功。
boolean isSuccess = downloadUrlToStream(imageUrl, outputStream);
if (isSuccess) {//成功
editor.commit();
} else {//失败
editor.abort();
}
//刷新一些缓存空间。
diskLruCache.flush();
}
}.start();
下载图片
private boolean downloadUrlToStream(String urlString, OutputSt
HttpURLConnection urlConnection = null;
BufferedOutputStream out = null;
BufferedInputStream in = null;
try {
final URL url = new URL(urlString);
urlConnection = (HttpURLConnection) url.openConnection
in = new BufferedInputStream(urlConnection.getInputStr
out = new BufferedOutputStream(outputStream, 8 * 1024)
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;
}
源码分析:
-
得到 DiskLruCache.Editor 对象之后,然后通过 editor.newOutputStream(0)获取往存储空间位置的输出流对象,具体代码在下面有分析。
-
然后调用 downloadUrlToStream 将下载好的图片往该输出流中写入数据。
-
如果成功,那么会调用 commit 方法。
-
如果失败,那么会调用 abort 方法。
下面关注一下 Editor.commit 方法的具体实现:
该方法就是根据 valueCount 进行遍历,因为先前设置了的值为 1,并且先前已经通过 downloadUrlToStream 方法将指定图片下载到 key.0.tmp 这个 dirtyFile 文件中了。之后就是需要将该 dirty File 进行重命名为 cleanFile ,其实也就是将 key.0.tmp 重命名为 key.0 而已,然后设置一个 length 的大小(这个 length 就是该图片的大小)和当前缓存的总大小 size 。
public void commit() throws IOException {
if (hasErrors) {
completeEdit(this, false);
remove(entry.key); // the previous entry is stale
} else {
//正常流程会走这段代码
completeEdit(this, true);
}
}
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
Entry entry = editor.entry;
...
//根据传入给 open 参数的 valueCount 进行遍历,也就是一个 key 对应多少 file 文件。
for (int i = 0; i < valueCount; i++) {
File dirty = entry.getDirtyFile(i);
if (success) {
//这个就是先前通过 editor.newOutputStream(0)所创建的 file 文件,名字为 key.0.tmp。
if (dirty.exists()) {
//名字为 key.0
File clean = entry.getCleanFile(i);
//将已经存在的 dirtyFile 重命名为 cleanFile
dirty.renameTo(clean);
//修改该文件的大小
long oldLength = entry.lengths[i];
long newLength = clean.length();
entry.lengths[i] = newLength;
//更新当前缓存的总大小
size = size - oldLength + newLength;
}
} else {
deleteIfExists(dirty);
}
}
}
下面关注一下 Editor.abort方法的具体实现:
public void abort() throws IOException {
completeEdit(this, false);
}
查看上面 compketeEdit 方法在参数2设置了 success = false 的情况会调用 deleteIfExists(dirty); 方法。该方法就是将先前通过 editor.newOutputStream(0) 创建的 dirtyFile(名子为key.0.tmp)进行删除。
private static void deleteIfExists(File file){
if (file.exists() && !file.delete()) {
throw new IOException();
}
}
3、读取缓存
通过 SnatShot 类获取输入流对象,然后读取该流的内容。
整体过程:
1.打开缓存,也就是获取到 DiskLruCache 对象
2.通过 get(key) 获取到 Snapshot 对象
3.通过 snapshot.getInputStream(0) 获取输入流对象
4.通过 BitmapFactory.decodeStream 得到对应的 bitmap 对象,然后显示在 ImageView 上即可。
DiskLruCache.Snapshot snapshot = diskLruCache.get(hashKeyForDisk(imageUrl));
InputStream inputStream = snapshot.getInputStream(0);
final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
runOnUiThread(new Runnable() {
@Override
public void run() {
mImageView.setImageBitmap(bitmap);
}
});
细节
1、snapshot.getInputStream(0)这个两个方法为什么传入的参数是 0 呢?
因为之前在 open 方法中,对应的 valueCount 表示一个 key 可以对应多少个文件,在 get 方法中会创建一个 InputStream[valueCount]的数组,如果 open 传入的 valueCount = 1 的话那么这里调用 getInoutStream(0) 参数传入 0 即可获取对应的输入流对象。
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;
}
//根据 valueCount 创建一个 InputStream 数组,对应的每一个文件都会用一个 InputStream 来表示。
InputStream[] ins = new InputStream[valueCount];
try {
for (int i = 0; i < valueCount; i++) {
//给每一个 InputStream 赋值
ins[i] = new FileInputStream(entry.getCleanFile(i));
}
} catch (FileNotFoundException e) {
// a file must have been deleted manually!
return null;
}
redundantOpCount++;
journalWriter.append(READ + ' ' + key + '\n');
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return new Snapshot(key, entry.sequenceNumber, ins);
}
###2、editor.newOutputStream(0) 的参数传入 0 的原因是什么呢?
在这个方法首先会根据 index 去获取一个 dirtyFile,返回文件的格式就是 key.index.tmp,例如 abc.0.tmp。
public OutputStream newOutputStream(int index) throws IOException {
synchronized (DiskLruCache.this) {
...
return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index)));
}
}
在写入缓存的代码可以看到在写入数据成功之后,会调用 commit 方法。
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++) {
//这里因为 valueCount = 1,因此这个循环只会跑一次。
//因此 entry.getDirtyFile(0),这里解释了为什么在 editor.newOutputStream(0)传入参数0了。
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);
}
}
3、这里的 key 为什么要进行 md5 加密处理呢?
DiskLruCache.Editor editor =
diskLruCache.edit(hashKeyForDisk(imageUrl));
//加密操作
public String hashKeyForDisk(String key) {
String cacheKey;
try {
final MessageDigest mDigest = MessageDigest.getInstance("MD5");
m。
* igest.update(key.getBytes());
cacheKey = bytesToHexString(mDigest.digest());
} catch (NoSuchAlgorithmException e) {
cacheKey = String.valueOf(key.hashCode());
}
return cacheKey;
}
- 我们都知道不同的操作系统对文件的命名规范可能不一样,例如 windows 就不允许出现特殊字符,因此为了避免特殊字符导致不能保存问题,就应该对 key 进行 md5 加密处理。
分析 journal 文件
下面是 journal 文件的内容:
-
第一行"libcore.io.DiskLruCache":写入的 DiskLruCache 内部的一个常量
static final String MAGIC = “libcore.io.DiskLruCache”; -
第二行"1":写入的DiskLruCache 内部的一个常量,表示版本号
static final String VERSION_1 = “1”; -
第三行"1":写入的是传入给 open 方法的第二个参数 appVersion 。
-
第四行"1":写入的是传入给 open 方法的第三个参数 valueCount,表示一个 key 对应多少个文件。
下面还有 DIRTY,CLEAN,REMOVE,READ 几个又是什么意思?
- DIRTY:当调用 Editor#edit(key) 时会往 jounrnal 文件写入
DIRTY + ’ ’ + key + ‘\n’
//DIRTY e37775b7868532e0d2986b1ff384c078
//当调用 edit 方法时会往文件中写入 DIRTY 数据。
private static final String DIRTY = "DIRTY";
journalWriter.write(DIRTY + ' ' + key + '\n');
journalWriter.flush();
- CLEAN:是在成功调用 commit 之后写入的。
CLEAN + ’ ’ + entry.key + entry.getLengths() + ‘\n’
//CLEAN e37775b7868532e0d2986b1ff384c078 152313
private static final String CLEAN = "CLEAN";
journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
- READ : 在每一次调用 get 方法都会往 journal 文件写入一条 READ 记录。
private static final String READ = "READ";
redundantOpCount++;
journalWriter.append(READ + ' ' + key + '\n');
- REMOVE:当前 conmmit 失败或者在 remove 方法中都会被调用,表示当前的 entry 被移除了,因此会往文件中写入日志。
//REMOVE 398f3dfc21888695c47301d5d91b34b1
lruEntries.remove(entry.key);
journalWriter.write(REMOVE + ' ' + entry.key + '\n');
LRU 算法
通过在线程池中执行 cleanupCallable 线程去执行清除最近最少使用的节点。这里怎么是最近最少使用的节点开始移除,请参考: LinkedHashMap 实现 LruCache 的底层数据结构?
//通过该方法进行移除最近最少使用的一些节点。
executorService.submit(cleanupCallable);
private final Callable<Void> cleanupCallable = new Callable<Void>() {
@Override 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) {
//底层数据结构是 LinkedHashMap
//因此这里获取到的 iterator 进行迭代遍历的是 LinkedHashMap 内部维护的双向链表,从最近最少使用的节点开始移除。
final Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next();
remove(toEvict.getKey());
}
}