参考资料《Android开发艺术探索》
如何高效的加载一个Bitmap?由于Bitmap的特殊性以及Android对单个应用所施加的内存限制,比如16M,这导致加载Bitmap时很容易出现内存溢出。下面这个异常信息在开发中应该时常遇到:
java.lang.OutOfMemoryError: bitmap size exceeds VM budget
因此如何高效的加载Bitmap是一个很重要也很容易被开发者忽视的问题。
接着介绍Android中常用的缓存策略,缓存策略是一个通用的思想,可以用在很多场景中,但是实际开发中经常需要用Bitmap做缓存。通过缓存策略,我们不需要每次都从网络上请求图片或者从存储设备中加载图片,这样就极大的提高了图片的加载效率以及产品的用户体验。目前比较常用的缓存策略是LruCache和DiskLruCache,其中LruCache常被用作内存缓存,而DiskLruCache常被用作存储缓存。Lru是Least Recently Used的缩写,即最近最少使用算法,这种算法的核心思想为:当缓存快满时,会淘汰近期最少使用的缓存目标,很显然Lru算法的思想是很容易被接受的。
Bitmap的高效加载
在介绍Bitmap的高效加载之前,先说一下如何加载一个Bitmap,Bitmap在Android中指的是一张图片,可以是png格式也可以是jpg等其他常见的图片格式。那么如何加载一个图片呢?BitmapFactory类提供了四种方法:decodeFile,decodeResource,decodeStream和decodeByteArray,分别用于支持从文件系统,资源,输入流以及字节数组中加载出一个Bitmap对象,其中decodeFile和decodeResource又间接调用了decodeStream方法,这四类方法最终是在Android底层实现的,对应着BitmapFactory类的几个native方法。
如何高效的加载Bitmap呢?其实核心思想也很简单,那就是采用BitmapFactory.Option来加载所需尺寸的图片。这里假设通过ImageView来显示图片,很多时候ImageView并没有图片的原始尺寸那么大,这个时候把整个图片加载进来后在设给ImageView,这显然是没有必要的,因为ImageView并没有办法显示原始的图片。通过BitmapFactory.Option就可以按一定的采样率来加载缩小后的图片,将缩小后的图片在ImageView中显示,这样就会降低内存占用从而在一定程度上避免OOM,提高了Bitmap加载时的性能。BitmapFactory提供的加载图片的四类方法都支持BitmapFactory.Option参数,通过他们就可以很方便地对一个图片进行采样缩放。
通过BitmapFactory.Option来缩放图片,主要是用到了它的inSampleSize参数,即采样率。当inSampleSize为1时,采样后的图片大小为图片的原始大小;当inSampleSize大于1时,比如为2,那么采样后的图片其宽/高均为原图大小的1/2,而像素数为原图的1/4,其占有的内存大小也为原图的1/4。可以发现inSampleSize必须是大于1的整数图片才会有缩小的效果,并且采样率同时作用于宽/高,这将导致缩放后的图片大小以采样率的2次方形式递减。当inSampleSize小于1时,其作用相当于1,即无缩放效果。另外最新的官方文档中指出,inSampleSize的取值应该总是为2的指数,比如1,2,4,8,16,等等。如果外界传递给系统的inSampleSize补位2 的指数,那么系统会向下取整并选择最接近的2的指数来代替,比如3,系统会选择2来代替,但是经过验证发现这个结论并非在所有的Android版本上都成立,因此把它当成一个开发建议即可。
考虑到以下的实际情况,比如ImageView的大小是100100像素,而图片的原始大小为200200,那么只需将采样率inSampleSize设为2即可。但是图片的大小为200300呢?这个时候采样率还应该选择2,这样缩放后的图片大小为100150像素,仍然是适合ImageView的,如果采样率是3,那么缩放后的图片大小就会小于ImageView所期望的大小,这样图片就会被拉抻从而导致模糊。
通过采样率即可有效地加载图片,那么到底如何获取采样路呢?获取采样率也很简单,遵循如下流程:
(1)将BitmapFactory.Option的inJustDecodeBounds参数设置为true并加载图片。
(2)从BitmapFactory.Option中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数。
(3)根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize。
(4)将BitmapFactory.Option的inJustDecodeBounds参数设置为false,然后重新加载图片。
经过上面的4个步骤,加载出的图片就是最终缩放后的图片,当然也有可能不需要缩放。这里说明一下inJustDecodeBounds参数,当此参数设置为true时,BitmapFactory只会解析图片的原始宽/高信息,并不会真正地加载图片,所以这个操作是轻量级的。另外需要注意的是,这个时候BitmapFactory获取的图片宽/高信息和图片的位置以及程序运行的设备有关,比如同一张图片放在不同的drawable目录下或者程序运行在不同屏幕密度的设备上,这都可能导致BitmapFactory获取到不同的结果,之所以会出现这个现象,这和Android的资源加载机制有关。
Android中的缓存策略
缓存策略在Android中有着广泛的使用场景,尤其在图片加载这个场景下,缓存策略就变得更为重要。考虑一种场景:有一批网络图片,需要下载后在用户界面上予以显示,这个场景在pc环境下是很简单的,直接把所有的图片下载到本地在显示即可,但是放到移动设备上就不一样了。不管是Android还是ios设备,流量对于用户来说都是一种宝贵的资源,由于流量是收费的,所以在应用开发中并不能过多地消耗用户的流量。
如何避免过多的流量消耗呢?那就是本节要讨论的主题:缓存。当程序第一次从网络加载图片后,就将其缓存到存储设备上,这样下次使用这张图片就不用再从网络上获取了,这样就节省了用户的流量。很多时候为了提高应用的用户体验,往往还会把图片在内存中在缓存一份,这样当应用打算从网络山请求一张图片时,程序会首先从内存中去获取,如果内存中没有那就从存储设备中去获取,如果存储设备中也没有,那就从网络上下载这张图片。因为从内存中加载图片比从存储设备中加载图片要快,所以这样即提高了程序的效率又为用户节约了不必要的流量开销。上述的缓存策略不仅仅适用于图片,也适用于其他的文件类型。
说到缓存策略,其实并没有统一的标准。一般来说,缓存策略主要包含缓存的添加,获取和删除这三类操作。如何添加和获取缓存这个比较好理解,那为什么还要删除缓存呢?这是因为不管是内存缓存还是存储设备缓存,它们的缓存大小都是有限制的,因为内存和诸如sd卡之类的存储设备都是有容量限制的,因此在使用缓存时总是要为缓存指定一个最大的容量。如果缓存满了,但是程序还是要向其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存,如何定义缓存的新旧这就是一种策略,不同的策略就应对着不同的缓存算法,比如可以简单的根据文件的最后修改时间来定义缓存的新旧,当缓存满时就将最后修改时间较早的缓存移除,这就是一种缓存算法,但是这种算法并不算完美。
目前常用的一种缓存算法是LRU(Least Recently Used),LRU是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和DisLruCache,LruCache用于实现内存缓存,而DisLruCache则充当了存储设备缓存,通过这二者的完美结合,就可以很方便的实现一个具有很高实用价值的ImageLoader。
LruCache
LruCache是一个泛型类,它的内部采用一个LinkedHashMap以强引用的方式存储外界的缓存对象,其提供了get和put方法来完成缓存的获取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象。首先要明白强引用,软引用和弱引用的区别。
强引用:直接的对象引用;
软引用:当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收;
弱引用:当一个对象只有弱引用存在时,此对象会随时被gc回收;
另外,LruCache是线程安全的,下面是LruCache的定义:
public class LruCache {
private final LinkedHashMap map;
}
LruCache的实现比较简单,下面代码展示LruCache的典型的初始化过程:
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;
LruCache mMemoryCahce = new LruCache(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的entryRemove方法,LruCache移除旧缓存时会调用到entryRemove方法,因此可在entryRemove中完成一些资源回收工作。
除了LruCache的创建以外,还有缓存的获取和添加,这也很简单,从LruCache中获取一个缓存对象,如下所示
mMemoryCahce.get(key)
向LruCache中添加一个缓存对象,如下所示
mMemoryCahce.pet(key, bitmap)
LruCache还支持删除操作,通过remove方法即可删除一个指定的缓存对象。可以看到LruCache的实现以及使用都非常简单,虽然简单,但是仍然不影响它具有强大的功能。
DiskLruCache
DiskLruCache用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。
DiskLruCache的创建
DiskLruCache并不能通过构造方法来创建,它提供了open方法用于创建自身,如下所示
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
open方法有四个参数,其中第一个参数表示磁盘缓存在文件系统中的存储路径。缓存路径可以选择SD卡上的缓存目录。
第二个参数表示应用的版本号,一般设为1即可。
第三个参数表示但个节点所对应的数据的个数,一般设为1即可。
第四个参数表述缓存的总大小,比如50M,当缓存大小超出这个设定值后,DiskLruCache会清除一些缓存从而保证总大小不大于这个设定值。
DiskLruCache的缓存添加
DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象。这里仍然以图片缓存举例,首先需要获取图片url所对应的key,然后根据key就可以通过edit()来获取Editor对象,如果这个缓存正在被编辑,那么edit()会返回null,即DiskLruCache不允许同时编辑一个缓存对象。之所以要把url转换成key,是因为图片的url中很可能有特殊字符,这将影响url在Android中直接使用,一般采用url的md5值作为key。
ImageLoader的实现
一般来说,一个优秀的ImageLoader应该具备如下功能
1.图片的同步加载
2.图片的异步加载
3.图片压缩
4.内存缓存
5.磁盘缓存
6.网络拉取
图片的同步加载是指能够以同步的方式向调用者提供所加载的图片,这个图片可能是从内存缓存中读取的,也可能是从磁盘缓存中读取的,还可能是从网络拉取的。图片的异步加载是一个很有用的功能,很多时候调用者不想在单独的线程中以同步的方式来获取图片,这个时候ImageLoader内部需要自己在线程中加载图片并将图片设置给所需的ImageView。图片压缩的作用更毋庸置疑了,这是降低OOM概率的有效手段,ImageLoader必须合适地处理图片的压缩问题。
内存缓存和磁盘缓存是ImageLoader的核心,也是ImageLoader的意义之所在,通过这两级缓存极大地提高了程序的效率并且有效地降低了对用户所造成的流量消耗,只有当这两级缓存都不可用时才需要从网络中拉取图片。
除此之外,ImageLoader还需要处理一些特殊的情况,比如在ListvView或者GridView中,View复用即是它们的优点也是它们的缺点。例如,在ListvView或者GridView中,假设一个item A正在从网络加载图片,它对应的ImageView为A,这个时候用户快速向下滑动列表,很可能item B复用了ImageView A,然后等一会之前的图片下载完毕了。如果直接给ImageView A设置图片,由于这时候ImageView A被item B所复用,但是item B要显示的图片显然不是item A刚刚下载好的图片,这个时候就会出现item B中显示了item A的图片,这就是常见的列表错位的问题,ImageLoader需要正确的处理这些特殊情况。
上面对ImageLoader的功能做了一个全面的分析,下面就可以一步步实现一个ImageLoader了,这里主要分为以下几步。
图片压缩功能的实现
public class ImageResizer {
private static final String TAG = "ImageResizer";
public ImageResizer() {
}
/**
*
* @param res Resources资源
* @param resId 加载的图片id
* @param reqWidth 期望的加载图片的宽
* @param reqHeight 期望的加载图片的高
* @return
*/
public Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
BitmapFactory.Options options = new BitmapFactory.Options();
//将options.inJustDecodeBounds设置为true并加载图片。设置为true时,BitmapFactory只会解析图片的宽高信息,并不会真正的加载图片
options.inJustDecodeBounds = true;
//加载Bitmap
BitmapFactory.decodeResource(res, resId, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
//将options.inJustDecodeBounds设置为false,这样就会完全加载Bitmap
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
public Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor fd, int reqWidth, int reqHeight){
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fd,null,options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFileDescriptor(fd,null,options);
}
public int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
if (reqWidth == 0 || reqHeight == 0) {
return 1;
}
//得到加载的图片的宽高
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;
//将图片多次缩小到传入的参数范围内,并计算出最终的inSampleSize值
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize >= reqWidth)) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
}