在创建响应式app中,一个非常好的做法就是确保你的主线程尽可能做最小量的工作。任何有可能导致你的app阻塞的长期任务都应该在不同的线程处理。这种任务的典型例子是网络操作,这种操作可能包含未知的延迟。用户可以容忍一些停顿、尤其是你如果在停顿时提供了正在进行的信息反馈。但如果直接卡死并且没有任何回馈信息,用户将不知所措。
在这篇文章中,我们将会创建一个简单的图片下载器来说明这种模式。接下来我们将会用ListView来展示从网上下载的缩略图,并且在后台创建一个异步的下载任务来保持app快速运行。
图片下载器
从网上下载图片是比较简单的,使用框架提供的http相关类即可。下面是实现步骤
static Bitmap downloadBitmap(String url) {
final AndroidHttpClient client = AndroidHttpClient.newInstance("Android");
final HttpGet getRequest = new HttpGet(url);
try {
HttpResponse response = client.execute(getRequest);
final int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != HttpStatus.SC_OK) {
Log.w("ImageDownloader", "Error " + statusCode + " while retrieving bitmap from " + url);
return null;
}
final HttpEntity entity = response.getEntity();
if (entity != null) {
InputStream inputStream = null;
try {
inputStream = entity.getContent();
final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
return bitmap;
} finally {
if (inputStream != null) {
inputStream.close();
}
entity.consumeContent();
}
}
} catch (Exception e) {
// Could provide a more explicit error message for IOException or IllegalStateException
getRequest.abort();
Log.w("ImageDownloader", "Error while retrieving bitmap from " + url, e.toString());
} finally {
if (client != null) {
client.close();
}
}
return null;
}
创建了客服端和http并请求。如果请求成功,服务器将会相应一个包含图片字节流的实体,字节流将会被解码成Bitmap并且返回给用户。注意,你必须需要在manifest添加网络权限。
注意:在BitmapFactory.decodeStream以前的版本中存在一个bug,可能阻止这代码在一个慢连接上执行。使用FlushedInputStream(inputStream) 代替解决这个问题。
下面是这个帮助类的实现:
static class FlushedInputStream extends FilterInputStream {
public FlushedInputStream(InputStream inputStream) {
super(inputStream);
}
@Override
public long skip(long n) throws IOException {
long totalBytesSkipped = 0L;
while (totalBytesSkipped < n) {
long bytesSkipped = in.skip(n - totalBytesSkipped);
if (bytesSkipped == 0L) {
int byte = read();
if (byte < 0) {
break; // we reached EOF
} else {
bytesSkipped = 1; // we read one byte
}
}
totalBytesSkipped += bytesSkipped;
}
return totalBytesSkipped;
}
}
这可以确保skip()实际跳过提供的字节数,除非到达了文件的结尾。
如果你在ListAdapter中的getView方法中直接使用这个方法,将会导致滚动卡顿。每一个新视图的显示都需要等待图片的下载,这样将会阻止平滑的滚动。
实际上,这是一个非常糟糕的想法,AndroidClient不允许自己在主线程运行。这上面的代码将会显示”该线程禁止Http请求”错误信息。如果你真的想要向你自己的脚设计,可以使用DefaultHttpClient.
引入异步任务
AsyncTask 类提供了从UI线程启动一个新任务的最简单方法之一。我们来创建一个ImageDownloader类,它将会负责创建这些线程。它将会提供一个下载方法,将从URL下载图片分配给ImageView:
public class ImageDownloader {
public void download(String url, ImageView imageView) {
BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
task.execute(url);
}
}
/* class BitmapDownloaderTask, see below */
}
BitmapDownloaderTask是实际上下载图片的AsyncTask.它用execute启动,立即返回,因此使得这个方法非常快,这是最终目的,因为它将从UI线程中调用。这里有它的实现类:
class BitmapDownloaderTask extends AsyncTask<String, Void, Bitmap> {
private String url;
private final WeakReference<ImageView> imageViewReference;
public BitmapDownloaderTask(ImageView imageView) {
imageViewReference = new WeakReference<ImageView>(imageView);
}
@Override
// Actual download method, run in the task thread
protected Bitmap doInBackground(String... params) {
// params comes from the execute() call: params[0] is the url.
return downloadBitmap(params[0]);
}
@Override
// Once the image is downloaded, associates it to the imageView
protected void onPostExecute(Bitmap bitmap) {
if (isCancelled()) {
bitmap = null;
}
if (imageViewReference != null) {
ImageView imageView = imageViewReference.get();
if (imageView != null) {
imageView.setImageBitmap(bitmap);
}
}
}
}
doInBackground 方法实际上是运行在task自己的进程中,它只是简单的使用我们在文章开始实现的dwonloadBitmap方法。
当任务完成后,onPostExecute在调用UI线程中运行。它将返回的Bitmap做为参数,它只和提供下载和存储在BitmapDownloaderTask中的ImageView简单的关联。请注意,这个ImageView是以WeakReference形式存储,所以,在进行下载的时候并不会阻止GC回收销毁的activity’s ImageView 。这就解释了为什么在onPostExecute中使用它们之前必须检查弱引用和ImageView不为空。
这个简化的例子演示了AsyncTask的使用,如果你去使用它,你将会看到这几行代码实际上极大的提高ListView的性能,并且滚动流畅。阅读Read Painless获取AsyncTasks的更多信息。
然而,一个ListView特定行为揭示了我们当前实现的一个问题。
实际上,出于内存效率的理由,当用户滑动时ListView将会循环的展示这些视图。如果滑动列表,给定的imageView对象将会被使用多次。每次显示ImageView都会正确的触发图片下载任务,最终都会改变ImageView的图片。所以,问题在哪?和多数平行的应用程序一样,关键的问题在于排序。在我们的例子中,不能保证下载任务按启动顺序完成。在列表最终显示的图片可能来自上一个项,而恰好该项需要更长的时间来下载。如果你为所有的ImageViews绑定下载图片,那这就没有问题了,但让我们来解决它在列表使用的常见问题。
处理并发
为了解决这个问题,我们应该记住下载的顺序,以便于上次开始的能有效的展示。每个ImageView记住最后的下载确实足够了。我们将使用Drawable子类在ImageVeiw中添加这些额外的信息,它将在下载的时候临时绑定到这个ImageView。这是我们的DownloadedDrawable代码:
static class DownloadedDrawable extends ColorDrawable {
private final WeakReference<BitmapDownloaderTask> bitmapDownloaderTaskReference;
public DownloadedDrawable(BitmapDownloaderTask bitmapDownloaderTask) {
super(Color.BLACK);
bitmapDownloaderTaskReference =
new WeakReference<BitmapDownloaderTask>(bitmapDownloaderTask);
}
public BitmapDownloaderTask getBitmapDownloaderTask() {
return bitmapDownloaderTaskReference.get();
}
}
该实现由ColorDrawable支持,这将会导致ImageView在下载的过程中显示一个黑色的背景。你可以使用一个“正在下载”的图片来代替,它将会给用户一个反馈。再一次,注意使用WeakReference来限制对象的依赖。
让我们来改变我们的代码得到新而类。首先,这个download方法现在将会创建这个类的实例并且使得它和ImageView关联:
public void download(String url, ImageView imageView) {
if (cancelPotentialDownload(url, imageView)) {
BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
DownloadedDrawable downloadedDrawable = new DownloadedDrawable(task);
imageView.setImageDrawable(downloadedDrawable);
task.execute(url, cookie);
}
}
由于新的图片下载开始,cancelPotentialDownload方法将会停止在ImageView上可能进行的下载。注意,这并不能足够保证最新的下载被展示,因为这个任务可能已经被完成了,它可能在onPostExecute方法中等待,它可以在一个新任务开启后依然可以执行。
private static boolean cancelPotentialDownload(String url, ImageView imageView) {
BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView);
if (bitmapDownloaderTask != null) {
String bitmapUrl = bitmapDownloaderTask.url;
if ((bitmapUrl == null) || (!bitmapUrl.equals(url))) {
bitmapDownloaderTask.cancel(true);
} else {
// The same URL is already being downloaded.
return false;
}
}
return true;
}
cancelPotentialDownload 使用AsyncTask类的cancel方法来停止这个下载。大多数的时间它都是返回true,以便于可以在下载中启动一个下载,我们不想这种情况发生的唯一理由是相同的URL已经在下载了,在这种情况下,我们只是让它继续下载。注意在这种实现下,如果ImageView被GC,它关联的下载不会被停止。RecyclerListener也许可以在这种情况下使用。
这个方法使用一个getBitmapDownloaderTask的帮助方法,它真是非常精确。
private static BitmapDownloaderTask getBitmapDownloaderTask(ImageView imageView) {
if (imageView != null) {
Drawable drawable = imageView.getDrawable();
if (drawable instanceof DownloadedDrawable) {
DownloadedDrawable downloadedDrawable = (DownloadedDrawable)drawable;
return downloadedDrawable.getBitmapDownloaderTask();
}
}
return null;
}
最后, onPostExecute必须被修改, 以便于这个ImageView仍然与这个下载过程关联时,它才会绑定Bitmap.
if (imageViewReference != null) {
ImageView imageView = imageViewReference.get();
BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView);
// Change bitmap only if this process is still associated with it
if (this == bitmapDownloaderTask) {
imageView.setImageBitmap(bitmap);
}
}
通过这些修改, 我们的ImageDownloader类提供了我们期待的基本的服务。你可以自由的使用它,或者在你的应用程序中演示这个异步模板来确定他们的响应。
Demo
文章的源代码可以在Google Code上在线获取。你可以在文章中讨论的三种不同的实现(没有异步任务、没有Bitmap关联任务和最终正确的版本)之间切换比较。注意,它的缓存大小已经被限制在10个图像,以便于更好的演示这个问题。
进一步优化
这个代码只是简单关注了平行方面,还有许多有用的特性在我们的实现中缺失。ImageDownloader类将首先获取缓存是明显有益的,特别是如果结合listView使用的时候,因为可以能出现当用户上下滚动时,相同的图片会出现多次。这个能被简单的实现,使用最少的记录使用缓存机制,它可以使用以Url为key的LinkedHashMap来存储Bitmap的SoftReferrences。更多的有关缓存机制也可以在硬盘上存储图片。如果你需要,也可以创建缩略图和调整图片大小。
我们的下载能正确处理下载错误和超时,在这种情况下它会返回一个null Bitmap.这个时候你可能想用一张错误的图片来代替。
我们的Http请求是非常简单的。你可能需要根据某些网站要求在请求中添加参数或者cookies。
在文章中,AsyncTask使用是真的很便利,可以轻易的从UI线程推迟工作。你也许想要使用Handler来对你的操作做更好的控制。列如在当前例子中控制并行的下载线程的总数。