Android中如何有效的加载Bitmap一直都是一个有意义的话题,我本人也很感兴趣。由于Bitmap的特殊性以及Android对单个应用所施加的内存限制(eg:16MB),导致加载Bitmap的时候很容易出现内存溢出。比如经常会遇到下面的异常:
java.lang.OutofMemoryError:bitmap size exceeds VM budget
在实际开发中经常要用到Bitmap做缓存,通过缓存策略我们不需要每次都从网络上请求图片或者从存储设备中加载图片,这样极大的提高了图片的加载效率以及产品的用户体验。目前比较常用的缓存策略是LruCache和DiskLruCache,其中LruCache常被用做内存缓存,而DiskLryCache常被用做存储缓存。Lru是Least Recently Used的缩写,即最近最少使用算法,这种算法的核心思想是:当缓存快满的时候,会淘汰近期最少使用的缓存目标,这种算法还是很容易接受的。
ListView和GridView由于要加载大量的子视图,当用户快速滑动的时候就容易出现卡顿的现象,下面会介如何优化列表的卡顿现象。
1.Bitmap的高效加载
Bitmap在Android中指的是一张图片,可以使png也可以是jpg等其他常见图片格式。BitmapFactory类提供了四类方法:decodeFile、decodeResource、decodeStream和decodeByteArray,他们分别支持从文件系统、资源、输入流以及字节数组中加载出一个Bitmap对象,其中前两个又间接调用了decodeStream。
那么怎样高效的加载Bitmap呢?采用BitmapFactory.Options来加载所需尺寸的图片。假设加载图片的ImageView的像素并没有原始图片大,通过BitmapFactory.Options就可以按一定的采样率来加载缩小后的图片,将缩小后的图片显示在ImageView中,这样既降低了内存占用从而在一定程度上避免OOM(Out Of Memory),又提高了Bitmap加载性能。上面说的四类方法都支持BitmapFactory.Options参数。当然也有图片不需要缩放。
通过BitmapFactoy.Options缩放图片主要是用到了他的inSampleSize参数,既采样率。当inSampleSize为1时,采样后图片和原始图片大小一样;当inSampleSize大小为2,采样后的图片的宽和高均为原始图片的1/2,而像素缩小为原来的1/4,那么内存占用也缩小为原来的1/4。比如一张图片像素为1024*1024,采用ARGB8888格式存储,那么占用内存是1024*1024*4既4MB,如果inSampleSize为2,采样后的图片占用内存就变成了512*512*4既1MB。当inSampleSize小于1的时候,作用效果相当于1,图片大小不变。有的官方文档指出当给inSampleSize指定的值不是2的指数系统会向下取整选择一个最接近2的指数的代替,但是在实际开发中有些Android版本并不成立,所以这个说法仅供参考。
再举个例子,比如ImageView的大小是100*100像素,而图片的原始大小是200*300,取inSampleSize为2的时候,缩放后的图片大小为100*150,仍然是适合ImageView的,可是如果inSampleSize是3,那么缩小后图片的宽高都小于ImageView所期望的值,进而被拉伸导致模糊。
既然通过采样率即可有效的加载图片,那到底如何获取采样率呢?遵循以下流程:
(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的资源加载机制有关。
先以加载drawable目录下的图片为例将上面的四个流程用程序实现,记载网络或者本地图片后面会结合缓存介绍:
public static Bitmap decodeSampledBitmapFormResource(Resource res,int resId,int reqWidth,int reqHeight){
final BitmapFactory.Options options= new BitmapFactory.Options();//以便利用BitmapFactory.Options缩放图片
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res,resId,options);//解析图片的宽高信息,赋值给options的成员变量outWidth和outHeight
options.inSampleSize = calculateInSampleSize(options,reqWidth,reqHeigth);//既然图片的原始宽高和要求宽高都知道了,就可以计算采样率了
options.inJustDecodeBounds = false;
return BitmapFatory.decodeResource(res,resId,options);//使用计算得到的inSampleSize返回图片
}
如果没有对应上面的四个步骤来理解这段代码,可能会多花很多时间。
public static int calculateInSampleSize(BitmapFactory.Options options,int reqWidth,int reqHeigth){
final int heigth = options.outHeigth;
final int width = options.outWidth;
int inSampleSize = 1;
if(heigth>reqHeigth||width>reqWidth){
final int halfHeigt = heigth/2;
final int halfWidth = width/2;
//只有在宽和高都缩小一倍后两者都不小于ImageView要求的宽高的情况下,inSampleSize才增大一倍。打个比方,原始图片宽高都是150,ImageView要求宽高100,因为图片缩小一倍后会因为被拉伸而变得模糊,所以这时选择采样率为1。
while
((halfHeigth/inSampleSize)>=reqHeigth&&(halfWidth/inSampleSize)>=reqWidth){
inSampleSize *=2;
}
}
return inSampleSize;
}
有了这两个方法实际使用就简单了,比如ImageView所期望的图片大小是100*100,那么就可以用下面的代码:
mImageView.setImageBitmap(decodeSampledBitmapFromResource(getResource(),R.id.image,100,100));
除了BitmapFactory的decodeResource方法,其他三个方法也支持采样加载,处理方法也是类似的,不再重复介绍了。
2.Android中的缓存策略
解决流量消耗首选的方法就是使用缓存。当程序第一次从网络上加载图片后就将其缓存到存储设备上,这样下次使用这张图片就不用再从网络上获取了,从而节省流量。很多时候为了提高用户体验还会在内存中缓存一份,这时当应用请求一张图片时会先从内存读取,如果内存中没有那就从存储设备这种获取,如果存储设备也没有那就从网络上下载这张图片。上述缓存策略不仅适用于图片,还适用于其他文件类型。
一般来说缓存策略主要包含缓存的添加、获取和删除。为什么要删除呢?这是因为内存和存储设备为应用提供的缓存空间是有限制的(总要在代码中为缓存指定最大容量),如果缓存满了但是程序还需要添加内容,就需要删除一些旧的缓存。缓存的新旧定义有很多策略,不同的策略对应不同的算法,如果仅仅简单的根据文件最后的修改时间定义新旧显然不太完美。目前常用的是LRU(Least Recently Used),最近最少使用算法,他的思想是当缓存满是,先删除那些最近最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和LruDiskCache,前者实现内存缓存,后者实现文件系统缓存,利用两者结合可以实现很完美的ImageLoader。
2.1 LruCache
LruCache是一个泛型类,他内部采用一个LinkedHashMap以强引用的方式存储外界的缓存对象。关于强引用、软引用和弱引用的区别欢迎朋友自行百度。
另外LruCache还是线程安全的,下面是他的定义:
public class LruCache
private final LinkedHashMap
......
}
下面的代码展示了以缓存Bitmap为例LruCache的典型的初始化的过程:
int maxMemory = (int)(Runtime.getRuntime().maxMemory()/1024);//当前进程可用内存
int cacheSize = maxMemory/8;//使用当前进程内存的八分之一作为缓存
mMemoryCache = new LruCache
@Override
protected int sizeOf(String key,Bitmap bitmap){
return bitmap.getRowBytes()*bitmap.getHeight()/1024;//返回某张图片的大小
}
}
和例子中一样,要做的就是提供一个cacheSize并重写sizeOf方法。在代码中数以1024都是为了保证单位的一致,因为默认都是字节,那除以1024就是KB了。如果有需要可以重写他的entryRemoved方法,LruCache移除旧内存时会调用这个方法,因此重写的内容就是完成一些回收工作。LruCache的获取、添加和删除通过就能Key完成的,方法名分别为get,put和remove。使用非常简单,但很强大。
2.2 DiskLruCache
DiskLruCache通过将缓存对象写入文件系统从而实现缓存效果。但他并不属于AndroidSDK的一部分,所以还需要自行下载。
(1)DiskLruCache的创建
DiskLruCache并不能通过构造方法来创建,可以使用他的open方法。如下所示:
public static DiskLruCache open(File directory,int appVersion,int valueCount,long maxSize)
可见open方法有四个参数,第一个参数表示缓存在文件系统的存储路径;第二个参数表示应用的版本号,一般设为1即可。第三个参数表示单个节点所对应的数据个数,一般也是设为1即可。第四个参数表示缓存的大小,以字节为单位,当缓存大小超过这个值DiskLruCache会清除一些缓存。下面是一个典型的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表示一个DiskLruCache对象的编辑对象,可以通过edit()来获取Editor对象。系统不希望一个DiskLruCache同时有多个Editor,所以当一个DiskLruCache正在被编辑,那么edit()会返回null。以缓存图片为例,首先需要获取图片url对应的key,这是因为图片的url中很可能有特殊字符,这样的url没办法在Android中直接作为缓存图片的key了,一般情况下都是使用url的md5值作为key,如下所示:
private String hashKeyFromUrl(String url){
String cacheKey;
try{
final MessageDigest mDigest= MessageeDigest.getInstance(“MD5”);//选取编码方式
mDigest.update(url.getBytes());//md5编码
cacheKey = bytesToHexString(mDigest.digest());//编码转String
}catch(NoSuchAlgorithException e){
cacheKey = String.valueOf(url.hashCode());//退而求其次使用哈希编码
}
}
private String bytesToHexString(byte[] bytes){
StringBuilder sb = new StringBuilder();
for(int i = 0;i
String hex = Integer.toHexString(0xFF&bytes[i]);//各位相与
if(hex.length() == 1){
sb.append(‘0’);
}
sb.append(hex);
}
return sb.toString();
}
当然这种编码格式同样适用于LruCache。将图片的url转化为key之后就可以用这个key获取Editor对象了。通过Editor就可以得到一个文件输出流,因为前面设置了每个节点只有一个数据,所以下面的DISK_CACH_INDEX常量直接设为0即可。如下所示:
String key = hashKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if(editor!=null){
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
}
有了这个输出流之后怎么办呢?当从网络下载图片时,图片就可以通过这个文件输出流写入到文件系统上。这个部分在前面讲Android的网络编程的博文中有详细代码,不再复述。
还有就是在输出流结束之后,图片并没有真正的写入文件系统,还必须通过Editor的commit来提交写入操作。如果图片下载过程出现异常,那么可以调用Editor的abort方法回退整个操作。假设从网络下载图片并写入文件系统的方法名为downloadUrlToStream,按照操作成不成功返回true和false,那么这个过程如下所示:
if(downloadUrlToStream(url,outputStream)){
editor.commit();
}else{
Editor.abort();
}
mDiskLruCache.flush();
进过上面的几个步骤,图片已经被正确的写入到文件系统了,接下来图片获取的操作就不需要请求网络了。
2.3 DiskLruCache的缓存查找
和缓存添加过程类似,缓存查找过程也需要将url转换为key,不同的是DiskLruCache的get方法获取到的是一个Snapshot对象,接着再通过Snapshot对象即可获得缓存的文件输入流,自然就得到了Bitmap对象。前面已经介绍过为了避免加载图片的过程中出现OOM,可以采用BitmapFactory.Options对象来加载一张缩放后的图片,但是利用这种方法对Snapshot获得的FileInputStream进行缩放存在问题。原因是FileInputStream是一种有序的文件流,两次调用decodeStream会影响FileInputStream的位置属性,所以第二次调用的decodeStream时返回值是null。解决办法就是通过文件流获取他的文件描述符,在通过BitmapFactory.decodeFileDescriptor方法加载一张缩放后的图片,代码实现如下:
DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
if(snapshot!=null){
FileInputStream fileInputStream
= (FileInt=putStream)snapshot.getInputStream(DISK_CACHE_INDEX);
Filedescriptor fileDescriptor = fileInputStream.getFD();
bitmap = mImageResizer.decodeSampleBitmapFormFileDescriptor
(fileDescriptor,reqWidth,reqHeigth);
if(bitmap!=null)//bitmap已经发生变化,再缓存一次
addBitmapToMemoryCache(key,bitmap);
}
ImageResizer是一个图片压缩类,他的另外两个方法在文中都单独解释过了,分别是decodeSampledBitmapFromResource和calculateInSampleSize。
public Bitmap decodeSampledBitmapFromDescriptor(FileDescriptor fd,int reqWidth,int reqHeigth){
final BitmapFactory.Options option = new BitmapFactory.Optioon();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fd,null,options);
option.inSampleSize = calculateInSampleSize(options,reqWidth,reqHright);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFileDescriptor(fd,null,options);
}
上面介绍了DiskLruCache的创建,缓存的添加和查找过程,已经有一个基本的了解了。除此之外,DiskLruCache还提供了remove、delete等缓存的删除操作,使用都很简单,不在介绍。
3.利用缓存实现一个ImageLoader
一般来说一个优秀的ImageLoader应该具备:
·图片的同步加载;
·图片的异步加载
·图片压缩
·内存缓存
·磁盘缓存
·网络拉取
很显然最好在ImageLoader的构造方法中进行Lrucache和DiskLruLoader的初始化,构造方法可以要求传入参数Context,以便创建DiskLruCache的缓存路径。前面已经详细介绍过两种缓存的各种方法,不再复述。
下面介绍一下从网络向DiskLruCache添加缓存:
private Bitmap loadBitmapFromHttp(String url,int reqWidth,int reqHeight) throws IOException{
if(Looper.myLooper()==Looper.getMainLooper()){
throws new RunTimeException(“can not visit network from UI Thread”);
}
if(DiskLruCache==null)
return null;
String ket = hashKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if(editor!=null){
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if(downloadUrlToStream(url,outputStream))//网络请求并通过outputStream写入缓存,前面有介绍
{editor.commit();}
else
{editor.abort();}
mDiskLruCache.flush();
}
return loadBitmapFromDiskCache(url,reqWidth,reqHeight);
}
接着看图片的同步加载,其工作过程遵循如下几步:
首先尝试从内存缓存中读取,接着尝试从磁盘缓存读取图片,最后才从网络上拉取图片。另外这个方法不能在主线程调用,这个执行环境的检查是在loadBitmapFromHttp中实现的,检查当前线程的looper是否为主线程的looper来判断是否是主线程,如果不是直接抛出终止异常。各个方法已经有单独介绍了,不在复述。暂且把方法名声明为loadBitmap,返回值类型是Bitmap。
下面是异步加载接口:
public void bindBitmap(final String uri,final ImageView imageView,final int reqWidth,int reqHeight){
imageView.setTag(TAG_KEY_URI,uri);
Bitmap bitmap = loadBitMapFromLruCache(uri);
if(bitmap!=null){
imageView.setImageBitmap(bitmap);
return;
}
Runable loadBitmapTask = new Runable(){
@Override
public void run(){
Bitmap bitmap = loadBitmap(uri,reqWidth,reqHeight);
if(bitmap!=null){
LoaderResult result = new LoaderResult(yri,reqWeith,reqHeight);
mMainHandler.obtainMessage(MESSAGE_POST_RESULT,result);
sendToTarget();
}
}
}
THREAD_POOL_EXECUTOR.execute(loadBitmapTask);
}
从代码中很容易看出,采取的策略就是先从内存中读取图片,如果读取不到就会在线程池中执行loadBitmap,然后将绑定的imageView,图片的地址uri和图片封装成一个LoaderResult对象,通过mMainHandler向主线程发送一个消息,这样就可以在主线程中给imageView设置图片了。之所以采用线程池是有原因的,因为采用普通线程去加载图片肯定会因为随着列表的滑动产生大量的线程,这样不利于整体的效率。没有使用AsyncTask是因为他无法实现并发效果,这显然不能接受,因为ImageLoad要的就是并发特性。虽然通过他的executeOnExecutor的方法可以执行异步任务,但归根结底还是通过Executor(线程池)执行的,这在Android3.0以后显得不太自然。这里采用线程池和Handler结合的方法实现并发和访问UI的能力。
private Handler mMainHandler = new Handler(Looper.getMainLooper()){
@Override
public void handleMessage(Message msg){
LoaderResult result = (LoaderResulter)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.e(TAG,”uri has chaged,ignored!”);
}
}
}
在代码中可以看出,Handler是利用主线程的Looper创建的,这也就是说ImageLoader可以在非主线程中创建了。检查uri是否发生变化是为了比面图片显示发生错位。
为了GrideView显示更加美观,建议使用自定义的ImageView来打造一个正方形的ImageView,只需要继承ImageView类然后将onMeasure方法体改为:
super.onMearsure(widthMeasureSpec,widthMeasureSpec);
然后再资源文件中定义GrideView的item的时候采用这个自定义ImageView即可。接下来就是实现一个BaseAdepter给GrideView使用了:
private class ImageAdapter extends BaseAdapter{
.......//一些变量,下面代码中有体现
public int getCount(){
return mUrlist.size();//mUrlist存储图片的url
}
@Override
public String getItem(int position){
return mUrlist.get(position);
}
@Override
public long getItemId(int position){
return position;
}
@Override
public view setView(int position,View convertView,ViewGroup parent){
if(convertView ==null){......}else{.....}
ImageView imageView= holder.imageview;
final String tag = (String)imageView.getTag();
final String uri = getItem(position);
if(!uri.equals(tag))
imageView.setImageDrawable(defaultBitmap);
if(mIsGrideViewIdle&&mCanGetBitmapFromNetWork){
imageView.setTag(uri);
mImageLoader.bindBitmap(uri,imageView,mImageWidth,mImageHeight);
}
return convertView;
}
}
从代码中可以看出,整个过程,包括内存缓存,磁盘缓存以及图片压缩等工作对ImageAdapter都是透明的,所以可见这是一个及其轻量级的ImageAdapter.
下面是他的简单使用代码:
gridView = (GridView)findViewById(R.id.gridview);
Adapter = new ImageAdapter(this);
gridView.setAdapter(adapter);
其次还有一点是怎么优化列表的卡顿问题。答案其实很简单,就是不要在主线程中做太耗时的操作即可,提高滑动的流畅性。具体来说有以下三点:
(1) 不要在setView中执行耗时操作。比如上面的例子,如果直接在setView中加载图片肯定会导致卡顿,因为加载图片是一个耗时的操作,这种耗时操作必须使用异步的方式来处理。
(2) 控制异步任务的执行频率。所以仅仅在setView中采用异步操作是不够的。比如说如果用户刻意的频繁地上下滑动,就会在瞬间产生成千上百的异步任务,这些异步任务势必会造成线程池拥堵并随机带来大量的UI操作,因为UI操作是在主线程进行的,这就会造成一定程度的卡顿。那怎么解决呢?可以考虑在列表滑动时停止加载图片,等列表停下来再加载图片,个人观察天天快报就是采用这种方式,不过只是猜想没得到官方证实。具体来说给ListView或者GridView设置setOnScrollListener,并在这个监听的onScrollStateChanged方法判断列表是否处于滑动状态。
public void onScrollStateChanged(AbslistView view,int scrolllistener){
if(scrollState ==OnScrollListener.SCROLL_STATE_IDLE){
mIsDridViewIdle = true;
adapter.notifyDataSetChanged();
}else{
mIsGridViewIdle = false;
}
}