这是我参与「第四届青训营 」笔记创作活动的第13天
Bitmap的加载和三级缓存
一、Bitmap的高效加载
1、Bitmap
在安卓中指的是一张图片,可以是png图片也可以是jpg等其他格式
2、BitmapFactory类提供了四类方法来加载图片
decodeFile、decodeResource、decodeStream、decodeByteArray,分别用于支持从文件系统、资源、输入流以及字节数组中加载出一个Bitmap对象,其中decodeFile和decodeResource又间 接调用了decodeStream方法,这四类方法最终是在Android的底层实现的,对应着BitmapFactory类的几个native方法
3、图片的高效加载
核心思想:采用BitmapFactory.Options来加载所需尺寸的图片
假设通过 ImageView来显示图片,很多时候ImageView并没有图片的原始尺寸那么大,这个时候把整个图片加载进来后再设给ImageView,这显然是 没必要的,因为ImageView并没有办法显示原始的图片。通过BitmapFactory.Options就可以按一定的采样率来加载缩小后的图片,将缩小后 的图片在ImageView中显示,这样就会降低内存占用从而在一定程度上避免OOM,提高了Bitmap加载时的性能
4、获取采样率流程
(1)将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片。 (2)从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数
(3)根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize。 (4)将BitmapFactory.Options的inJustDecodeBounds参数设为false,然后重新加载图片
inJustDecodeBounds参数:当此参数设为true时,BitmapFactory只会解析图片的原始宽/高信息,并不会去真正地加载图片,所以这个操作是轻量级的。另外需要注意的是, 这个时候BitmapFactory获取的图片宽/高信息和图片的位置以及程序运行的设备有关,比如同一张图片放在不同的drawable目录下或者程序 运行在不同屏幕密度的设备上,这都可能导致BitmapFactory获取到不同的结果,之所以会出现这个现象,这和Android的资源加载机制有关
代码:
public static Bitmap decodeSampledBitmapFromResource(Resources res,int resId,
int reqWidth,int reqHeight) {
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res,resId,options);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options,reqWidth,reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res,resId,options);
}
public static int calculateInSampleSize(
BitmapFactory.Options options,int reqWidth,int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// Calculate the largest inSampleSize value that is a power of 2 and
keeps both
// height and width larger than the requested height and width.
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
有了上面的两个方法,实际使用的时候就很简单了,比如ImageView所期望的图片大小为100×100像素,这个时候就可以通过如下方 式高效地加载并显示图片:
mImageView.setImageBitmap( decodeSampledBitmapFromResource(getResources(),R.id.myimage,100,100));
二、Android中缓存策略
1、缓存的主要优点
- 移动端为客户节省流量
- 减少第一次加载以后的加载时间,图片的加载耗时很多
2、缓存策略主要包含缓存的添加、获取和删除这三类操作
为什么还要删除缓存呢?这是因为不管是内存缓存还是存储设备缓存,它们的缓存大小都是有限制的,因为内存和诸如SD卡之类的存储设备都是有容量限制的,因此在使用缓存时总是要为缓存指定一个最大的容量。如果当缓存容量满了,但是程序 还需要向其添加缓存,这个时候该怎么办呢?这就需要删除一些旧的缓存并添加新的缓存
3、目前常用的一种缓存算法是LRU(Least Recently Used)
LRU是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰那些 近期最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和DiskLruCache,LruCache用于实现内存缓存,而DiskLruCache则充当了 存储设备缓存,通过这二者的完美结合,就可以很方便地实现一个具有很高实用价值的ImageLoader。
三、LruCache
1、LruCache是一个泛型类,它内部采用一个LinkedHashMap以强引用的方式存储外界的缓存对象,其提供了get和put方法来完成缓存的获 取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象
- 强引用: 直接的对象引用,只要强引用还存在,该对象就不会被回收
- 软引用: 当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收
- 弱引用: 当一个对象只有弱引用存在时,此对象会在gc触发时就被回收
- 虚引用:没有实际引用意义,一般是用来标记该引用是否已经被回收
LruCache是线程安全的
LruCache的定义:
public class LruCache { private final LinkedHashMap map; ... }
2、LruCache实现内存缓存
- LruCache的初始化
int maxMemory=(int) (Runtime.getRuntime().maxMemory()/1024);
int cacheSize=maxMemory/8;
mMemoryCache=new LruCache<String,Bitmap>(CacheSize){
@Override
protected int sizeOf(String key,Bitmap bitmap){
return bitmap.getRowBytes()*bitmap.getHeight()/1024;
}
}
在上面的代码中,只需要提供缓存的总容量大小并重写sizeOf方法即可。sizeOf方法的作用是计算缓存对象的大小,这里大小的单位 需要和总容量的单位一致。对于上面的示例代码来说,总容量的大小为当前进程的可用内存的1/8,单位为KB,而sizeOf方法则完成了 Bitmap对象的大小计算。很明显,之所以除以1024也是为了将其单位转换为KB。一些特殊情况下,还需要重写LruCache的entryRemoved方 法,LruCache移除旧缓存时会调用entryRemoved方法,因此可以在entryRemoved中完成一些资源回收工作(如果需要的话)
-
LruCache缓存的获取
mMemoryCache.get(key); -
向LruCache添加一个缓存对象
mMemoryCache.put(key,bitmap); -
LruCache还支持删除操作,通过remove方法即可删除一个指定的缓存对象
mMemoryCache.remove(key);
四、DiskLruCache
用于实现存储设备缓存,即磁盘缓存,通过将缓存对象写入文件系统从而实现缓存的效果
1、DiskLruCache的创建
DiskLruCache不能通过构造方法来创建,不过它提供了open方法用于打开自身
public static DiskLruCache open(File directory,int appVersion,int valueCount,long maxSzie)
- 第一个参数表示磁盘缓存在文件系统中的存储路径。缓存路径可以选择SD卡上的缓存目录,具体是 指/sdcard/Android/data/package_name/cache目录,其中package_name表示当前应用的包名,当应用被卸载后,此目录会一并被删除。当然也 可以选择SD卡上的其他指定目录,还可以选择data下的当前应用的目录,具体可根据需要灵活设定。这里给出一个建议:如果应用卸载 后就希望删除缓存文件,那么就选择SD卡上的缓存目录,如果希望保留缓存数据那就应该选择SD卡上的其他特定目录
- 第二个参数表示应用的版本号,一般设为1即可。当版本号发生改变时DiskLruCache会清空之前所有的缓存文件,而这个特性在实际 开发中作用并不大,很多情况下即使应用的版本号发生了改变缓存文件却仍然是有效的,因此这个参数设为1比较好
- 第三个参数表示单个节点所对应的数据的个数,一般设为1即可
- 第四个参数表示缓存的总大小,比如50MB,当缓存大小超出这个 设定值后,DiskLruCache会清除一些缓存从而保证总大小不大于这个设定值
private static final long DISK_CACHE_SIZE=1024*1024*50;//50MB
File diskCacheDir=getDiskCacheDir(mContext,"bitmap");
if(!diskCacheDir.exists()){
diskCacheDir.mkdirs();
}
mDiskLruCache=DiskLruCache.open(diskCacheDir,1,1,DISK_CACHE_SIZE);
2、DiskLruCache的缓存添加
DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象,仍然以图片缓存举例,首先需要获取 图片url所对应的key,然后根据key就可以通过edit()来获取Editor对象,如果这个缓存正在被编辑,那么edit()会返回null,即DiskLruCache不 允许同时编辑一个缓存对象。之所以要把url转换成key,是因为图片的url中很可能有特殊字符,这将影响url在Android中直接使用,一般采用url的md5值作为key
//这个方法就是用于将url转换成MD5值
private String hashKeyFormUrl(String url) {
String cacheKey;
try {
final MessageDigest messageDigest = MessageDigest.getInstance("MD5");
messageDigest.update(url.getBytes());
cacheKey = byteToHexString(messageDigest.digest());
} catch (Exception e) {
cacheKey = String.valueOf(url.hashCode());
}
return cacheKey;
}
private String byteToHexString(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对象,通过它就可以得到一个文件输出流。需要注意的是,由于前面在DiskLruCache的open方法中设置了一个节点只能有一个数 据,因此下面的DISK_CACHE_INDEX常量直接设为0即可
String key = hashKeyFormUrl(url);
DiskLruCache.Editor editor = mDiskCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
}
return cacheKey;
有了文件输出流,接下来当从网络下载图片时,图片就可以通过这个文件输出流写入到文件系统上, 这个过程的实现如下
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();
经过上面的几个步骤,图片已经被正确地写入到文件系统了,接下来图片获取的操作就不需要请求网络了
3、DiskLruCache的缓存查找
和缓存的添加过程类似,缓存查找过程也需要将url转换为key,然后通过DiskLruCache的get方法得到一个Snapshot对象,接着再通过 Snapshot对象即可得到缓存的文件输入流,有了文件输出流,自然就可以得到Bitmap对象了。为了避免加载图片过程中导致的OOM问 题,一般不建议直接加载原始图片。可以通过BitmapFactory.Options对象来加载一张缩放后的图片,但是那种方法对 FileInputStream的缩放存在问题,原因是FileInputStream是一种有序的文件流,而两次decodeStream调用影响了文件流的位置属性,导致了第二次decodeStream时得到的是null。为了解决这个问题,可以通过文件流来得到它所对应的文件描述符,然后再通过 BitmapFactory.decodeFileDescriptor方法来加载一张缩放后的图片,这个过程的实现如下
五、ImageLoader的实现
一般一个优秀的ImageLoader需要具备如下功能:
- 图片的同步加载:指能够以同步的方式向调用者提供所加载的图片,这个图片可能是从内存缓存中读取的,也可能是从磁盘缓存中 读取的,还可能是从网络拉取的
- 图片的异步加载:就是ImageLoader自己在内部线程中加载图片并将图片设置给所需的ImageView,因为很多时候调用者不想在单独的线程中以同步的方式来获取图 片
- 图片压缩:实际上是压缩像素,降低OOM的概率
- 内存缓存
- 磁盘缓存:内存缓存和磁盘缓存是ImageLoader的核心,也是ImageLoader的意义之所在,通过这两级缓存极大地提高了程序的效率并且有效地降 低了对用户所造成的流量消耗,只有当这两级缓存都不可用时才需要从网络中拉取图片
- 网络拉取
1、图片压缩
public class ImageResizer {
private static final String TAG="ImageResizer";
public Bitmap decodeSampleBitmapFromResource(Resources res,int resId,int reqWidth,int reqHeight){
final BitmapFactory.Options options=new BitmapFactory.Options();
options.inJustDecodeBounds=true;
BitmapFactory.decodeResource(res,resId,options);
options.inSampleSize=calculateInSampleSize(options,reqWidth,reqHeight);
options.inJustDecodeBounds=false;
return BitmapFactory.decodeResource(res,resId,options);
}
public Bitmap decodeSampleBitmapFromFileDescriptor(FileDescriptor fd,int reqWidth,int reqHeight){
final BitmapFactory.Options options=new BitmapFactory.Options();
options.inJustDecodeBounds=true;
BitmapFactory.decodeFileDescriptor(fd,null,options);
options.inSampleSize=calculateInSampleSize(options,reqWidth,reqHeight);
return BitmapFactory.decodeFileDescriptor(fd,null,options);
}
private int calculateInSampleSize(BitmapFactory.Options options,int reqWidth,int reqHeight){
//如果需要的宽高的是0,采样率返回1
if (reqWidth==0||reqHeight==0){
return 1;
}
int height=options.outHeight;
int width=options.outWidth;
Log.e(TAG, "origin,w="+width+"h="+height );
int inSampleSize=1;
//宽高应该同时满足目标宽高,所以使用或运算
while (height/inSampleSize>=reqHeight||width/inSampleSize>=reqWidth){
inSampleSize*=2;
}
Log.e(TAG, "InSampleSize: "+inSampleSize);
return inSampleSize;
}
}
2、内存缓存和磁盘缓存的实现
这里选择LruCache和DiskLruCache来分别完成内存缓存和磁盘缓存的工作。在ImageLoader初始化时,会创建LruCache和 DiskLruCache
private LruCache<String, Bitmap> mMemoryCache;
private DiskLruCache mDiskLruCache;
private Context mContext;
private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50; //50MB
private ImageLoader(Context context){
mContext=context.getApplicationContext();
//总容量的大小等于当前进程的可用内存大小的1/8,单位为KB,除以1024也是为了将其单位转化为KB
int maxMemory=(int) (Runtime.getRuntime().maxMemory())/1024;
//总容量
int cacheSize=maxMemory/8;
mMemoryCache=new LruCache<String,Bitmap>(cacheSize){
@Override
protected int sizeOf(String key,Bitmap bitmap){
//sizeOf方法的作用是计算缓存对象的大小,这里大小的单位需要和总容量的单位一致
//以用户定义的单位返回key和value条目的大小。默认实现返回 1,因此 size 是条目数,而 max size 是最大条目数。
//条目的大小在缓存中时不得更改
return bitmap.getRowBytes()*bitmap.getHeight()/1024;
}
};
File diskCacheDir = getDiskCacheDir(mContext,"bitmap");
if (!diskCacheDir.exists()){
diskCacheDir.mkdirs();
}
//这里是一个判断,即有可能磁盘剩余空间小于磁盘缓存所需的大小,一般就是指用户的手机内存不足了,因此不能创建磁盘缓存,这个时候磁盘缓存就会失效
if (getUsableSpace(diskCacheDir)>DISK_CACHE_SIZE){
try{
mDiskLruCache=DiskLruCache.open(diskCacheDir,1,1,DISK_CACHE_SIZE);
mIsDiskLruCacheCreated=true;
}catch (IOException e){
e. printStackTrace();
}
}
}
内存缓存的添加和获取功能
//内存缓存的添加和获取
private void addBitmapToMemoryCache(String key,Bitmap bitmap){
if (getBitmapFromMemCache(key)==null){
mMemoryCache.put(key,bitmap);
}
}
private Bitmap getBitmapFromMemCache(String key){
return mMemoryCache.get(key);
}
磁盘缓存的添加 需要通过Editor来完成,Editor提供了commit和abort方法来提交和撤销对文件系统的写操作,具体实现请参看下面的loadBitmap-FromHttp方 法。磁盘缓存的读取需要通过Snapshot来完成,通过Snapshot可以得到磁盘缓存对象对应的FileInputStream,但是FileInputStream无法便捷地 进行压缩,所以通过FileDescriptor来加载压缩后的图片,最后将加载后的Bitmap添加到内存缓存中,具体实现请参看下面的 loadBitmapFromDiskCache方法。
private Bitmap loadBitmapFromHttp(String url,int reqWidth,int reqHeight) throws IOException {
if (Looper.myLooper()==Looper.getMainLooper()){
throw new RuntimeException("can not visit netWork from UI Thread.");
}
if (mDiskLruCache==null){
return null;
}
String key=hashFromUrl(url);
DiskLruCache.Editor editor=mDiskLruCache.edit(key);
if (editor!=null){
OutputStream outputStream=editor.newOutputStream(DISK_CACHE_SIZE);
if (downloadUrlToStream(url,outputStream)){
editor.commit();
}else {
editor.abort();
}
mDiskLruCache.flush();
}
return loadBitmapFromHttp(url,reqWidth,reqHeight);
}
private Bitmap loadBitmapFromDiskCache(String url,int reqWidth){
if (Looper.myLooper()==Looper.getMainLooper()){
throw new RuntimeException("load bitmap from UI Thread,it's not recommend");
}
if (mDiskLruCache==null){
return null;
}
Bitmap bitmap=null;
String key=hashFromUrl(url);
DiskLruCache.Snapshot snapshot=mDiskLruCache.get(key);
if (snapshot!=null){
FileInputStream fileInputStream=(FileInputStream) snapshot.getInputStream(DISK_CACHE_SIZE);
FileDescriptor fileDescriptor=fileInputStream.getFD();
bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(fileDescriptor,
reqWidth,reqHeight);
if (bitmap != null) {
addBitmapToMemoryCache(key,bitmap);
}
}
return bitmap;
}
3、同步加载和异步加载接口的设计
- 同步加载接口的设计
public Bitmap loadBitmap(String uri,int reqWidth,int reqHeight) {
Bitmap bitmap = loadBitmapFromMemCache(uri);
if (bitmap != null) {
Log.d(TAG,"loadBitmapFromMemCache,url:" + uri);
return bitmap;
}
try {
bitmap = loadBitmapFromDiskCache(uri,reqWidth,reqHeight);
if (bitmap != null) {
Log.d(TAG,"loadBitmapFromDisk,url:" + uri);
return bitmap;
}
bitmap = loadBitmapFromHttp(uri,reqWidth,reqHeight);
Log.d(TAG,"loadBitmapFromHttp,url:" + uri);
} catch (IOException e) {
e.printStackTrace();
}
if (bitmap == null && !mIsDiskLruCacheCreated) {
Log.w(TAG,"encounter error,DiskLruCache is not created.");
bitmap = downloadBitmapFromUrl(uri);
}
return bitmap;
}
首先尝试从内存缓存中读取图片,接着尝试从磁盘缓存中读取图片,最后 才从网络中拉取图片。另外,这个方法不能在主线程中调用,否则就抛出异常。这个执行环境的检查是在loadBitmapFromHttp中实现的, 通过检查当前线程的Looper是否为主线程的Looper来判断当前线程是否是主线程,如果不是主线程就直接抛出异常中止程序
-
异步加载接口的设计
public void bindBitmap(final String uri,final ImageView imageView, final int reqWidth,final int reqHeight) { imageView.setTag(TAG_KEY_URI,uri); Bitmap bitmap = loadBitmapFromMemCache(uri); if (bitmap != null) { imageView.setImageBitmap(bitmap); return; } Runnable loadBitmapTask = new Runnable() { @Override public void run() { Bitmap bitmap = loadBitmap(uri,reqWidth,reqHeight); if (bitmap != null) { LoaderResult result = new LoaderResult(imageView,uri,bitmap); mMainHandler.obtainMessage(MESSAGE_POST_RESULT,result).sendToTarget(); } } }; THREAD_POOL_EXECUTOR.execute(loadBitmapTask); }从bindBitmap的实现来看,bindBitmap方法会尝试从内存缓存中读取图片,如果读取成功就直接返回结果,否则会在线程池中去调用 loadBitmap方法(也就是异步加载实际上也是调用同步加载),当图片加载成功后再将图片、图片的地址以及需要绑定的imageView封装成一个LoaderResult对象,然后再通过 mMainHandler向主线程发送一个消息,这样就可以在主线程中给imageView设置图片了
ImageLoader直接采用主线程的Looper来构造Handler对象,这就使得 ImageLoader可以在非主线程中构造了。另外为了解决由于View复用所导致的列表错位这一问题,在给ImageView设置图片之前都会检查它 的url有没有发生改变,如果发生改变就不再给它设置图片
private Handler mMainHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { LoaderResult result = (LoaderResult) msg.obj; ImageView imageView = result.imageView; imageView.setImageBitmap(result.bitmap); String uri = (String) imageView.getTag(TAG_KEY_URI); if (uri.equals(result.uri)) { imageView.setImageBitmap(result.bitmap); } else { Log.w(TAG,"set image bitmap,but url has changed,ignored!"); } }; };