通过多线程技术提高Android应用性能

有一个好方法可以让你的应用保持快速响应,那就是让主UI线程尽量少做事情,如果在UI线程中做一个耗时过长的处理,会导致UI僵死,因此对于有可能耗时过长的任务应该另起一个线程处理。这种典型的应用场景就是做网络相关的操作,因为网络传输过程中可能有意料不到的延迟。通常来说,用户可以忍受反馈时的一小段等待,但界面僵死就是另外一回事了。

本文就根据这种设计模式实现一个简单的图片下载应用,我们将实现一个带有图片预览功能的列表,这些图片都是从互联网上下载的,下载操作是在后台异步下载的。

 图片下载应用

从网上下载图片非常简单,使用Android framework中提供的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) {

    // 可以在这里提供更多更详细的关于IOException和IllegalStateException的错误信息

    getRequest.abort();
    Log.w("ImageDownloader", "Error while retrieving bitmap from " + url + e.toString());
  } finally {
    if (client != null) {
      client.close();
    }
  }
  return null;
}
上面的代码创建了一个发送HTTP请求的客户端,如果HTTP请求正确返回,将会返回一张图片的二进制编码流,通过它可以解码创建一个Bitmap对象。当然,如果想要上面这段代码正常运行,必须保证网络环境正常。(【译者注】:网络环境包括一个可达的web服务器,你的Android应用要有android.permission.INTERNET权限,另外有一点,自从Android3.0以后,Android强制不允许在Android主线程中做网络相关操作,否则会抛出NetworkOnMainThreadException异常,想尝试上面的代码的读者要注意这几点。)
注意:上面这个版本中使用的BitmapFactory.decodeStream 在网络连接速度慢的情况下可能会导致图片编码失败,这个问题可以使用FlushedInputStream(inputStream)替代BitmapFactory.decodeStream方法来解决。下面是一个实现:(【译者注】:这里原作者说BitmapFactory.decodeStream当网速慢的时候可能会失败,这个现象是正确的,原因是因为InputStream中的skip方法有问题,这个问题google一把,有很多相关内容,下面这个实现就是解决这个问题的。)
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 bytes = read();
        if (bytes < 0) {
          break;  //读到文件结束
        } else {
          bytesSkipped = 1; // 读一个字节
        }
      }
      totalBytesSkipped += bytesSkipped;
    }
    return totalBytesSkipped;
  }
}
上面代码中的skip()方法能略过指定的字节数,除非已经读到了文件尽头。如果直接在ListAdapter的getView方法中使用上面的下载图片的代码的话,会导致界面一顿一顿的,用户体验会很差,每张图片的显示都需要等待图片下载完成,使得界面无法顺畅的上下滚动。(【译者注】:如上所注,如果用API level 11以上的(包括11),那会直接抛异常,不会执行。现在的Android强制网络操作必须在UI线程之外做。)
 这样看来这的确不是一个好主意,所以AndroidHttpClient不允许在主线程中启动,上面的代码会报”This thread forbids HTTP requests” 的错误,所以如果你真想尝试的话,请使用DefaultHttpClient代替。(【译者注】:事实上,我在Android4.0.3上使用DefaultHttpClient也不行。)

 使用异步任务

