Android 之 加载图片篇
这一章节会介绍一些处理与加载Bitmap对象的常用方法,这些技术能够使得程序的UI不会被阻塞,并且可以避免程序超出内存限制。如果我们不注意这些,Bitmaps会迅速的消耗掉可用内存从而导致程序崩溃,出现下面的异常:java.lang.OutofMemoryError: bitmap size exceeds VM budget.
在Android应用中加载Bitmaps的操作是需要特别小心处理的,有下面几个方面的原因:
1、移动设备的系统资源有限。Android设备对于单个程序至少需要16MB的内存。Android Compatibility Definition Document (CDD), Section 3.7. Virtual Machine Compatibility 中给出了对于不同大小与密度的屏幕的最低内存需求。 应用应该在这个最低内存限制下去优化程序的效率。当然,大多数设备的都有更高的限制需求。
2、 Bitmap会消耗很多内存,特别是对于类似照片等内容更加丰富的图片。 例如,Galaxy Nexus的照相机能够拍摄2592x1936 pixels (5 MB)的图片。 如果bitmap的图像配置是使用ARGB_8888 (从Android 2.3开始的默认配置) ,那么加载这张照片到内存大约需要19MB(2592*1936*4 bytes) 的空间,从而迅速消耗掉该应用的剩余内存空间。
3、Android应用的UI通常会在一次操作中立即加载许多张bitmaps。 例如在ListView, GridView 与ViewPager 等控件中通常会需要一次加载许多张bitmaps,而且需要预先加载一些没有在屏幕上显示的内容,为用户滑动的显示做准备。
高效加载大图片
图片有不同的形状与大小。在大多数情况下它们的实际大小都比需要呈现的尺寸大很多。例如,系统的图库应用会显示那些我们使用相机拍摄的照片,但是那些图片的分辨率通常都比设备屏幕的分辨率要高很多。
考虑到应用是在有限的内存下工作的,理想情况是我们只需要在内存中加载一个低分辨率的照片即可。为了更便于显示,这个低分辨率的照片应该是与其对应的UI控件大小相匹配的。加载一个超过屏幕分辨率的高分辨率照片不仅没有任何显而易见的好处,还会占用宝贵的内存资源,另外在快速滑动图片时容易产生额外的效率问题。
1读取位图的尺寸与类型(Read Bitmap Dimensions and Type)
BitmapFactory提供了一些解码(decode)的方法(decodeByteArray(), decodeFile(), decodeResource()等),用来从不同的资源中创建一个Bitmap。 我们应该根据图片的数据源来选择合适的解码方法。 这些方法在构造位图的时候会尝试分配内存,如果大量分配内存,就会导致OutOfMemory的异常。每一种解码方法都可以通过BitmapFactory.Options设置一些附加的标记,以此来指定解码选项。设置 inJustDecodeBounds 属性为true可以在解码的时候避免内存的分配,它会返回一个null的Bitmap,但是可以获取到 outWidth, outHeight 与 outMimeType。该技术可以允许你在构造Bitmap之前优先读图片的尺寸与类型。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;
为了避免java.lang.OutOfMemory 的异常,我们需要在真正解析图片之前检查它的尺寸(除非你能确定这个数据源提供了准确无误的图片且不会导致占用过多的内存)。
2 加载一个按比例缩小的版本到内存中(Load a Scaled Down Version into Memory)
通过上面的步骤我们已经获取到了图片的尺寸,这些数据可以用来帮助我们决定应该加载整个图片到内存中还是加载一个缩小的版本。有下面一些因素需要考虑:
1评估加载完整图片所需要耗费的内存。
2程序在加载这张图片时可能涉及到的其他内存需求。
3呈现这张图片的控件的尺寸大小。
4屏幕大小与当前设备的屏幕密度。
例如,如果把一个大小为1024x768像素的图片显示到大小为128x96像素的ImageView上,就没有必要把整张原图都加载到内存中。
为了告诉解码器去加载一个缩小版本的图片到内存中,需要在BitmapFactory.Options 中设置 inSampleSize 的值。例如, 一个分辨率为2048x1536的图片,如果设置 inSampleSize 为4(表示缩小为原来的1/4),那么会产出一个大约512x384大小的Bitmap。加载这张缩小的图片仅仅使用大概0.75MB的内存,如果是加载完整尺寸的图片,那么大概需要花费12MB(前提都是Bitmap的配置是 ARGB_8888)。下面有一段根据目标图片大小来计算Sample图片大小的代码示例:
public static int calculateInSmpleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
// 图片的原始高度和宽度
final int height = options.outHeight;
final int width = options.outWidth;
int inSmaleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfWidth = width / 2;
final int halfHeight = height / 2;
while (((halfWidth / inSmaleSize) > reqWidth)
&& ((halfHeight / inSmaleSize) > reqHeight)) {
inSmaleSize *= 2;
}
}
return inSmaleSize;
}
为了使用该方法,首先需要设置 inJustDecodeBounds 为 true, 把options的值传递过来,然后设置inSampleSize 的值并设置 inJustDecodeBounds为 false,之后重新调用相关的解码方法。代码如下
public static Bitmap decodeSampledBitmapFromResource(Resources res,int resId, int reqWidth, int reqHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// 计算 inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth,reqHeight);
// 解析资源中的图片
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
使用上面这个方法可以简单地加载一张任意大小的图片。如下面的代码样例显示了一个接近 100x100像素的缩略图:
mImageView.setImageBitmap(decodeSampledBitmapFromResource(getResources(), R.drawable.ic_launcher, 100, 100));
在子线程(非UI线程)中处理bitmap
在上面介绍了一系列的BitmapFactory.decode*方法,当图片来源是网络或者是存储卡时(或者是任何不在内存中的形式),这些方法都不应该在UI 线程中执行。因为在上述情况下加载数据时,其执行时间是不可估计的,它依赖于许多因素(从网络或者存储卡读取数据的速度,图片的大小,CPU的速度等)。如果其中任何一个子操作阻塞了UI线程,系统都会容易出现应用无响应的错误。(ANR)
这一节课会介绍如何使用AsyncTask在后台线程中处理Bitmap并且演示如何处理并发(concurrency)的问题。
1使用AsyncTask(Use a AsyncTask)
AsyncTask 类提供了一个在后台线程中执行一些耗时操作的简单方法,它还可以把后台的执行结果呈现到UI线程中。下面是一个加载大图的示例:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
private final WeakReference<ImageView> imageViewReference;
private int data = 0;
public BitmapWorkerTask(ImageView imageView) {
// Use a WeakReference to ensure the ImageView can be garbage
// collected 使用若引用来确保ImageView可以被GC回收
imageViewReference = new WeakReference<ImageView>(imageView);
}
// 在后台(子线程)中加载图片(100X100)
@Override
protected Bitmap doInBackground(Integer... params) {
data = params[0];
return decodeSampledBitmapFromResource(getResources(), data, 100, 100);
}
// Once complete, see if ImageView is still around and set bitmap.
@Override
protected void onPostExecute(Bitmap bitmap) {
// 如果成功加载了图片,同时ImageView控件没有被回收,那就可以将图片设置给ImageView
if (imageViewReference != null && bitmap != null) {
final ImageView imageView = imageViewReference.get();
if (imageView != null) {
imageView.setImageBitmap(bitmap);
}
}
}
}
为ImageView使用WeakReference的目的是确保了AsyncTask所引用的资源可以被垃圾回收器回收。由于当任务结束时不能确保ImageView仍然存在,因此我们必须在onPostExecute()里面对引用进行检查。该ImageView在有些情况下可能已经不存在了,例如,在任务结束之前用户使用了回退操作,或者是配置发生了改变(如旋转屏幕等)
开始异步加载位图,只需要创建一个新的任务并执行它即可:
public void loadBitma(int resId, ImageView imageview) {
BitmapWorkerTask task = new BitmapWorkerTask(imageview);
task.execute(resId);
}
2 处理并发问题(Handle Concurrency)
通常类似ListView与GridView等视图控件在使用上面演示的AsyncTask 方法时,会同时带来并发的问题。(多线程就必然会涉及到并发问题)首先为了更高的效率,ListView与GridView的子Item视图会在用户滑动屏幕时被循环使用。如果每一个子视图都触发一个AsyncTask,那么就无法确保关联的视图在结束任务时,分配的视图已经进入循环队列中,给另外一个子视图进行重用。而且, 无法确保所有的异步任务的完成顺序和他们本身的启动顺序保持一致。
为此,就需要一种处理并发的解决方案,我们使用ImageView保存最近使用的AsyncTask的引用,这个引用可以在任务完成的时候再次读取检查。使用这种方式, 就可以对前面提到的AsyncTask进行扩展。
创建一个专用的Drawable的子类来储存任务的引用。在这种情况下,我们使用了一个BitmapDrawable,在任务执行的过程中,一个占位图片会显示在ImageView中:
在执行BitmapWorkerTask 之前,你需要创建一个AsyncDrawable并且将它绑定到目标控件ImageView中
static class AsyncDrawable extends BitmapDrawable {
private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;
// 这个地方的bitmap就是一个默认的bitmap图片,在你尚未从网络上下 载下图片的时候,有默认图片就会好看一点。
public AsyncDrawable(Resources res, Bitmap bitmap,
BitmapWorkerTask bitmapWorkerTask) {
super(res, bitmap);
bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(
bitmapWorkerTask);
}
public BitmapWorkerTask getBitmapWorkerTask() {
return bitmapWorkerTaskReference.get();
}
}
/*
* 在执行BitmapWorkerTask 之前,你需要创建一个AsyncDrawable并且将它绑定到目标控件ImageView中:
*/
public void loadBitmap(int resId, ImageView imageView) {
if (cancelPotentialWork(resId, imageView)) {
final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
final AsyncDrawable asyncDrawable = new AsyncDrawable(
getResources(), mPlaceHolderBitmap, task);
imageView.setImageDrawable(asyncDrawable);
task.execute(resId);
}
}
在上面的代码示例中,cancelPotentialWork 方法检查是否有另一个正在执行的任务与该ImageView关联了起来,如果的确是这样,它通过执行cancel()方法来取消另一个任务。在少数情况下, 新创建的任务数据可能会与已经存在的任务相吻合,这样的话就不需要进行下一步动作了。下面是 cancelPotentialWork方法的实现 。
public static boolean cancelPotentialWork(int data, ImageView imageView) {
final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (bitmapWorkerTask != null) {
final int bitmapData = bitmapWorkerTask.data;
if (bitmapData == 0 || bitmapData != data) {
// Cancel previous task
bitmapWorkerTask.cancel(true);
} else {
// The same work is already in progress
return false;
}
}
// No task associated with the ImageView, or an existing task was
// cancelled
return true;
}
/*
* getBitmapWorkerTask(),它被用作检索AsyncTask是否已经被分配到指定的ImageView:
*/
private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
if (imageView != null) {
final Drawable drawable = imageView.getDrawable();
if (drawable instanceof AsyncDrawable) {
final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
return asyncDrawable.getBitmapWorkerTask();
}
}
return null;
}
最后一步是在BitmapWorkerTask的onPostExecute() 方法里面做更新操作:
@Override
protected void onPostExecute(Bitmap bitmap) {
if (isCancelled()) {
bitmap = null;
}
// 如果成功加载了图片,同时ImageView控件没有被回收,那就可以将图片设置给ImageView
if (imageViewReference != null && bitmap != null) {
final ImageView imageView = imageViewReference.get();
final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (this == bitmapWorkerTask && imageView != null) {
imageView.setImageBitmap(bitmap);
}
}
}
这个方法(loadBitmap(int resId, ImageView imageView))不仅仅适用于ListView与GridView控件,在那些需要循环利用子视图的控件中同样适用:只需要在设置图片到ImageView的地方调用 loadBitmap方法。例如,在GridView中的getView()中,为子视图加载图片就可以调用该方法了。