LruCache 内存缓存
LruCache基本使用
//初始化
mLruCache = new LruCache(100){
@Override
protected int sizeOf(@NonNull String key, @NonNull Bitmap value) {
return value.getByteCount() / 1024/1024;
}
};
//存
mLruCache.put("key","value");
//取
mLruCache.get("key");
LruCache是如何实现最优算法的
public class LruCache {
private final LinkedHashMap map;
......
}
LruCache类第一行我们就可以知道原来LruCache是通过LinkedHashMap
类来进行数据存储的。而LinkedHashMap类是HashMap的子类,通过学习我们知道LinkedHashMap在HashMap的基础上额外维护了一个链表,其内部已经内置了最优算法策略。只需要在初始化LinkedHashMap的时候(int initialCapacity,float loadFactor, boolean accessOrder)将accessOrder设置为true
就可以开启最优算法策略(保持访问顺序)。
其中需要注意的是初始化时候LruCache中参数为最大存放的值,而重写的sizeOf需要和这个值是同一个单位。
那么LruCache是如何将LinkedHashMap的最优算法和缓存大小结合起来的呢?
我们来看一看put方法:
public final V put(K key, 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;
}
put方法中首先针对key、value进行判空,然后将值put进map中,依据put的返回值(为空成功、不为空失败)进行处理,最后调用trimToSize
方法。
private 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) {
break;
}
// BEGIN LAYOUTLIB CHANGE
// get the last item in the linked list.
// This is not efficient, the goal here is to minimize the changes
// compared to the platform version.
Map.Entry toEvict = null;
for (Map.Entry entry : map.entrySet()) {
toEvict = entry;
}
// END LAYOUTLIB CHANGE
if (toEvict == null) {
break;
}
key = toEvict.getKey();
value = toEvict.getValue();
map.remove(key);
size -= safeSizeOf(key, value);
evictionCount++;
}
entryRemoved(true, key, value, null);
}
}
从trimToSize源码中来看,当size小于最大值时跳过,而大于最大值时通过map.entrySet()
获取链表的最后一项,遍历移除。从而达到最优的效果。
DiskLruCache磁盘缓存
我们在日常开发中经常会遇到加载多图OOM问题,而解决OOM问题最常用的技术就是使用LruCache技术。LruCache技术主要是管理内存中图片的存储与释放,一旦图片从内存中移除,那么再次使用的话就需要再次从网络上面加载,这非常耗费资源。针对此Google提供了一套磁盘缓存技术DiskLruCache(非Google官方编写,但获得官方认证),下面我们来学习下DiskLruCache相关技术。
DiskLruCache的使用
下面我们来学习一下DiskLruCache的使用步骤。首先我们要知道,DiskLruCache的构造方法是私有的,所以他不能直接new出来,如果我们需要创建它的实例,我们需要调用它的open方法:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
open方法参数有四个,分别为缓存路径
、当前程序的版本号
、同一个key可以缓存的数量
(通常是1)和缓存的最大字节数
。
首先是缓存路径通常存放在/sdcard/Android/data/
这个路径下面,此路径是在sdk中既不影响内置存储又能够随apk卸载而删除避免脏数据的产生。手机有可能没有存储卡或者存储卡没有挂载,针对这种情况我们需要将缓存存放到内置存储上面。下面是兼容的代码:
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或者其他缓存类型。
其次是版本号,我们可以通过PackageManager
获取版本号代码如下:
public static synchronized int getVersionCode(Context context) {
try {
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(), 0);
return packageInfo.versionCode;
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
需要注意的是,每当版本号改变,缓存路径下存储的所有数据都会被清除掉,因为DiskLruCache认为当应用程序有版本更新的时候,所有的数据都应该从网上重新获取。
第三个参数默认是1,最后一个参数通常传个10M就行,可依据自身情况来设置。
因此一个完整的open方法调用应该是:
File cacheDir = getDiskCacheDir(this,"bitmap");
if (!cacheDir.exists()){
cacheDir.mkdirs();
}
try {
DiskLruCache diskLrucache = DiskLruCache.open(cacheDir,getVersionCode(this),1,10*1024*1024);
} catch (IOException e) {
e.printStackTrace();
}
首先获取存储路径,路径不存在则创建,然后将参数分别传入到open方法中。
此时我们有了DiskLruCache实例,就可以对缓存的数据进行操作了,操作包含读取 、写入还有删除等等,我们一一来学习。
写入操作
首先我们来看一下写入操作,为了将网络图片写入到磁盘中,我们需要先将其下载到本地,那么我们来写一下下载的操作:
public boolean downloadImage(String path, OutputStream outputStream){
HttpURLConnection httpURLConnection = null;
BufferedOutputStream bufferedOutput = null;
BufferedInputStream bufferedInput = null;
try {
URL url = new URL(path);
httpURLConnection = (HttpURLConnection) url.openConnection();
InputStream inputStream = httpURLConnection.getInputStream();
bufferedInput = new BufferedInputStream(inputStream);
bufferedOutput = new BufferedOutputStream(outputStream);
int read;
while ((read = bufferedInput.read()) != -1){
bufferedOutput.write(read);
}
return true;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (null != httpURLConnection) {
httpURLConnection.disconnect();
}
try {
if (null != bufferedInput) {
bufferedInput.close();
}
if (null != bufferedOutput) {
bufferedOutput.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
}
下载的操作是访问网络路径的网址,将其通过OutputStream写入到本地。有了这个方法后,我们就可以通过DiskLruCache进行写入了,写入的操作通过DiskLruCache.Editor这个类完成。DiskLruCache.Editor的构造方式是私有的,所以它是通过DiskLruCache的edit
方法来获取的:
DiskLruCache.Editor editor = mDiskLruCache.edit(MD5Util.encodeKey(path));
考虑到特殊字符等情况路径URL不能直接作为key来使用,通常情况下我们将对URL采用MD5加密
的方式来获得最终的key。如下图所示:
/**
* 将key进行MD5编码
*/
public static String encodeKey(String key) {
String cacheKey;
try {
final MessageDigest mDigest = MessageDigest.getInstance("MD5");
mDigest.update(key.getBytes());
cacheKey = bytesToHexString(mDigest.digest());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
cacheKey = String.valueOf(key.hashCode());
}
return cacheKey;
}
private static 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();
}
拿到DiskLruCache.Editor
实例,我们就可以通过newOutputStream获取到OutputStream,这时候我们调用之前的下载方法完成下载保存工作,示例代码如下:
OutputStream outputStream = editor.newOutputStream(0);
if (downloadImage(path, outputStream)){
editor.commit();
} else {
editor.abort();
}
我们之前调用open方法传入的第三个参数valueCount为1,也就是说一个key对应一条数据,因此newOutputStream参数传0,意思就是获取key对应值的列表的第一个数据。
downloadImage返回值为true说明下载成功了,因此我们需要commit一下,返回false则代表失败要abort掉。
所以最终保存下载资源的的代码为:
public void download(){
new Thread(new Runnable() {
@Override
public void run() {
try {
DiskLruCache.Editor editor = mDiskLruCache.edit(MD5Util.encodeKey(path));
if (null != editor) {
OutputStream outputStream = editor.newOutputStream(0);
if (downloadImage(path, outputStream)){
editor.commit();
} else {
editor.abort();
}
}
mDiskLruCache.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
读取操作
文件下载成功了,现在我们来看一下读取的操作。读取操作是通过Snapshot
这个类来完成,那么我们来看一下获取这个类实例的代码:
String key = MD5Util.encodeKey(path);
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
获取到MD5加密
过后的Key,然后通过DiskLruCache的get方法就可以获取。获取到Snapshot 对象后通过getInputStream方法获取到InputStream流,然后使用 BitmapFactory.decodeStream获取到Bitmap对象,拿到
Bitmap对象后可以直接设置给ImageView。读取的操作完成,下面来看看读取的完整代码:
String key = MD5Util.encodeKey(path);
DiskLruCache.Snapshot snapShot = null;
try {
snapShot = mDiskLruCache.get(key);
if (snapShot != null) {
InputStream is = snapShot.getInputStream(0);
Bitmap bitmap = BitmapFactory.decodeStream(is);
mImg.setImageBitmap(bitmap);
}
} catch (IOException e) {
e.printStackTrace();
}
删除操作
学完了存储和读取后,我们来看一看删除操作。删除操作是通过DiskLruCache的remove方法来完成的:
public synchronized boolean remove(String key) throws IOException
代码很熟悉了,依据路径生成一个key,然后调用remove方法完成删除操作。
String key = MD5Util.encodeKey(path);
try {
mDiskLruCache.remove(key);
} catch (IOException e) {
e.printStackTrace();
}
其实大多是情况并不需要我们自己去调用这个方法,因为在我们调用open方法的时候传入了最大值,当存储大小达到最大值时DiskLruCache最自己删除掉不活跃的资源,其内部最优算法和LruCache一样都是依据LinkedHashMap来实现的。只有在资源过期的时候如果我们需要加载最新的资源,这时候我们才会主动去调用这个方法。
其余操作
除了存储、读取和删除外,DiskLruCache还提供一些其他的api。下面我们来简单的过一下。
1.size()
获取存储路径下的总字节数,以byte为单位,如果应用需要展示缓存大小,可以通过此方法展示出来。
2.flush()
这个方法用于将内存中的操作记录同步到日志文件(也就是journal文件)当中。这个方法非常重要,因为DiskLruCache工作的核心就是要依赖journal文件中的内容。之前写入缓存操作的时候有调用过一次这个方法,但其实并不是每次写入缓存都要调用一次flush()方法的,频繁地调用只会额外增加同步journal文件的时间。比较标准的做法就是在Activity的onPause()方法中进行一次调用就行了。
3.close()
该方法是关闭DiskLruCache的方法,和open方法相对应。一旦调用了该方法,后续将不能再调用DiskLruCache任何操作数据的方法,所以改方法应该在销毁方法中执行。
4.delete()
删除所有数据的方法,如果需要清除缓存功能,可以调用此方法。
journal文件学习
journal文件是DiskLruCache的核心,路径在缓存目录下。所有DiskLruCache相关的操作都绕不开它。下面我们来看下该文件的数据:
libcore.io.DiskLruCache
1
1
1
DIRTY 9ada4c71e7d4e9dadd46620ea05a83d7
CLEAN 9ada4c71e7d4e9dadd46620ea05a83d7 31335
READ 9ada4c71e7d4e9dadd46620ea05a83d7
REMOVE 9ada4c71e7d4e9dadd46620ea05a83d7
第一行libcore.io.DiskLruCache代表我们使用DiskLruCache技术。
第二行1代码缓存的版本。
第三行1代表我们的app版。
第四行1是open传入的第三个参数,代表一个key对应几个值
第五行是空白行
以上就是journal文件的头信息。
在往后文件中的每行都是缓存条目状态的记录。每行包含以空格分隔的值:状态,键和可选的数据大小值。
下面我们来看下各个状态的含义:
DIRTY
:DIRTY行代表正在创建和更新的条目,每个DIRTY动作后面都应该有CLEAN行为或者REMOVE行为,如果没有则代表可能是需要删除的临时文件。
CLEAN
:CLEAN行是记录已成功发布并可以读取的缓存条目,后面会加入每个值的长度。
READ
:READ行是记录Lru访问的行为。
REMOVE
:REMOVE行记录已删除的条目。
DiskLruCache源码解读
学完了journal文件相关信息,那么我们来学习一下DiskLruCache是如何结合journal完成缓存处理的。
DiskLruCache通过open方法获取实例,那么我们来看一下open方法:
open方法
/**
* Opens the cache in {@code directory}, creating a cache if none exists
* there.
*
* @param directory a writable directory
* @param appVersion appVersion
* @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
* @return DiskLruCache
* @throws IOException if reading or writing the cache directory fails
*/
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
throws IOException {
...
// 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;
}
open方法的目前就是初始化DiskLruCache
实例,当缓存路径journal文件存在时,代表已经缓存过资源,调用readJournal
方法加载journal文件数据,不存在时则通过rebuildJournal
方法初始化journal文件。
我们来看一下这两个方法做了什么操作:
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) {
...
readJournalLine(reader.readLine());
lineCount++;
...
}
...
}
方法开头的操作读取journal文件的头信息进行异常判断。接着就是通过readJournalLine循环遍历每一行,然后将数据填充LinkedHashMap中。代码如下:
private void readJournalLine(String line) throws IOException {
...
if (secondSpace == -1) {
key = line.substring(keyBegin);
if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
lruEntries.remove(key);
return;
}
} else {
key = line.substring(keyBegin, secondSpace);
}
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);
}
}
可以看到填充的过程中会根据状态行的状态进行不同的操作。接着看下rebuildJournal方法:
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));
}
rebuildJournal方法中主要是针对journal
、journal.tmp
和journal.bkp
的处理。先在journal.tmp中写入头信息,如果journal存在的话将journal改名为journal.bkp,然后将journal.tmp改名为journal,最后删除journal.bkp文件,并初始化BufferedWriter对象。
看完了open方法后我们来看下存储操作,存储操作通过DiskLruCache的edit方法完成,那么我们看一下该方法:
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;
}
可以看到该方法创建了Editor对象,并写入DIRTY
状态行。通过Editor对象,可以直接获取到对应的文件路径,数据下载完成后调用editor.commit()
方法,下载失败调用editor.abort()
方法。定位发现这两个方法最终调用的都是completeEdit
方法,只不过是否成功的参数不一样。
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
...
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);
}
}
可以看到下载成功会写入CLEAN
状态行,下载失败会写入REMOVE
行,所以DIRTY行后面必须跟着CLEAN行和REMOVE行。否则的话就是无效行。
其余的方法大差不差。我们略过直接来关注最优算法策略。在completeEdit的最后一行,看到当当前值大于最大值时,触发executorService线程池触发cleanupCallable
。
new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue());
private final Callable cleanupCallable = new Callable() {
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) {
Map.Entry toEvict = lruEntries.entrySet().iterator().next();
remove(toEvict.getKey());
}
}
cleanupCallable
的call方法中执行trimToSize方法,这个方法和LruCache中的方法作用一样,利用LinkedHashMap完成最优算法策略。
总结
1.LruCache和DiskLruCache都是借助LinkedHashMap来完成最优算法的。
2.DiskLruCache磁盘缓存核心是journal文件
3.journal文件的每一行都是一个操作状态
4.DIRTY状态行后面必须跟随CLEAN行和REMOVE行,否则就是无效行。