AsyncTask类提供了一种最为简单的方法从UI启动一个新的任务,我们现在创建一个ImageDownloader类负责创建这些任务,这个类中的download方法可以从给定URL下载图片并且将该图片赋给一个ImageView控件。
public class ImageDownloader {
  public void download(String url, ImageView imageView) {
    BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
    task.execute(url);
  }
}
 BitmapDownloaderTask类继承自AsynTask,它提供下载图片的功能。它的execute方法可以即时返回,因此速度非常快,从UI线程调用的时候就不会感觉到有什么卡顿的感觉,而这正是我们的目的。下面是BitmapDownloaderTask的实现代码:
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
  // 这里是在下载线程中真正要执行的代码
  protected Bitmap doInBackground(String... params) {
    // 参数params是从execute方法传递过来的,params[0]就是要下载的图片url
    return downloadBitmap(params[0]);
  }

  @Override
  // 一旦图片下载完成,就将它在ImageView控件上显示出来
  protected void onPostExecute(Bitmap bitmap) {
    if (isCancelled()) {
      bitmap = null;
    }
    if (imageViewReference != null) {
      ImageView imageView = imageViewReference.get();
      if (imageView != null) {
        imageView.setImageBitmap(bitmap);
      }
    }
  }
}
实际上真正在后台异步运行的是doInBackground方法,在这个方法中只是简单的调用本文开头时提供的downloadBitmap方法。
当异步任务执行完成以后,也就是doInBackground方法执行完成之后,onPostExecute方法将被调用,Android会自动将doInBackground方法的返回结果作为参数传递给onPostExecute方法,在这段代码中,onPostExecute就是简单的将doInBackground方法下载的图片与ImageView关联起来。值得注意的一点是,在这里的ImageView是通过一个WeakReference关联的,所以在下载过程没有结束前,ImageView所在的Activity有可能被结束,ImageView对象被垃圾回收,所以这里在使用ImageView之前必须要做两次是否为null的验证,一次是WeakReference对象的验证,一次是ImageView对象的验证。
这个简单的例子介绍了AsyncTask的使用,尝试一下,你就会发现只需要添加很少的代码就可以让你的ListView性能有很大的提高,现在它可以平滑的滚动了。这里有篇文章“轻松的多线程”,介绍了更多关于AsyncTask的细节。
然而,由于ListView的一个特性使我们当前实现还是有问题。事实上,为了更高效的使用内存,当用户滚动ListView的时候,它会重复利用显示控件。当手指在屏幕上猛的一划,让ListView滚动幅度很大的时候,一个ImageView对象会被多次利用(【译者注:这个应用有点像走马灯一样,用有限的几个ImageView显示很多图片,因此可能一个ImageView会用来显示多个图片),每次显示该对象时都会触发下载图片任务,然后更新自己显示的图片。那么问题出在哪里呢?正如大多数多线程应用一样,最关键的问题就是顺序问题。在我们现在这个应用场景中,根本没有保证先开始的下载任务会先结束,导致的结果当前显示的图片有可能是上一次下载任务的图片。这种情况一般在下载时间较长的时候会发生。实际上,如果你下载的图片只显示一次,并且都在ListView中的ImageView对象上显示的话,这就不算什么问题(【译者注】:也不一定,比如看连环画的时候,图片顺序非常重要),不过我们为了适应更普遍的需求还是把这个问题给修好。(【译者注】:实际上,如果你是依次新建AsyncTask同时下载图片,而且都是用execute()方法触发的话,如果你用的是Android1.6~Android3.0之间的版本的话那会出现原作者说的这个问题,否则你会发现他还真的是先开始的任务先结束。而且只有当第一个任务结束后,下一个任务才会开始。因为在Android1.6之前,Android的所有AsyncTask都会在除了UI线程之外的一个线程中运行,Android1.6开始,会为每个AsyncTask新建一个线程,后来可能是为了防止无止境的滥用线程或者一些其他的问题,从Android3.0开始,所有的AsyncTask又只在一个线程中运行,如果想要多个AsyncTask同时运行,需要自己建一个Excutor,然后用AsyncTask的executeOnExecutor方法执行。)

处理并发问题

