在 Android 所有系统自带的控件当中,ListView 这个控件算是用法比较复杂的了,关键是用法复杂也就算了,它还经常会出现一些稀奇古怪的问题,让人非常头疼。比如说在 ListView 中加载图片,如果是同步加载图片倒还好,但是一旦使用异步加载图片那么问题就来了,这个问题我相信很多 Android 开发者都曾经遇到过,就是异步加载图片会出现错位乱序的情况。遇到这个问题时,不少人在网上搜索找到了相应的解决方案,但是真正深入理解这个问题出现的原因并对症解决的人恐怕还并不是很多。那么今天我们就来具体深入分析一下 ListView 异步加载图片出现乱序问题的原因,以及怎么样对症下药去解决它。
本篇文章的原理基础建立在上一篇文章之上,如果你对 ListView 的工作原理还不够了解的话,建议先去阅读 Android ListView 工作原理完全解析,带你从源码的角度彻底理解 。
问题重现
要想解决问题首先我们要把问题重现出来,这里只需要搭建一个最基本的 ListView 项目,然后在 ListView 中去异步请求图片并显示,问题就能够得以重现了,那么我们就新建一个 ListViewTest 项目。
项目建好之后第一个要解决的是数据源的问题,由于 ListView 中需要从网络上请求图片,那么我就提前准备好了许多张图片,将它们上传到了我的 CSDN 相册当中,然后新建一个 Images 类,将所有相册中图片的 URL 地址都配置进去就可以了,代码如下所示:
/**
* 原文地址: http://blog.csdn.net/guolin_blog/article/details/45586553
* @author guolin
*/
public class Images {
public final static String[] imageUrls = new String[] {
"https://img-my.csdn.net/uploads/201508/05/1438760758_3497.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760758_6667.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760757_3588.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760756_3304.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760755_6715.jpeg",
"https://img-my.csdn.net/uploads/201508/05/1438760726_5120.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760726_8364.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760725_4031.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760724_9463.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760724_2371.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760707_4653.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760706_6864.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760706_9279.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760704_2341.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760704_5707.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760685_5091.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760685_4444.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760684_8827.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760683_3691.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760683_7315.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760663_7318.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760662_3454.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760662_5113.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760661_3305.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760661_7416.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760589_2946.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760589_1100.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760588_8297.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760587_2575.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760587_8906.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760550_2875.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760550_9517.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760549_7093.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760549_1352.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760548_2780.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760531_1776.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760531_1380.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760530_4944.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760530_5750.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760529_3289.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760500_7871.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760500_6063.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760499_6304.jpeg",
"https://img-my.csdn.net/uploads/201508/05/1438760499_5081.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760498_7007.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760478_3128.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760478_6766.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760477_1358.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760477_3540.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760476_1240.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760446_7993.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760446_3641.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760445_3283.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760444_8623.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760444_6822.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760422_2224.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760421_2824.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760420_2660.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760420_7188.jpg",
"https://img-my.csdn.net/uploads/201508/05/1438760419_4123.jpg",
};
}
复制代码
设置好了图片源之后,我们需要一个 ListView 来展示所有的图片。打开或修改 activity_main.xml 中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ListView
android:id="@+id/list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
</ListView>
</LinearLayout>
复制代码
很简单,只是在 LinearLayout 中写了一个 ListView 而已。接着我们要定义 ListView 中每一个子 View 的布局,新建一个 image_item.xml 布局,加入如下代码:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<ImageView
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="120dp"
android:src="https://juejin.cn/post/@drawable/empty_photo"
android:scaleType="fitXY"/>
</LinearLayout>
复制代码
仍然很简单,image_item.xml 布局中只有一个 ImageView 控件,就是用它来显示图片的,控件在默认情况下会显示一张 empty_photo。这样我们就把所有的布局文件都写好了。
接下来新建 ImageAdapter 做为 ListView 的适配器,代码如下所示:
/**
* 原文地址: http://blog.csdn.net/guolin_blog/article/details/45586553
* @author guolin
*/
public class ImageAdapter extends ArrayAdapter<String> {
/**
* 图片缓存技术的核心类,用于缓存所有下载好的图片,在程序内存达到设定值时会将最少最近使用的图片移除掉。
*/
private LruCache<String, BitmapDrawable> mMemoryCache;
public ImageAdapter(Context context, int resource, String[] objects) {
super(context, resource, objects);
// 获取应用程序最大可用内存
int maxMemory = (int) Runtime.getRuntime().maxMemory();
int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache<String, BitmapDrawable>(cacheSize) {
@Override
protected int sizeOf(String key, BitmapDrawable drawable) {
return drawable.getBitmap().getByteCount();
}
};
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
String url = getItem(position);
View view;
if (convertView == null) {
view = LayoutInflater.from(getContext()).inflate(R.layout.image_item, null);
} else {
view = convertView;
}
ImageView image = (ImageView) view.findViewById(R.id.image);
BitmapDrawable drawable = getBitmapFromMemoryCache(url);
if (drawable != null) {
image.setImageDrawable(drawable);
} else {
BitmapWorkerTask task = new BitmapWorkerTask(image);
task.execute(url);
}
return view;
}
/**
* 将一张图片存储到LruCache中。
*
* @param key
* LruCache的键,这里传入图片的URL地址。
* @param drawable
* LruCache的值,这里传入从网络上下载的BitmapDrawable对象。
*/
public void addBitmapToMemoryCache(String key, BitmapDrawable drawable) {
if (getBitmapFromMemoryCache(key) == null) {
mMemoryCache.put(key, drawable);
}
}
/**
* 从LruCache中获取一张图片,如果不存在就返回null。
*
* @param key
* LruCache的键,这里传入图片的URL地址。
* @return 对应传入键的BitmapDrawable对象,或者null。
*/
public BitmapDrawable getBitmapFromMemoryCache(String key) {
return mMemoryCache.get(key);
}
/**
* 异步下载图片的任务。
*
* @author guolin
*/
class BitmapWorkerTask extends AsyncTask<String, Void, BitmapDrawable> {
private ImageView mImageView;
public BitmapWorkerTask(ImageView imageView) {
mImageView = imageView;
}
@Override
protected BitmapDrawable doInBackground(String... params) {
String imageUrl = params[0];
// 在后台开始下载图片
Bitmap bitmap = downloadBitmap(imageUrl);
BitmapDrawable drawable = new BitmapDrawable(getContext().getResources(), bitmap);
addBitmapToMemoryCache(imageUrl, drawable);
return drawable;
}
@Override
protected void onPostExecute(BitmapDrawable drawable) {
if (mImageView != null && drawable != null) {
mImageView.setImageDrawable(drawable);
}
}
/**
* 建立HTTP请求,并获取Bitmap对象。
*
* @param imageUrl
* 图片的URL地址
* @return 解析后的Bitmap对象
*/
private Bitmap downloadBitmap(String imageUrl) {
Bitmap bitmap = null;
HttpURLConnection con = null;
try {
URL url = new URL(imageUrl);
con = (HttpURLConnection) url.openConnection();
con.setConnectTimeout(5 * 1000);
con.setReadTimeout(10 * 1000);
bitmap = BitmapFactory.decodeStream(con.getInputStream());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (con != null) {
con.disconnect();
}
}
return bitmap;
}
}
}
复制代码
ImageAdapter 中的代码还算是比较简单的,在 getView() 方法中首先根据当前的位置获取到图片的 URL 地址,然后使用 inflate() 方法加载 image_item.xml 这个布局,并获取到 ImageView 控件的实例,接下来开启了一个 BitmapWorkerTask 异步任务来从网络上加载图片,最终将加载好的图片设置到 ImageView 上面。注意这里为了防止图片占用过多的内存,我们还是使用了 LruCache 技术来进行内存控制,对这个技术不熟悉的朋友可以参考我之前的一篇文章 Android 高效加载大图、多图解决方案,有效避免程序 OOM 。
最后,程序主界面的代码就非常简单了,修改 MainActivity 中的代码,如下所示:
/**
* 原文地址: http://blog.csdn.net/guolin_blog/article/details/45586553
* @author guolin
*/
public class MainActivity extends Activity {
private ListView listView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
listView = (ListView) findViewById(R.id.list_view);
ImageAdapter adapter = new ImageAdapter(this, 0, Images.imageThumbUrls);
listView.setAdapter(adapter);
}
}
复制代码
这就是整个程序所有的代码了,记得还需要在 AndroidManifest.xml 中添加 INTERNET 权限。
那么目前程序的思路其实是很简单的,我们在 ListView 的 getView() 方法中开启异步请求,从网络上获取图片,当图片获取成功就后就将图片显示到 ImageView 上面。看起来没什么问题对吗?那么现在我们就来运行一下程序看一看效果吧。
恩?怎么会这个样子,当滑动 ListView 的时候,图片竟然会自动变来变去,而且图片显示的位置也不正确,简直快乱成一锅粥了!可是我们所有的逻辑都很简单呀,怎么会导致出现这种图片自动变来变去的情况?很遗憾,这是由于 Listview 内部的工作机制所导致的,如果你对 Listview 的工作机制不了解,那么就会很难理解这种现象,不过好在上篇文章中我已经讲解过 ListView 的工作原理了,因此下面就让我们一起分析一下这个问题出现的原因。
原因分析
上篇文章中已经提到了,ListView 之所以能够实现加载成百上千条数据都不会 OOM,最主要在于它内部优秀的实现机制。虽然作为普通的使用者,我们大可不必关心 ListView 内部到底是怎么实现的,但是当你了解了它的内部原理之后,很多之前难以解释的问题都变得有理有据了。
ListView 在借助 RecycleBin 机制的帮助下,实现了一个生产者和消费者的模式,不管有任意多条数据需要显示,ListView 中的子 View 其实来来回回就那么几个,移出屏幕的子 View 会很快被移入屏幕的数据重新利用起来,原理示意图如下所示:
那么这里我们就可以思考一下了,目前数据源当中大概有 60 个图片的 URL 地址,而根据 ListView 的工作原理,显然不可能为每张图片都单独分配一个 ImageView 控件,ImageView 控件的个数其实就比一屏能显示的图片数量稍微多一点而已,移出屏幕的 ImageView 控件会进入到 RecycleBin 当中,而新进入屏幕的元素则会从 RecycleBin 中获取 ImageView 控件。
那么,每当有新的元素进入界面时就会回调 getView() 方法,而在 getView() 方法中会开启异步请求从网络上获取图片,注意网络操作都是比较耗时的,也就是说当我们快速滑动 ListView 的时候就很有可能出现这样一种情况,某一个位置上的元素进入屏幕后开始从网络上请求图片,但是还没等图片下载完成,它就又被移出了屏幕。这种情况下会产生什么样的现象呢?根据 ListView 的工作原理,被移出屏幕的控件将会很快被新进入屏幕的元素重新利用起来,而如果在这个时候刚好前面发起的图片请求有了响应,就会将刚才位置上的图片显示到当前位置上,因为虽然它们位置不同,但都是共用的同一个 ImageView 实例,这样就出现了图片乱序的情况。
但是还没完,新进入屏幕的元素它也会发起一条网络请求来获取当前位置的图片,等到图片下载完的时候会设置到同样的 ImageView 上面,因此就会出现先显示一张图片,然后又变成了另外一张图片的情况,那么刚才我们看到的图片会自动变来变去的情况也就得到了解释。
问题原因已经分析出来了,但是这个问题该怎么解决呢?说实话,ListView 异步加载图片的问题并没有什么标准的解决方案,很多人都有自己的一套解决思路,这里我准备给大家讲解三种比较经典的解决办法,大家通过任何一种都可以解决这个问题,但是我们每多学习一种思路,水平就能够更进一步的提高。
解决方案一 使用 findViewWithTag
findViewWithTag 算是一种比较简单易懂的解决方案,其实早在 Android 照片墙应用实现,再多的图片也不怕崩溃 这篇文章当中,我就采用了 findViewWithTag 来避免图片出现乱序的情况。那么这里我们先来看看怎么通过修改代码把这个问题解决掉,然后再研究一下 findViewWithTag 的工作原理。
使用 findViewWithTag 并不需要修改太多的代码,只需要改动 ImageAdapter 这一个类就可以了,如下所示:
/**
* 原文地址: http://blog.csdn.net/guolin_blog/article/details/45586553
* @author guolin
*/
public class ImageAdapter extends ArrayAdapter<String> {
private ListView mListView;
......
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (mListView == null) {
mListView = (ListView) parent;
}
String url = getItem(position);
View view;
if (convertView == null) {
view = LayoutInflater.from(getContext()).inflate(R.layout.image_item, null);
} else {
view = convertView;
}
ImageView image = (ImageView) view.findViewById(R.id.image);
image.setImageResource(R.drawable.empty_photo);
image.setTag(url);
BitmapDrawable drawable = getBitmapFromMemoryCache(url);
if (drawable != null) {
image.setImageDrawable(drawable);
} else {
BitmapWorkerTask task = new BitmapWorkerTask();
task.execute(url);
}
return view;
}
......
/**
* 异步下载图片的任务。
*
* @author guolin
*/
class BitmapWorkerTask extends AsyncTask<String, Void, BitmapDrawable> {
String imageUrl;
@Override
protected BitmapDrawable doInBackground(String... params) {
imageUrl = params[0];
// 在后台开始下载图片
Bitmap bitmap = downloadBitmap(imageUrl);
BitmapDrawable drawable = new BitmapDrawable(getContext().getResources(), bitmap);
addBitmapToMemoryCache(imageUrl, drawable);
return drawable;
}
@Override
protected void onPostExecute(BitmapDrawable drawable) {
ImageView imageView = (ImageView) mListView.findViewWithTag(imageUrl);
if (imageView != null && drawable != null) {
imageView.setImageDrawable(drawable);
}
}
......
}
}
复制代码
改动的地方就只有这么多,那么我们来分析一下。由于使用 findViewWithTag 必须要有 ListView 的实例才行,那么我们在 Adapter 中怎样才能拿到 ListView 的实例呢?其实如果你仔细通读了上一篇文章就能知道,getView() 方法中传入的第三个参数其实就是 ListView 的实例,那么这里我们定义一个全局变量 mListView,然后在 getView() 方法中判断它是否为空,如果为空就把 parent 这个参数赋值给它。
另外在 getView() 方法中我们还做了一个操作,就是调用了 ImageView 的 setTag() 方法,并把当前位置图片的 URL 地址作为参数传了进去,这个是为后续的 findViewWithTag() 方法做准备。
最后,我们修改了 BitmapWorkerTask 的构造函数,这里不再通过构造函数把 ImageView 的实例传进去了,而是在 onPostExecute() 方法当中通过 ListView 的 findVIewWithTag() 方法来去获取 ImageView 控件的实例。获取到控件实例后判断下是否为空,如果不为空就让图片显示到控件上。
这里我们可以尝试分析一下 findViewWithTag 的工作原理,其实顾名思义,这个方法就是通过 Tag 的名字来获取具备该 Tag 名的控件,我们先要调用控件的 setTag() 方法来给控件设置一个 Tag,然后再调用 ListView 的 findViewWithTag() 方法使用相同的 Tag 名来找回控件。
那么为什么用了 findViewWithTag() 方法之后,图片就不会再出现乱序情况了呢?其实原因很简单,由于 ListView 中的 ImageView 控件都是重用的,移出屏幕的控件很快会被进入屏幕的图片重新利用起来,那么 getView() 方法就会再次得到执行,而在 getView() 方法中会为这个 ImageView 控件设置新的 Tag,这样老的 Tag 就会被覆盖掉,于是这时再调用 findVIewWithTag() 方法并传入老的 Tag,就只能得到 null 了,而我们判断只有 ImageView 不等于 null 的时候才会设置图片,这样图片乱序的问题也就不存在了。
这是第一种解决方案。
解决方案二 使用弱引用关联
虽然这里我给这种解决方案起名叫弱引用关联,但实际上弱引用只是辅助手段而已,最主要的还是关联,这种解决方案的本质是要让 ImageView 和 BitmapWorkerTask 之间建立一个双向关联,互相持有对方的引用,再通过适当的逻辑判断来解决图片乱序问题,然后为了防止出现内存泄漏的情况,双向关联要使用弱引用的方式建立。相比于第一种解决方案,第二种解决方案要明显复杂不少,但在性能和效率方面都会有更好的表现。
我们仍然只需要改动 ImageAdapter 中的代码,但这次改动的地方比较多,所以我就把 ImageAdapter 中的全部代码都贴出来了,如下所示:
/**
* 原文地址: http://blog.csdn.net/guolin_blog/article/details/45586553
* @author guolin
*/
public class ImageAdapter extends ArrayAdapter<String> {
private ListView mListView;
private Bitmap mLoadingBitmap;
/**
* 图片缓存技术的核心类,用于缓存所有下载好的图片,在程序内存达到设定值时会将最少最近使用的图片移除掉。
*/
private LruCache<String, BitmapDrawable> mMemoryCache;
public ImageAdapter(Context context, int resource, String[] objects) {
super(context, resource, objects);
mLoadingBitmap = BitmapFactory.decodeResource(context.getResources(),
R.drawable.empty_photo);
// 获取应用程序最大可用内存
int maxMemory = (int) Runtime.getRuntime().maxMemory();
int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache<String, BitmapDrawable>(cacheSize) {
@Override
protected int sizeOf(String key, BitmapDrawable drawable) {
return drawable.getBitmap().getByteCount();
}
};
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (mListView == null) {
mListView = (ListView) parent;
}
String url = getItem(position);
View view;
if (convertView == null) {
view = LayoutInflater.from(getContext()).inflate(R.layout.image_item, null);
} else {
view = convertView;
}
ImageView image = (ImageView) view.findViewById(R.id.image);
BitmapDrawable drawable = getBitmapFromMemoryCache(url);
if (drawable != null) {
image.setImageDrawable(drawable);
} else if (cancelPotentialWork(url, image)) {
BitmapWorkerTask task = new BitmapWorkerTask(image);
AsyncDrawable asyncDrawable = new AsyncDrawable(getContext()
.getResources(), mLoadingBitmap, task);
image.setImageDrawable(asyncDrawable);
task.execute(url);
}
return view;
}
/**
* 自定义的一个Drawable,让这个Drawable持有BitmapWorkerTask的弱引用。
*/
class AsyncDrawable extends BitmapDrawable {
private WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;
public AsyncDrawable(Resources res, Bitmap bitmap,
BitmapWorkerTask bitmapWorkerTask) {
super(res, bitmap);
bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(
bitmapWorkerTask);
}
public BitmapWorkerTask getBitmapWorkerTask() {
return bitmapWorkerTaskReference.get();
}
}
/**
* 获取传入的ImageView它所对应的BitmapWorkerTask。
*/
private BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
if (imageView != null) {
Drawable drawable = imageView.getDrawable();
if (drawable instanceof AsyncDrawable) {
AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
return asyncDrawable.getBitmapWorkerTask();
}
}
return null;
}
/**
* 取消掉后台的潜在任务,当认为当前ImageView存在着一个另外图片请求任务时
* ,则把它取消掉并返回true,否则返回false。
*/
public boolean cancelPotentialWork(String url, ImageView imageView) {
BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (bitmapWorkerTask != null) {
String imageUrl = bitmapWorkerTask.imageUrl;
if (imageUrl == null || !imageUrl.equals(url)) {
bitmapWorkerTask.cancel(true);
} else {
return false;
}
}
return true;
}
/**
* 将一张图片存储到LruCache中。
*
* @param key
* LruCache的键,这里传入图片的URL地址。
* @param drawable
* LruCache的值,这里传入从网络上下载的BitmapDrawable对象。
*/
public void addBitmapToMemoryCache(String key, BitmapDrawable drawable) {
if (getBitmapFromMemoryCache(key) == null) {
mMemoryCache.put(key, drawable);
}
}
/**
* 从LruCache中获取一张图片,如果不存在就返回null。
*
* @param key
* LruCache的键,这里传入图片的URL地址。
* @return 对应传入键的BitmapDrawable对象,或者null。
*/
public BitmapDrawable getBitmapFromMemoryCache(String key) {
return mMemoryCache.get(key);
}
/**
* 异步下载图片的任务。
*
* @author guolin
*/
class BitmapWorkerTask extends AsyncTask<String, Void, BitmapDrawable> {
String imageUrl;
private WeakReference<ImageView> imageViewReference;
public BitmapWorkerTask(ImageView imageView) {
imageViewReference = new WeakReference<ImageView>(imageView);
}
@Override
protected BitmapDrawable doInBackground(String... params) {
imageUrl = params[0];
// 在后台开始下载图片
Bitmap bitmap = downloadBitmap(imageUrl);
BitmapDrawable drawable = new BitmapDrawable(getContext().getResources(), bitmap);
addBitmapToMemoryCache(imageUrl, drawable);
return drawable;
}
@Override
protected void onPostExecute(BitmapDrawable drawable) {
ImageView imageView = getAttachedImageView();
if (imageView != null && drawable != null) {
imageView.setImageDrawable(drawable);
}
}
/**
* 获取当前BitmapWorkerTask所关联的ImageView。
*/
private ImageView getAttachedImageView() {
ImageView imageView = imageViewReference.get();
BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (this == bitmapWorkerTask) {
return imageView;
}
return null;
}
/**
* 建立HTTP请求,并获取Bitmap对象。
*
* @param imageUrl
* 图片的URL地址
* @return 解析后的Bitmap对象
*/
private Bitmap downloadBitmap(String imageUrl) {
Bitmap bitmap = null;
HttpURLConnection con = null;
try {
URL url = new URL(imageUrl);
con = (HttpURLConnection) url.openConnection();
con.setConnectTimeout(5 * 1000);
con.setReadTimeout(10 * 1000);
bitmap = BitmapFactory.decodeStream(con.getInputStream());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (con != null) {
con.disconnect();
}
}
return bitmap;
}
}
}
复制代码
那么我们一点点开始解析。首先刚才说到的,ImageView 和 BitmapWorkerTask 之间要建立一个双向的弱引用关联,上述代码中已经建立好了。ImageView 中可以获取到它所对应的 BitmapWorkerTask,而 BitmapWorkerTask 也可以获取到它所对应的 ImageView。
下面来看一下这个双向弱引用关联是怎么建立的。BitmapWorkerTask 指向 ImageView 的弱引用关联比较简单,就是在 BitmapWorkerTask 中加入一个构造函数,并在构造函数中要求传入 ImageView 这个参数。不过我们不再直接持有 ImageView 的引用,而是使用 WeakReference 对 ImageView 进行了一层包装,这样就 OK 了。
但是 ImageView 指向 BitmapWorkerTask 的弱引用关联就没这么容易了,因为我们很难将 BitmapWorkerTask 的一个弱引用直接设置到 ImageView 当中。这该怎么办呢?这里使用了一个比较巧的方法,就是借助自定义 Drawable 的方式来实现。可以看到,我们自定义了一个 AsyncDrawable 类并让它继承自 BitmapDrawable,然后重写了 AsyncDrawable 的构造函数,在构造函数中要求把 BitmapWorkerTask 传入,然后在这里给它包装了一层弱引用。那么现在 AsyncDrawable 指向 BitmapWorkerTask 的关联已经有了,但是 ImageView 指向 BitmapWorkerTask 的关联还不存在,怎么办呢?很简单,让 ImageView 和 AsyncDrawable 再关联一下就可以了。可以看到,在 getView() 方法当中,我们调用了 ImageView 的 setImageDrawable() 方法把 AsyncDrawable 设置了进去,那么 ImageView 就可以通过 getDrawable() 方法获取到和它关联的 AsyncDrawable,然后再借助 AsyncDrawable 就可以获取到 BitmapWorkerTask 了。这样 ImageView 指向 BitmapWorkerTask 的弱引用关联也成功建立。
现在双向弱引用的关联已经建立好了,接下来就是逻辑判断的工作了。那么怎样通过逻辑判断来避免图片出现乱序的情况呢?这里我们引入了两个方法,一个是 getBitmapWorkerTask() 方法,这个方法可以根据传入的 ImageView 来获取到它对应的 BitmapWorkerTask,内部的逻辑就是先获取 ImageView 对应的 AsyncDrawable,再获取 AsyncDrawable 对应的 BitmapWorkerTask。另一个是 getAttachedImageView() 方法,这个方法会获取当前 BitmapWorkerTask 所关联的 ImageView,然后调用 getBitmapWorkerTask() 方法来获取该 ImageView 所对应的 BitmapWorkerTask,最后判断,如果获取到的 BitmapWorkerTask 等于 this,也就是当前的 BitmapWorkerTask,那么就将 ImageView 返回,否则就返回 null。最后,在 onPostExecute() 方法当中,只需要使用 getAttachedImageView() 方法获取到的 ImageView 来显示图片就可以了。
那么为什么做了这个逻辑判断之后,图片乱序的问题就可以得到解决呢?其实最主要的奥秘就是在 getAttachedImageView() 方法当中,它会使用当前 BitmapWorkerTask 所关联的 ImageView 来反向获取这个 ImageView 所关联的 BitmapWorkerTask,然后用这两个 BitmapWorkerTask 做对比,如果发现是同一个 BitmapWorkerTask 才会返回 ImageView,否则就返回 null。那么什么情况下这两个 BitmapWorkerTask 才会不同呢?比如说某个图片被移出了屏幕,它的 ImageView 被另外一个新进入屏幕的图片重用了,那么就会给这个 ImageView 关联一个新的 BitmapWorkerTask,这种情况下,上一个 BitmapWorkerTask 和新的 BitmapWorkerTask 肯定就不相等了,这时 getAttachedImageView() 方法会返回 null,而我们又判断 ImageView 等于 null 的话是不会设置图片的,因此就不会出现图片乱序的情况了。
除此之外还有另外一个方法非常值得大家注意,就是 cancelPotentialWork() 方法,这个方法可以大大提高整个 ListView 图片加载的工作效率。这个方法接收两个参数,一个图片的 url,一个 ImageView。看一下它的内部逻辑,首先它也是调用了 getBitmapWorkerTask() 方法来获取传入的 ImageView 所对应的 BitmapWorkerTask,接下来拿 BitmapWorkerTask 中的 imageUrl 和传入的 url 做比较,如果两个 url 不等的话就调用 BitmapWorkerTask 的 cancel() 方法,然后返回 true,如果两个 url 相等的话就返回 false。
那么这段逻辑是什么意思呢?其实并不复杂,两个 url 做比对时,如果发现是相同的,说明请求的是同一张图片,那么直接返回 false,这样就不会再去启动 BitmapWorkerTask 来请求图片,而如果两个 url 不相同,说明这个 ImageView 被另外一张图片重新利用了,这个时候就调用了 BitmapWorkerTask 的 cancel() 方法把之前的请求取消掉,然后重新启动 BitmapWorkerTask 来去请求新图片。有了这个操作保护之后,就可以把一些已经移出屏幕的无效的图片请求过滤掉,从而整体提升 ListView 加载图片的工作效率。
这是第二种解决方案。
解决方案三 使用 NetworkImageView
前面两种解决方案都需要我们自己去做额外的逻辑处理,因为 ImageView 本身是不能自动解决这个问题的,但是如果我们使用 NetworkImageView 这个控件的话就非常简单了,它自身就已经考虑到了这个问题,我们直接使用它就可以了,不用做任何额外的处理也不会出现图片乱序的情况。
NetworkImageView 是 Volley 当中提供的控件,对于这个控件我之前专门写过一篇博客来讲解,还不熟悉这个控件的朋友可以先去阅读 Android Volley 完全解析 (二),使用 Volley 加载网络图片。
下面我们看一下如何用 NetworkImageView 来解决这个问题,首先需要修改一下 image_item.xml 文件,因为我们已经不再使用 ImageView 控件了,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<com.android.volley.toolbox.NetworkImageView
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="120dp"
android:src="https://juejin.cn/post/@drawable/empty_photo"
android:scaleType="fitXY"/>
</LinearLayout>
复制代码
很简单,只是把 ImageView 替换成了 NetworkImageView。然后修改 ImageAdapter 中的代码,如下所示:
/**
* 原文地址: http://blog.csdn.net/guolin_blog/article/details/45586553
* @author guolin
*/
public class ImageAdapter extends ArrayAdapter<String> {
ImageLoader mImageLoader;
public ImageAdapter(Context context, int resource, String[] objects) {
super(context, resource, objects);
RequestQueue queue = Volley.newRequestQueue(context);
mImageLoader = new ImageLoader(queue, new BitmapCache());
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
String url = getItem(position);
View view;
if (convertView == null) {
view = LayoutInflater.from(getContext()).inflate(R.layout.image_item, null);
} else {
view = convertView;
}
NetworkImageView image = (NetworkImageView) view.findViewById(R.id.image);
image.setDefaultImageResId(R.drawable.empty_photo);
image.setErrorImageResId(R.drawable.empty_photo);
image.setImageUrl(url, mImageLoader);
return view;
}
/**
* 使用LruCache来缓存图片
*/
public class BitmapCache implements ImageCache {
private LruCache<String, Bitmap> mCache;
public BitmapCache() {
// 获取应用程序最大可用内存
int maxMemory = (int) Runtime.getRuntime().maxMemory();
int cacheSize = maxMemory / 8;
mCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getRowBytes() * bitmap.getHeight();
}
};
}
@Override
public Bitmap getBitmap(String url) {
return mCache.get(url);
}
@Override
public void putBitmap(String url, Bitmap bitmap) {
mCache.put(url, bitmap);
}
}
}
复制代码
没错,就是这么简单,一共 60 行左右的代码搞定一切!我们不需要自己再去写一个 BitmapWorkerTask 来处理图片的下载和显示,也不需要自己再去管理 LruCache 的逻辑,一切 NetworkImageView 都帮我们做好了。至于上面的代码我就不再做解释了,因为实在是太简单了。
那么当然了,虽然现在没有做任何额外的逻辑处理,但是也根本不会出现图片乱序的情况,因为 NetworkImageView 在内部都帮我们处理掉了。不过大家可能都很好奇,NetworkImageView 到底是如何做到的呢?那么就让我们来分析一下它的源码吧。
NetworkImageView 中开始加载图片的代码是 setImageUrl() 方法,源码分析就从这里开始吧,如下所示:
/**
* Sets URL of the image that should be loaded into this view. Note that calling this will
* immediately either set the cached image (if available) or the default image specified by
* {@link NetworkImageView#setDefaultImageResId(int)} on the view.
*
* NOTE: If applicable, {@link NetworkImageView#setDefaultImageResId(int)} and
* {@link NetworkImageView#setErrorImageResId(int)} should be called prior to calling
* this function.
*
* @param url The URL that should be loaded into this ImageView.
* @param imageLoader ImageLoader that will be used to make the request.
*/
public void setImageUrl(String url, ImageLoader imageLoader) {
mUrl = url;
mImageLoader = imageLoader;
// The URL has potentially changed. See if we need to load it.
loadImageIfNecessary(false);
}
复制代码
setImageUrl() 方法中并没有几行代码,让人值得留意的是 loadImageIfNecessary() 这个方法,看上去具体加载图片的逻辑就是在这里进行的,那么我们就跟进去瞧一瞧:
/**
* Loads the image for the view if it isn't already loaded.
* @param isInLayoutPass True if this was invoked from a layout pass, false otherwise.
*/
private void loadImageIfNecessary(final boolean isInLayoutPass) {
int width = getWidth();
int height = getHeight();
boolean isFullyWrapContent = getLayoutParams() != null
&& getLayoutParams().height == LayoutParams.WRAP_CONTENT
&& getLayoutParams().width == LayoutParams.WRAP_CONTENT;
// if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content
// view, hold off on loading the image.
if (width == 0 && height == 0 && !isFullyWrapContent) {
return;
}
// if the URL to be loaded in this view is empty, cancel any old requests and clear the
// currently loaded image.
if (TextUtils.isEmpty(mUrl)) {
if (mImageContainer != null) {
mImageContainer.cancelRequest();
mImageContainer = null;
}
setDefaultImageOrNull();
return;
}
// if there was an old request in this view, check if it needs to be canceled.
if (mImageContainer != null && mImageContainer.getRequestUrl() != null) {
if (mImageContainer.getRequestUrl().equals(mUrl)) {
// if the request is from the same URL, return.
return;
} else {
// if there is a pre-existing request, cancel it if it's fetching a different URL.
mImageContainer.cancelRequest();
setDefaultImageOrNull();
}
}
// The pre-existing content of this view didn't match the current URL. Load the new image
// from the network.
ImageContainer newContainer = mImageLoader.get(mUrl,
new ImageListener() {
@Override
public void onErrorResponse(VolleyError error) {
if (mErrorImageId != 0) {
setImageResource(mErrorImageId);
}
}
@Override
public void onResponse(final ImageContainer response, boolean isImmediate) {
// If this was an immediate response that was delivered inside of a layout
// pass do not set the image immediately as it will trigger a requestLayout
// inside of a layout. Instead, defer setting the image by posting back to
// the main thread.
if (isImmediate && isInLayoutPass) {
post(new Runnable() {
@Override
public void run() {
onResponse(response, false);
}
});
return;
}
if (response.getBitmap() != null) {
setImageBitmap(response.getBitmap());
} else if (mDefaultImageId != 0) {
setImageResource(mDefaultImageId);
}
}
});
// update the ImageContainer to be the new bitmap container.
mImageContainer = newContainer;
}
复制代码
这里在第 43 行调用了 ImageLoader 的 get() 方法来去请求图片,get() 方法会返回一个 ImageContainer 对象,这个对象封装了图片请求地址、Bitmap 等数据,每个 NetworkImageView 中都会对应一个 ImageContainer。然后在第 31 行我们看到,这里从 ImageContainer 对象中获取封装的图片请求地址,并拿来和当前的请求地址做对比,如果相同的话说明这是一条重复的请求,就直接 return 掉,如果不同的话就调用 cancelRequest() 方法将请求取消掉,然后将图片设置为默认图片并重新发起请求。
那么解决图片乱序最核心的逻辑就在这里了,其实 NetworkImageView 的解决思路还是比较简单的,就是如果这个控件已经被移出了屏幕且被重新利用了,那么就把之前的请求取消掉,仅此而已。
而我们都知道,在通常情况下,仅仅这么处理可能是解决不了问题的,因为 Java 的线程无法保证一定可以中断,即使像第二种解决方案里使用的 BitmapWorkerTask 的 cancel() 方法,也不能保证一定可以把请求取消掉,所以还需要使用弱引用关联的处理方式。但是在 NetworkImageView 当中就可以这么任性,仅仅调用 cancelRequest() 方法把请求取消掉就可以了,这主要是得益于 Volley 的出色设计。由于 Volley 在网络方面的封装非常优秀,它可以保证,只要是取消掉的请求,就绝对不会进行回调,既然不会回调,那么也就不会回到 NetworkImageView 当中,自然也就不会出现乱序的情况了。
需要注意的是,Volley 只是保证取消掉的请求不会进行回调而已,但并没有说可以中断任何请求。由此可见即使是 Volley 也无法做到中断一个正在执行的线程,如果有一个线程正在执行,Volley 只会保证在它执行完之后不会进行回调,但在调用者看来,就好像是这个请求就被取消掉了一样。
那么这里我们只分析与图片乱序相关部分的源码,如果你想了解关于 Volley 更多的源码,可以参考我之前的一篇文章 Android Volley 完全解析 (四),带你从源码的角度理解 Volley 。
这是第三种解决方案。
好了,关于 ListView 异步加载图片乱序的问题今天我们就讨论到这里,如果你把三种解决方案都理解清楚的话,那么对于这个问题研究的就算比较透彻了。下一篇文章仍然是 ListView 主题,我们将学习一下如何对 ListView 控件进行一些功能扩展,感兴趣的朋友请继续阅读 Android ListView 功能扩展,实现高性能的瀑布流布局 。
blockquote{border-left: 10px solid rgba(128,128,128,0.075); background-color: rgba(128,128,128,0.05); border-radius: 0 5px 5px 0; padding: 15px 20px; }
关注我的技术公众号“郭霖”,优质技术文章推送。