Android中的缓存策略:LruCache和DiskLruCache
导言
本篇文章主要是介绍Android中内置的两个缓存类的原理。所谓缓存,就是将获取的数据保存下来以便下次继续使用,这种技术尤其在网络请求和图片加载中有用,可以显著地提升App的性能表现。Android中也内置了两个缓存类,分别是LruCache和DiskLruCache。
LruCache
所谓LRU其实是(Least Recently Used)的缩写,他的意思就是近期最少使用算法,顾名思义,当缓存区满的时候该策略将首先排除掉最久没有被使用过的缓存,这种策略很简单也很有效。如果没有记错的话在Google的Volley库中也使用到了这种缓存策略。
LruCache的使用
LruCache的使用很简单,它的内部使用LinkedHashMap实现,我们可以像使用其他的Map或者List一样直接使用LruCache。不过我们需要重写其sizeOf
方法,除此之外还需要指定其最大的容量。这里说的容量指的是占得内存空间的大小而不是数据的个数。 这个最大容量是和sizeOf
方法配合来实现缓存策略的。
一个最简单的例子如下:
int CacheSie = 1024; //我们以以kb为单位
LruCache<String, Bitmap> map = new LruCache<>(CacheSie){
@Override
protected int sizeOf(@NonNull String key, @NonNull Bitmap value) {
//这里一开始计算出的占用内存大小是以B为单位,我们转成KB
return value.getRowBytes() * value.getHeight() / 1024 ;
}
};
我们以上面这段代码为例来说明,如果我们想要我们的缓存区的最大容量为1024K的话,我们就将1024传入LruCache的构造函数中代表这个缓存区的最大容量为1024KB。记住,这里的大小是我们规定的,它的单位也是我们规定的。接着,我们重写器sizeOf
方法,计算出存进去的每个Bitmap占用的内存大小,通过value.getRowBytes()
我们可以计算出Bitmap的每一行占用的内存大小,这里是以B为单位,接着将这个值乘以它的高度,这样就计算出来了一张Bitmap所占用的内存大小。不过这里是以B为单位的,而我们规定的最大内存容量是以KB为单位的,所以还需要将这个计算出的内存大小除以1024将其转化成KB为单位。
这样我们就成功创建出了一个LruCache并可以使用了,它的最大缓存容量为1024KB。
源码解析LruCache
构造方法
接下来我们从源码的角度分析LruCache。首先从它的构造方法入手:
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
this.maxSize = maxSize;
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}
可以看到这里LruCache有两个成员变量,maxSize
就是我们在上面的例子中传入的最大缓存容量,而在这里我们也可以看到LruCache的内部是使用LinkedHashMap来存储元素的,LinkedHashMap与一般的HashMap的区别就是它内部维护了一个列表来记录元素的插入顺序,这样它在输出时就不会乱序了。
get方法
接下来从插入和获取项这两个方法来看,先看其get
方法:
public final V get(@NonNull K key) {
if (key == null) { //当键值为空时,直接抛出异常
throw new NullPointerException("key == null");
}
V mapValue;
synchronized (this) { //进行上锁,所以说是线程安全的
mapValue = map.get(key); //尝试从内部的LinkedHashMap中获取数据
if (mapValue != null) { //当获取到了数据时
hitCount++; //缓存命中数+1
return mapValue; //返回命中的数据
}
missCount++; //若缓存未命中的话,缓存未命中数+1
}
//接下来都是缓存没有命中的分支
V createdValue = create(key); //尝试调用create方法根据key值创建一个新对象,不过create方法默认返回null
if (createdValue == null) {//当create方法并没有创建出新的对象时
return null; //直接返回空指针
}
//上锁 这里都是通过create方法成功创建出了一个新对象的分支
synchronized (this) {
createCount++; //构建新对象数+1
mapValue = map.put(key, createdValue); //将新构建出来的对象放入到内部的LinkedHashMap中
//如果创建出来的值对应的不是一个新的键的话,也就是说同一个键对应了多个值的话,说明冲突了
if (mapValue != null) {
// 取消上述操作,感觉是一个乐观锁的实现
map.put(key, mapValue);
} else {
// 如果没有冲突的话,更新当前的缓存容量
size += safeSizeOf(key, createdValue);
}
}
//逻辑和上面一致,如果产生了冲突的话
if (mapValue != null) {
entryRemoved(false, key, createdValue, mapValue); //一个回调方法,在发生冲突或者一个缓存被释放时调用,默认无实现
return mapValue;//返回值
} else { //如果没有产生冲突
trimToSize(maxSize);//如果有必要的话,释放掉缓存区中最久没使用的缓存
return createdValue; //返回值
}
}
主要的逻辑注释已经在上面的代码中注释了,get方法的逻辑简单来说就是先尝试从缓存区中获取数据,缓存命中了就直接返回数据。否则会尝试调用create
方法来创建一个新的数据,create方法默认无实现。创建完毕之后先将新创建的数据放入到内部缓存区中,之后还要考虑冲突的情况,所谓的冲突就是指一个key对应了多个value的情况。如果产生了冲突就取消上面的将新创建的数据放入缓存区这个行为。如果无冲突就会更新内部缓存区当前的大小,最后调用trimToSize
方法对缓存区进行维护,具体就是当缓存区超出最大内存限制时将最久未使用的缓存清除出去。这就是整个get
方法的流程,这里整个流程中还涉及到了其他的方法,接下来我们看一看整个流程之中涉及到的其他方法。
safeSizeOf方法
这个方法是用来更新整个缓存区的内存容量的,它的逻辑也很简单:
private int safeSizeOf(K key, V value) {
int result = sizeOf(key, value);
if (result < 0) {
throw new IllegalStateException("Negative size: " + key + "=" + value);
}
return result;
}
可以看到,这整个方法就是调用我们之前重写的sizeOf
方法计算出了新的数据项占用内存的大小,然后将其返回出去。
trimToSize方法
这个方法是用来维护整个缓存区的容量大小的,具体来说,当当前的Size超过我们一开始传入的maxSize的话就会将缓存区中最久没有被使用的缓存项给清除出去:
public 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 || map.isEmpty()) {
break;
}
Map.Entry<K, V> toEvict = map.entrySet().iterator().next();//获取迭代器
key = toEvict.getKey();
value = toEvict.getValue();
map.remove(key);//移除数据
size -= safeSizeOf(key, value);
evictionCount++;
}
entryRemoved(true, key, value, null);
}
}
那究竟是怎么找到最久没使用的缓存的呢,答案之一是内部使用的LinkedHashMap
,前面说到过LinkedHashMap
内部可以维护一个列表来记录数据插入的顺序,这样在查找时也会维持一样的顺序吗,这样就保证了先存入且未被使用过的缓存总是在队首。第二个原因就是此处使用的迭代器,迭代器保证了每次都可以访问到下一个数据缓存项。不过这里也可以看到实际上LruCache并不保证缓存区的容量总是小于最大缓存容量,因为这里只是进行了一次迭代,而不是循环迭代,不能保证清除出去的那一项的内存容量大于等于新加入的那一项内存容量。
put方法
put方法是用来向缓存区添加数据用的,它的逻辑也很简单:
public final V put(@NonNull K key, @NonNull 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;
}
可以看到这里对冲突也进行了处理,与之前在get
方法里的不同,这里对待原数据的态度是直接覆盖,毕竟是put
方法,用来更新缓存中的数据也很合理。
LruCache是线程安全的
看了这么大段的源码,我们应该可以发现LruCache在对内部的LinkedHashMap
进行操作时都进行了上锁的操作,也就是说LruCache在理论上是线程安全的,我们可以在多线程的环境下安全地使用它。
DiskLruCache
DiskLruCache的意思是磁盘缓存,所谓磁盘缓存就是它会将缓存数据写入磁盘中而不是一直保存在运行内存中,它是Android官方所推荐的一种缓存,但是它并不在Android SDK中,也就是说我们无法直接使用它,这个缓存类在Glide
库中有用到,这并不意外,因为Android官方也推荐我们直接使用Glide
库进行图片的加载:
推荐我们使用磁盘缓存的原因也很简单,因为运行时内存是很有限的,而一般来说随着我们的图片越来越高清,将一个图片的数据完全缓存进入运行时内存是很不合算的,很容易就会出现内存不足的情况。当然磁盘缓存和内存缓存相比速度当然会慢一点。从它的名字中也可以大概知道这个缓存使用到的也是Lru策略。
DiskLruCache的使用
首先我们需要在Android项目中引入DiskLruCache的依赖:
implementation 'com.jakewharton:disklrucache:2.0.2'
DiskLruCache的创建
DiskLruCache并不能用构造方法直接创建出来,它提供了一个静态方法DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
来创建一个实例,其中第一个参数是缓存文件的目录,第二个参数是app的版本,一般写1即可,第三个参数是每个节点对应的数据项数目,一般也写一即可,最后是最大容量,和之前的LruCache是一样的。
给出一个例子,这里我们在Activity的环境下写,这样可以直接获取缓存:
public class testActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final int maxSize = 1024 * 1024 * 24;//最大容量设置为24MB
DiskLruCache disCache = null;
File diskCacheDir = getCacheDir();//获取当前应用的缓存目录
if (!diskCacheDir.exists()) { //如果缓存文件不存在则新创建一个缓存文件
diskCacheDir.mkdir();
}
try {
disCache = DiskLruCache.open(diskCacheDir,1,1,maxSize);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
这里我们直接调用getCacheDir
方法,它将返回一个绝对路径,这个路径指向当前应用的特定缓存文件。
向DiskLruCache中添加缓存
既然是磁盘缓存,那么DiskLruCache缓存的添加实际上和文件操作很类似,都需要借助输入和输出流来读写,为了获取输入和输出流需要获得Editor对象:
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLifecycle();
final int maxSize = 1024 * 1024 * 24;//最大容量设置为24MB
DiskLruCache disCache = null;
File diskCacheDir = getCacheDir();//获取当前应用的缓存目录
if (!diskCacheDir.exists()) { //如果缓存文件不存在则新创建一个缓存文件
diskCacheDir.mkdir();
}
BufferedOutputStream bus = null;
DiskLruCache.Editor mEditor = null;
try {
disCache = DiskLruCache.open(diskCacheDir,1,1,maxSize);
mEditor = disCache.edit("key");
//通过Editor获得缓存文件的输出流
bus = new BufferedOutputStream(mEditor.newOutputStream(0));//此处的0为下标,实际上就和打开缓存时传入的第三个参数有关
bus.write(new byte[1024]);//使用输入流进行修改
mEditor.commit();//提交修改
} catch (IOException e) {
throw new RuntimeException(e);
} finally { //这一整段都是关于资源的回收和异常情况的处理
try {
mEditor.abort(); //如果出现异常就取消修改
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
bus.close();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
bus = null
}
}
}
}
这里可以看到虽然我们是使用输出流进行缓存的操作的,但是最后还需要调用Editor的commit
来提交修改。这里还需要说明的是虽然我们可以通过edit()
方法来获取Editor方法,但是如果这个缓存正在被修改,那么edit()
会返回null,也就是说DiskLruCache是不允许同时编辑一个缓存对象的。
从缓存中查找数据
最后是从缓存中查找数据,这个过程和缓存的添加类似,我们可以通过DiskLruCache的get
方法可以获取到对应的Snapshot
对象,这个英文单词的名字是快照,通过这个快照对象我们可以获得对应的输入流来读取缓存数据,比如说bitmap的输入流数据我们可以通过BitmapFactory.decode
等方法进行解析。
BufferedInputStream bis = null;
try {
//通过DiskLruCache获取数据对应的SnapShot对象
DiskLruCache.Snapshot shot = disCache.get("key");
//获得对应的输入流
bis = new BufferedInputStream(shot.getInputStream(0)) ;
bis.read();
} catch (IOException e) {
throw new RuntimeException(e);
}
源码简单解析
由于DiskLruCache的源码很长,我们简单分析几个重要的方法。
open方法
首先来看创建DiskLruCache的open方法,这个方法是用来创建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");
}
//获得回退文件
File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
if (backupFile.exists()) { //若回退文件存在的话
File journalFile = new File(directory, JOURNAL_FILE);//创建日记文件
// 如果日记文件存在的话,删除回退文件
if (journalFile.exists()) {
backupFile.delete();
} else {
//当日记文件不存在的话,将回退文件重命名成日记文件
renameTo(backupFile, journalFile, false);
}
}
//调用构造方法创建出真正的DiskLruCache对象
DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
if (cache.journalFile.exists()) { //如果cache的日记文件存在的话
try {
cache.readJournal(); //读取日记文件
cache.processJournal(); //处理日记文件
cache.journalWriter = new BufferedWriter( //获得日记文件的字节流输出
new OutputStreamWriter(new FileOutputStream(cache.journalFile, true), Util.US_ASCII));
return cache;//返回cache
} catch (IOException journalIsCorrupt) {
System.out
.println("DiskLruCache "
+ directory
+ " is corrupt: "
+ journalIsCorrupt.getMessage()
+ ", removing");
cache.delete();//发生异常的话将cache删除
}
}
//这里是当cache的日记文件不存在的分支
directory.mkdirs();//根据目录创建文件
cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);//构造出实例
cache.rebuildJournal();//重建日记文件
return cache;//返回cache
}
重要的逻辑还是在上面已经标注出来了,可以看到这整个open
的过程中有几个比较重要的东西,其中之一就是所谓的日记文件和回退文件。在DiskLruCache的头部注释中介绍了这个日记文件,主要就是记录了DiskLruCache类的参数,比如说我们传入的APP版本等信息,除此之外这个日记文件还记录了文件的修改轨迹和具体的数据键值对。
get方法
接下来我们看get方法:
public synchronized Snapshot get(String key) throws IOException {
checkNotClosed();//检查当前缓存文件未被关闭
validateKey(key);//验证key的有效性
Entry entry = lruEntries.get(key);//获得通过lruEntries获得键值对,这个lruEntries也是一个LinkedHashMap
if (entry == null) { //如果获得的键值对为空直接返回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);
}
可以看到这里,这里通过一个lruEntries来获取数据,这个lruEntries具体是在日记文件的初始化过程中加载的:
private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' ');
if (firstSpace == -1) {
throw new IOException("unexpected journal line: " + line);
}
.........
Entry entry = lruEntries.get(key);
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);//加载数据
}
.........
}