凡永恒伟大的爱,都要绝望一次,消失一次,一度死,才会重获爱,重新知道生命的价值。——《文学回忆录》
1、概述
由于Bitmap的特殊性以及Android对单个进程应用只分配16M的内存,这导致加载Bitmap的时候很容易出现内存泄漏。为了解决这个问题,引入了缓存策略。
缓存策略是一个通用的思想,可以用在很多场景中,在实际开发中,经常用Bitmap做缓存。通过缓存策略,我们不需要每次从网络上请求图片或者从存储设备中加载图片,这样就极大的提高了图片的加载效率以及产品的用户体验。
目前常用的缓存策略,LruCache常被用作内存缓存,DiskLruCache常被用作存储缓存。Lru(Least Recently Used),即最近最少使用算法。核心思想:当缓存快满时,会淘汰近期最少使用的缓存目标。
2、三级缓存
三级缓存策略,通过网络、本地、内存三级缓存图片,来减少不必要的网络交互,避免浪费流量。
- 网络加载,不优先加载,速度慢,浪费流量
- 本地缓存,次优先加载,速度快
- 内存缓存,优先加载,速度最快
三级缓存原理
- 首次加载 Android App 时,肯定要通过网络交互来获取图片,之后我们可以将图片保存至本地SD卡和内存中
- 之后运行 App 时,优先访问内存中的图片缓存,若内存中没有,则加载本地SD卡中的图片
- 总之,只在初次访问新内容时,才通过网络获取图片资源
3、Bitmap的高效加载
缓存实现最终目的是为了图片的高效显示,所以先介绍Bitmap的高效加载。
3.1、Bitmap加载
BitmapFactory类提供了四类方法:decodeFile、decodeResource、decodeStream和decodeByteArray,分别用于从文件系统、资源、输入流以及字节数组中加载一个Bitmap对象,其中decodeFile和decodeResource间接调用了decodeStream方法。
3.2、高效加载Bitmap
采用BitmapFactory.Options来加载所需尺寸的图片。这里假设通过ImageView来显示图片,很多时候,ImageView的尺寸并没有图片的原始尺寸那么大,这个时候把整个图片加载进来再设给ImageView,这显然是没有必要的,因为ImageView并没有办法显示原始图片。
通过BitmapFactory.Options就可以按一定的采样率来加载缩小后的图片,将缩小后的图片在ImageView中显示,就可以降低内存的使用,在一定程度上避免OOM,提高Bitmap加载时的性能。
BitmapFactory.Options中的inJustDecodeBounds参数:
当此参数为true时,BitmapFactory只会解析图片的原始宽/高信息,并不会真正加载图片,这个操作是轻量级的。这里需要注意的是,Bitmap获取的图片宽/高信息和图片的位置以及程序运行的设备有关,比如同一张图片放在不同的drawable目录下或运行在不同屏幕密度的设备上,这都可能导致BitmapFactory获取不同的结果。
BitmapFactory.Options中的inSampleSize参数:
通过BitmapFactory.Option来缩放图片,主要用到它的inSampleSize参数即采样率。当inSampleSize为1时,采样后的图片大小为图片的原始大小;当inSampleSize为2时,采样后的图片大小宽/长为图片的原始大小的1/2,而像素数为原来的1/4,内存也为原来的1/4。
获取采样率流程:
(1)将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片;
(2)从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数;
(3)根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize;
(4)将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片。
代码实现:
package com.chunsoft.bitmapcache;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Log;
import java.io.FileDescriptor;
/**
* Developer:chunsoft on 2017/3/27 15:22
* Email:chun_soft@qq.com
* Content:图片压缩,降低OOM概率
*/
public class ImageResizer {
private static final String TAG = "ImageResizer";
public ImageResizer() {
}
public Bitmap decodeSampledBitmapFromResource(Resources res,
int resId, int reqWidth, int reqheight) {
// 1.将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId,options);
// 2.从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数
// 3.根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqheight);
// 4.将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res,resId, options);
}
public Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor fd,
int reqWidth,int reqHeight) {
// 1.将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fd, null, options);
// 2.从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数
// 3.根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 4.将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFileDescriptor(fd, null, options);
}
private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqheight) {
if (reqheight == 0 || reqWidth == 0) {
return 1;
}
// 从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数
final int height = options.outHeight;
final int width = options.outWidth;
Log.e(TAG,"origin, w=" + width +" h=" + height);
int inSampleSize = 1;
if (height > reqheight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
while ((halfHeight / inSampleSize) >= reqheight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
Log.e(TAG, "sampleSize:" + inSampleSize);
return inSampleSize;
}
}
4、LruCache
LruCache是一个泛型类,它内部采用了LinkedHashMap以强引用的方式存储外界的缓存对象,其提供了get和put方法来完成缓存的获取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象。LruCache是线程安全的。
代码实现:
//求最大内存大小,换成KB单位
int maxMemory = (int)(Runtime.getRuntime().maxMemory() / 1024);
//定义缓存为内存的1/8
int cacheSize = maxMemory / 8;
//内存缓存
mMemoryCache = new LruCache<String, Bitmap>(cacheSize){
//Bitmap大小的计算
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getRowBytes()*bitmap.getHeight() / 1024;
}
};
//添加图片到内存缓存
private void addBitmapToMemoryCache(String key,Bitmap bitmap) {
if (getBitmapFromMemCache(key) == null) {
mMemoryCache.put(key, bitmap);
}
}
//从内存缓存中获取图片
private Bitmap getBitmapFromMemCache(String key) {
return mMemoryCache.get(key);
}
5、DiskLruCache
DiskLruCache用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统,从而实现缓存的效果。它不属于Android SDK的一部分,源码链接。
5.1、DiskLruCache的创建
DiskLruCache不能通过构造方法来创建,它提供了open方法创建自身
/*** 参数1:磁盘缓存文件在文件系统中的存储路径
* 参数2:应用版本号,一般设置为1
* 参数3:单个节点对应的数据个数,一般设置为1
* 参数4:缓存的总大小
*/
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
5.2、DiskLruCache的缓存添加
DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象。首先需要获取图片url所对应的key,然后根据ke y就可以通过edit()来获取Editor对象,如果这个缓存正在被编辑,edit()会返回null,即DiskLruCache不允许同时编辑一个缓存对象,之所以要把url转换成key,是因为图片中的url中可能存在特殊字符,这将影响url在Android中直接使用,一般采用url的md5值作为key。
//将URL转化为md5值作为key
//图片中的url很有可能有特殊字符,这将影响url在Android直接使用,一般采用url的md5值作为key
private String hashKeyFromUrl(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;
}
//转化为16进制
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对象,通过它就可以得到一个文件输出流。
String key = hashKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)) {
editor.commit();
} else {
editor.abort();
}
mDiskLruCache.flush();
}
有了文件输出流,当从网络下载图片时,图片就可以通过这个文件输出流写入到文件系统上。
public 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();
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, "downloadBitmap failed." + e);
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
MyUtils.close(out);
MyUtils.close(in);
}
return false;
}
经过上面的步骤,其实并没有真正地将图片写入文件系统,还必须通过Editor()的commit()来提交写入操作。如果下载过程发生异常,那么可以通过Editor的abort()来回退下载操作。
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)) {
editor.commit();
} else {
editor.abort();
}
mDiskLruCache.flush();
5.3、DiskLruCache的缓存查找
和缓存的添加过程类似,缓存查找过程也需要将url转化为key,然后通过DiskLruCache的get方法得到一个Snapshot对象,接着再通过Snapshot对象即可获得缓存的文件输入流,有了文件输入流就可以得到Bitmap对象。通过文件流来得到文件描述符,再通过BitmapFactory.decodeFileDescriptor方法来加载一张缩放后的图片:
Bitmap bitmap = null;
String key = hashKeyFromUrl(url);
//磁盘缓存的读取需要通过Snapshot来完成
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
if (snapShot != null) {
//通过Snapshot可以得到磁盘缓存对象的FileInputStream
FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(DISK_CACHE_INDEX);
//FileInputStream无法便捷地进行压缩,所以通过FileDescriptor来加载压缩后的图片,
//最后将加载后的Bitmap添加到内存缓存中
FileDescriptor fileDescriptor = fileInputStream.getFD();
bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(fileDescriptor,
reqWidth, reqHeight);
if (bitmap != null) {
addBitmapToMemoryCache(key, bitmap);
}
那么这样就实现了Android的三级缓存,具体实现代码在文末会给出。
6、图片三级缓存加载实现
(1)图片的同步加载
图片的同步加载是指能够以同步的方式向调用者提供加载的图片,这个图片可能是从缓存中读取的,也可能是从磁盘缓存中读取的,还可能是从网络拉取的。
(2)图片的异步加载
很多时候调用者不想在单独的线程中以同步的方式来获取图片,这时候需要在线程中加载图片,并将图片设置给需要的ImageView。
(3)图片压缩
降低OOM概率的有效手段
(4)内存缓存
(5)磁盘缓存
(6)网络拉取
具体代码在文末。
7、优化列表的卡顿现象
主要解决方案就一条,不要在主线程中做太耗时的操作。主要三个方面。
(1)不要在getView中执行耗时操作。如果直接在getView中加载图片,肯定会导致卡顿,因为加载图片是一个耗时操作,因此采用异步操作。
(2)控制异步任务的执行频率。以照片墙来说,在getView方法中会通过异步方法来异步加载图片,但是如果用户刻意地频繁上下滑动,这在一瞬间产生上百个异步任务,这些异步任务会造成线程池的拥堵并随即带来大量的UI更新操作,这是没有意义的。
可以考虑在列表滑动的时候停止加载图片,尽管这个过程是异步的,等列表停下来以后再加载图片仍然可以获得良好的用户体验。具体实现时,可以考虑给ListView或GridView设置setOnScrollListener,并在OnScrollListener的onScrollStatechanged方法中判断列表是否处于滑动状态,如果是的话就停止加载图片:
if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
mIsGridViewIdle = true;
mImageAdapter.notifyDataSetChanged();
} else {
mIsGridViewIdle = false;
}
然后在getView方法中,仅当列表停止时才能加载图片。
if (mIsGridViewIdle && mCanGetBitmapFromNetWork) {
imageView.setTag(uri);
mImageLoader.bindBitmap(uri, imageView, mImageWidth, mImageWidth);
}
(3)硬件加速。一般来说,经过上面两个步骤,列表都不会有卡顿现象,在绝大数情况下,硬件加速可以解决莫名的卡顿现象,还可以设置android:hardwareAcclerated=”true”即可为Activity开启硬件加速。
8、总结
本文介绍了图片加载、三级缓存策略、列表的滑动流畅性,通过文末的demo,可以更好的理解本文内容。
=====================================================