LRU缓存原理
LRU(Least Recently Used),近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和DiskLruCache,分别用于实现内存缓存和硬盘缓存。其中内存缓存可以直接使用,磁盘缓存要使用第三方库。
争议点: 一般认为,最近使用的元素会移到队尾,删除的是头部元素。这样理解也没什么大问题。不过,根据LinkedHashMap的习惯,应该是插入的时候是加在队头,删除的是队尾元素,而遍历是从队尾向队头顺序进行。
- 下面这张图,将原理说得很清楚:从队头插入,从队尾删除。遍历的话,从队尾向队头遍历。
- trimToSize的作用是超过容量的时候做删除动作。从下面这张图可以明显的看到,遍历和删除的都是队尾的元素。
- LinkedHashMap的get()方法会将元素移到队头,这是通过recordAccess()方法实现的:先删除此元素,然后将此元素加到队头。
简要步骤
-
创建一个空白工程,就是那个Hello World
-
加载DiskLruCache的第三方库,一般用GitHub上的,在gradle文件中加下面这句,然后Sync一下:
// https://github.com/JakeWharton/DiskLruCache
compile 'com.jakewharton:disklrucache:2.0.2'
- 切换到Project标签视图,可以看到第三方的DiskLruCache已经下载了。
简单封装
一般这种考虑容量的缓存,在图片上用得比较多,因此封装成一个BitmapCache,包含内存缓存和磁盘缓存两部分。
内存缓存初始化
- LruCache是泛型,确定键值对的类型为<String, Bitmap>之后,需要重写sizeOf函数,需要给出Bitmap的大小。随着版本发展,Bitmap大小有不同的表示方法,所以做成一个静态方法,方便调用。
/**
* 得到bitmap的大小
*/
public static int getBitmapSize(Bitmap bitmap) {
// API 19
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return bitmap.getAllocationByteCount();
}
// API 12
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
return bitmap.getByteCount();
}
// 在低版本中用一行的字节x高度
return bitmap.getRowBytes() * bitmap.getHeight();
}
Android 计算Bitmap大小 getRowBytes和getByteCount()
- 初始化内存缓存:
- 设置LruCache缓存的大小,一般为当前进程可用容量的1/8。
- 重写sizeOf方法,计算出要缓存的每张图片的大小。
/**
* 初始化内存缓存
*/
private void initMemoryCache() {
// 线程总内存,以KB为单位。
final int maxMemory = (int)Runtime.getRuntime().maxMemory() / 1024;
// 缓存大小为总内存的1/8,以KB为单位。
final int cacheSize = maxMemory / 8;
memoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap value) {
// Bitmap, 以KB为单位。
return BitmapUtil.getBitmapSize(value) / 1024;
}
};
}
磁盘缓存初始化
- DiskLruCache的open函数需要File类型的directory参数,所以,先写一个工具方法,获取cache路径
/**
*
* @param context 上下文
* @param uniqueName 名字
* @return
*/
public static File getCacheDirectory(Context context, String uniqueName) {
// 获取cache目录,优先使用SD卡
String cachePath;
boolean isExternal = (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
|| !Environment.isExternalStorageRemovable());
if (isExternal) {
// SD卡存在或者不可被移除时,使用外部存储
// cachePath = /sdcard/Android/data/<application package>/cache
cachePath = context.getExternalCacheDir().getPath();
} else {
// SD卡不可用,使用内部存储
// cachePath = /data/data/<application package>/cache
cachePath = context.getCacheDir().getPath();
}
// 创建目录
File directory = new File(cachePath + File.separator + uniqueName);
//如果不存在,创建目录
if (!directory.exists()) {
directory.mkdir();
}
return directory;
}
- 每当版本号改变,缓存路径下存储的所有数据都会被清除掉,因为DiskLruCache认为当应用程序有版本更新的时候,所有的数据都应该从网上重新获取。所以DiskLruCache的open方法需要一个appVersion的参数。所以写一个获取版本号的工具方法。
/**
*
* @param context 上下文
* @return 版本号version Code
*/
public static int getAppVersion(Context context) {
// 默认填1
int appVersion = 1;
try {
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(),0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
appVersion = (int)packageInfo.getLongVersionCode();
} else {
appVersion = packageInfo.versionCode;
}
Log.d(TAG, "appVersionCode:" + appVersion);
} catch (PackageManager.NameNotFoundException e){
e.printStackTrace();
}
return appVersion;
}
- DiskLruCache的初始化并不是构造函数,而是open方法。
// 用类名做本类的TAG
private static String TAG = BitmapCache.class.getSimpleName();
// 硬盘缓存目录名字
private static final String DISK_CACHE_NAME = "DiskBitmapCache";
// 硬盘缓存大小,10M
private static final long DISK_CACHE_SIZE = 10 * 1024 * 1024;
/**
*
* @param context 上下文
*/
private void initDiskCache(Context context) {
File directory = FileUtil.getCacheDirectory(context, DISK_CACHE_NAME);
int appVersion = AppUtil.getAppVersion(context);
try {
diskCache = DiskLruCache.open(directory, appVersion, 1, DISK_CACHE_SIZE);
Log.d(TAG, "initDiskCache finished");
} catch (IOException e) {
e.printStackTrace();
}
}
Key 的选择
硬盘缓存和磁盘缓存都需要String类型的Key。如果是本地资源图片,没有必要用到缓存。所以一般是加载网络图片,才要用到缓存。直观理解,就会想到用图片的url字符串来代表不同的图片。由于url可能含有特殊字符,作为Key不合适,所以做一下转换,将url字符串md5一下,然后作为key。
/**
* 将url字符串转换为MD5字符串
* @param urlString
* @return
*/
public static String urlStringToMd5String(String urlString) {
String md5String;
try {
final MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(urlString.getBytes());
md5String = EncodeUtil.bytesToHexString(digest.digest());
} catch (NoSuchAlgorithmException e){
md5String = String.valueOf(urlString.hashCode());
}
return md5String;
}
/**
* byte转16进制字符串
* @param bytes 字节数组
* @return 16进制字符串
*/
public static String bytesToHexString(byte[] bytes) {
StringBuffer sb = new StringBuffer();
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是通过本地输出流OutputStream的方式写入的,所以需要有一个方法,将网络图片下载到本地的输出流进行对接。
/**
* 将图片下载到本地输出流
* @param urlString 图片url
* @param outputStream 本地输出流
* @return 下载是否成功
*/
public static boolean downloadUrlToStream(String urlString, OutputStream outputStream) {
HttpURLConnection urlConnection = null;
BufferedOutputStream out = null;
BufferedInputStream in = null;
try {
final URL url = new URL(urlString);
urlConnection = (HttpURLConnection)url.openConnection();
// Buffer大小使用默认的8K,8 * 1024 = 8192字节
out = new BufferedOutputStream(outputStream);
in = new BufferedInputStream(urlConnection.getInputStream());
int b;
while ((b = in.read()) != -1) {
out.write(b);
}
return true;
} catch (final IOException e){
e.printStackTrace();;
} finally {
try {
if (urlConnection != null) {
urlConnection.disconnect();
}
if (out != null) {
out.close();
}
if (in != null) {
in.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 默认不成功
return false;
}
下载并写入磁盘缓存
以流的方式从url下载图片,然后写入本地缓存。这个过程不需要转化为Bitmap,直接操作流。这是DiskLruCache写入的方式,以流的方式进行对接,确实有点别扭。这个过程由于涉及到网络下载,所以耗时较长。
/**
* 下载图片并写入硬盘缓存;这个过程耗时,放入子线程执行
* @param imageUrl 图片url
* @return 执行结果
*/
public boolean downloadUrlToDiskCache(String imageUrl) {
try {
String key = EncodeUtil.urlStringToMd5String(imageUrl);
DiskLruCache.Editor editor = diskCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (NetworkUtil.downloadUrlToStream(imageUrl, outputStream)) {
editor.commit();
Log.v(TAG, "下载" + imageUrl + "到磁盘缓存");
return true;
} else {
editor.abort();
return false;
}
}
} catch (IOException e) {
e.printStackTrace();
}
// 默认不成功
return false;
}
从磁盘缓存读取Bitmap
如果图片已经下载过一次,那么就存在于磁盘缓存中,那么就可以从磁盘缓存中读出来。这个时候是InputStream,可以转化为Bitmap格式,同时存一份到内存缓存中,下次可以直接从内存缓存中读。DiskLruCache读是通过snapshot的方式完成的,也很别扭。
/**
* 从磁盘缓存读入图片,并转存到内存缓存中
* @param imageUrl 图片url
* @return Bitmap
*/
private Bitmap getBitmapFromDiskCache(String imageUrl) {
Bitmap bitmap = null;
try {
String key = EncodeUtil.urlStringToMd5String(imageUrl);
DiskLruCache.Snapshot snapshot = diskCache.get(key);
if (snapshot != null) {
InputStream inputStream = snapshot.getInputStream(DISK_CACHE_INDEX);
bitmap = BitmapFactory.decodeStream(inputStream);
// 如果成功读取了,存入内存缓存中
if (bitmap != null) {
memoryCache.put(key, bitmap);
}
}
} catch (IOException e) {
e.printStackTrace();
}
return bitmap;
}
读取Bitmap
对于使用者来说,只是想从缓存读取Bitmap,并不关心来源。所以可以再包一层。如果曾经读取过,那么就来自内存缓存,速度是最快的。如果曾经下载过,但是还没读取过,那么就需要从磁盘缓存中读取,速度稍微慢一点,不过也是比较快的,(读文件的速度)。如果硬盘缓存中也没有,j就返回null。
/**
* 从缓存中读取Bitmap
* @param imageUrl 图片url
* @return Bitmap;如果内存中没有,返回null
*/
public Bitmap getBitmap(String imageUrl) {
Bitmap bitmap = null;
try {
String key = EncodeUtil.urlStringToMd5String(imageUrl);
// 先从内存缓存读取; 如果有,直接返回
bitmap = memoryCache.get(key);
if (bitmap != null) {
Log.v(TAG, "来自内存缓存");
return bitmap;
}
// 内存缓存中没有,从磁盘缓存读取
bitmap = getBitmapFromDiskCache(imageUrl);
if (bitmap != null) {
Log.v(TAG, "来自磁盘缓存");
return bitmap;
}
} catch (Exception e) {
e.printStackTrace();
}
// 内存缓存和磁盘缓存中都没有
return null;
}
加载图片
由于从网络下载图片比较耗时,所以要引入多线程。由于是异步的,所以得到的Bitmap需要通过回调函数的方式回传给调用者。另外,由于Bitmap的使用一般是UI进程,所以回调函数的执行在主线程中。
// 主线程Handler
private static Handler mainHandler = new Handler(Looper.getMainLooper());
// 回调函数
public interface BitmapLoaderCallback {
void success(Bitmap bitmap);
void failure(String message);
}
// 加载
public void loadBitmap(final String imageUrl, final BitmapLoaderCallback callback) {
// 如果内存中有,从内存中取
final Bitmap bitmap = cache.getBitmap(imageUrl);
if (bitmap != null) {
mainHandler.post(new Runnable() {
@Override
public void run() {
if (callback != null) {
callback.success(bitmap);
}
}
});
return;
}
// 内存中没有,需要下载,这个过程耗时比较长,放子线程中执行
// 图片经常用,所以这里用线程池管理一下比较好
new Thread(new Runnable() {
@Override
public void run() {
// 下载并写入磁盘缓存
boolean isOk = cache.downloadUrlToDiskCache(imageUrl);
if (!isOk) {
mainHandler.post(new Runnable() {
@Override
public void run() {
if (callback != null) {
callback.failure("下载并缓存图片失败,url:" + imageUrl);
}
}
});
return;
}
// 下载完成后,再从缓存中读一次,从磁盘缓存读入到内存缓存
final Bitmap bitmapFromNetwork = cache.getBitmap(imageUrl);
mainHandler.post(new Runnable() {
@Override
public void run() {
if (callback != null) {
if (bitmapFromNetwork != null) {
callback.success(bitmapFromNetwork);
} else {
callback.failure("获取图片失败,url:" + imageUrl);
}
}
}
});
}
}).start();
}
小结:通过以上封装,只要调用loadBitmap这一个方法就可以了。
其他接口封装
/**
* 清空缓存
*/
public void clear() {
// 磁盘缓存
memoryCache.evictAll();
// 硬盘缓存
try {
diskCache.delete();
// 清除之后会自动关闭,要重新打开
initDiskCache(context);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 缓存大小信息
* @return 信息字符串,Kb
*/
public String size() {
int memorySize = memoryCache.size();
long diskSize = diskCache.size();
return ("内存缓存:" + memorySize/1024 + "Kb;" + "磁盘缓存:" + diskSize/1024 + "Kb。");
}
/**
* 删除指定图片
* @param imageUrl 图片的url
*/
public void removeImage(String imageUrl) {
String key = EncodeUtil.urlStringToMd5String(imageUrl);
if (key == null) {
return;
}
try {
memoryCache.remove(key);
diskCache.remove(key);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 仅磁盘缓存在某些情况下需要,比如进入后台
*/
public void flush() {
try {
diskCache.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
测试Demo
- 测试demo可以简洁一点,比如这样
- 因为涉及到网络,所以要用到权限
<!-- 允许程序打开网络套接字 -->
<uses-permission android:name="android.permission.INTERNET" />
-
另外图片的url要求是https的,不然网络连不上
-
MainActivity的代码类似这样的:
public class MainActivity extends AppCompatActivity {
// 图片缓存
private BitmapLoader bitmapLoader;
// 测试url,网上随便选的
final String imageUrl = "https://img.ithome.com/newsuploadfiles/2014/12/20141223_115629_592.jpg";
private Context context;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
context = getApplicationContext();
bitmapLoader = new BitmapLoader(context);
}
@Override
protected void onPause() {
super.onPause();
bitmapLoader.flush();
}
// 获取测试图片
public void getImageButtonClicked(View view) {
bitmapLoader.loadBitmap(imageUrl, new BitmapLoader.BitmapLoaderCallback() {
@Override
public void success(Bitmap bitmap) {
ImageView getButtonImageView = (ImageView)findViewById(R.id.img_test);
getButtonImageView.setImageBitmap(bitmap);
}
@Override
public void failure(String message) {
Toast.makeText(context, message, LENGTH_LONG).show();
}
});
}
// 获取缓存大小信息
public void getSizeButtonClicked(View view) {
String sizeInfo = bitmapLoader.size();
if (sizeInfo != null) {
TextView sizeTextView = (TextView)findViewById(R.id.text_size);
sizeTextView.setText(sizeInfo);
}
}
// 移除测试图片
public void removeTestImageButtonClicked(View view) {
bitmapLoader.removeImage(imageUrl);
}
}
网友评论