为了解决这个问题,我们需要记下每个下载任务的顺序,确保最后一个开始下载的图片是最终显示的图片。这样的话就需要每一个ImageView对象都记得它最后一次下载的是哪张图片。我们现在就用一个包装过的Drawable子类来保存这一信息,然后在下载过程中将这个Drawable子类对象与ImageView控件绑定,下面给出DowloadedDrawable类的实现代码:

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控件会显示一个黑色背景,当然你也可以使用一个进度条替代单纯的黑色背景从而给用户下载进度的反馈信息。另外,注意要使用WeakRefernece来减弱对象之间的依赖关系。
下面我们改进之前的ImageDownloader类的download方法,首先先创建一个DownloadedDrawable的实例,并且赋给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方法,如果一个新的下载任务开始时,发现上一个下载任务还没有结束的话,就通过该方法结束上一个下载任务。但是请注意,即使这么做也不能保证最新下载的图片一定会被最终显示出来,因为每个任务结束后会执行它的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 {
      // 相同的URL已经在下载过程中了,因此不取消,因为不管是先下载还是后下载,反正下载的都是同一个文件
      return false;
    }
  }
  return true;
}
 cancelPotentialDownload使用AsyncTask类的cancel方法来终止一个还没有结束的下载任务。大多数时候它会返回true,因此在download方法中可以新启动一个下载任务。但是如果相同的URL已经在下载过程中,那我们就没有必要取消该任务再新建一个任务来下载该URL的图片了。还需要注意一点,如果下载任务对应的ImageView对象已经被垃圾回收了,而该任务还没有结束,这种情况该怎么办?这时可以使用RecyclerListener来处理这种情况。
如果要使用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还是不是依然与本任务相对应,只有当它们依然对应时,才会将下载下来的图片在对应的ImageView控件上显示出来:
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上下载,点击这里。你可以比较一下本文中描述的三种实现(不使用异步任务的,不将下载任务和ImageView绑定的,还有最终的版本)。请注意为了更好的演示效果,我们只缓存10张图片。(【译者注】:这里原作者说的缓存在前面的文章中没有提到,在他的代码中每次下载图片前都先尝试从缓存中获取该URL对应的图片,如果这张图片已经被下载过了就不再新建下载任务而直接使用缓存中的图片,其实用适当的缓存来减少消耗大的操作也是个相当好的经验)。
下一步工作
本文的代码只注重了并行方面的需求,为此简化了不少,从而很多有用的功能都没有包含在内。ImageDownloader类首先应该使用缓存,特别当它与ListView配合使用的时候,因为这种情况下屏幕上下来回滚动,一张图片可能会被显示多次。这时可以使用LinkedHashMap来实现一个缓存,用来存储最近使用过的图片,缓存SoftRefernces中以URL为Key,以BitMap为value。缓存的大小可以根据本地存储的大小来决定(【译者注:不一定都要存在内存中,从本地sdcard中读取图片速度也会被从网上下载快得多,因此缓存在sdcard上也是个不错的选择)。如果需要的话,还可以添加图片预览和图片缩放的功能。
在我们的实现中,当下载错误或者超时的时候,会返回null,在这里可以考虑使用一张提示错误的图片替代null。
我们发送HTTP请求的代码非常简单,我们可以考虑为了某些特定的网站添加一些必须的参数或者cookie。(【译者注】:比如说有些网站禁止外来访问,可以添加HTTP协议中的referer或者某些网站需要验证才能下载的等等)。
本文使用的AsyncTask类是可以非常简单方便的从UI线程中触发异步任务。你也可以考虑使用Handler类来对你想做的事情做更好的控制,比如说控制下载线程数的总量等等。( 【译者注】:现在的AsyncTask配合Executor使用完全可以做到控制总线程数的功能,并且如果在非UI线程中更新UI会导致“android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views”这样的异常,因此个人建议尽量使用AsyncTask比较好。)
英文原文:Gilles Debunne,编译:ImportNew - 赵荣
译文地址: http://www.importnew.com/895.html
【如需转载,请在正文中标注并保留原文链接、译文链接和译者等信息,谢谢合作!】

关于作者: 赵荣

一个不甘寂寞,热爱Java技术,喜欢Android平台,略懂密码技术,技术兴趣广泛,有些闷骚的胖子码农.

查看赵荣的更多文章 >>



相关文章

发表评论

Comment form

(*) 表示必填项

1 条评论

  1. 刘佳 说道:

    不错,受益匪浅

    Thumb up 0 Thumb down 0

跳到底部
返回顶部