Android开发之ListView异步加载图片

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/dmk877/article/details/49366421

ListView这个控件对于大家肯定不会陌生,即使你是初学者相信也会用ListView。因为ListView这个控件实在是太常用,可以说基本上每一个项目开发都会用到它,今天这篇博客主要讲解,ListView异步加载图片的问题,相信通过本篇博客的学习你将有意想不到的收获。如有谬误请批评指正,如有疑问请留言。


1.内容介绍


通过本篇博客你将学到

①ListView异步加载图片的方式

②给ImageView设置Tag,解决图片覆盖问题

采用LruCache缓存已经加载过的图片 

当ListView滚动时不加载图片,滚动停止时才加载图片,从而达到ListView滑动很流畅的效果 

当ListView加载图片时只加载当前屏幕内可见的条目

上述5种功能是我们经常在项目中遇到的,这也是用户体验最好的方式,那么今天就和大家一步一步探讨上述5种方式的实现方式。光说没用首先我们来上张图,看看我们究竟实现了什么效果

扫描二维码关注公众号,回复: 3444390 查看本文章


从上面图片我们可以清楚的看到,当我们滑动ListView时是不加载图片的,当停止时才异步加载图片,而且只加载当前可见条目的图片,并且进行了缓存,当下次加载时直接从缓存读取图片,这就是我们要达到的整体的效果,进入正题。


2.ListView异步加载图片的方式


(1)采用多线程的方式Handler+Message异步加载图片

首先我们来看第一种方式采用多线程,首先我们的方法需要两个参数,①显示图片的控件,②图片的路径

        //1.多线程的方法
	public void showImageByThead(ImageView iv,final String url){
		mImageView = iv;
		mUrl = url;
		new Thread(){
			public void run() {
				Bitmap bitmap = getBitmapFromUrl(url);
				Message message = Message.obtain();
				message.obj=bitmap;
				mHandler.sendMessage(message);
			};
		}.start();
	}

因为在子线程中是不能对UI进行更新的,所以我们需要通过Handler+Message的方式去在主线程中去更新UI,因此我们还需要一个Handler对象

private Handler mHandler = new Handler(){
		public void handleMessage(android.os.Message msg) {
			super.handleMessage(msg);
			
		 mImageView.setImageBitmap((Bitmap) msg.obj);
		};
	};

这样我们将耗时的操作放在子线程中待其拿到数据后发一条消息到主线程中,从而在主线程中进行更新显示。

在showImageByThread方法中用到了getBitmapFromUrl方法,它的代码如下

public Bitmap getBitmapFromUrl(String urlString){
		Bitmap bitmap;
		InputStream is = null;
		try {
			URL mUrl= new URL(urlString);
			HttpURLConnection connection = (HttpURLConnection) mUrl.openConnection();
			is = new BufferedInputStream(connection.getInputStream());
			bitmap=BitmapFactory.decodeStream(is);
			connection.disconnect();
			return bitmap;
		} catch (MalformedURLException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}finally{
			try {
				is.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		return null;
	}

(2)采用Android中AsyncTask

这种方式比较简单,如果你不熟悉AsyncTask的用法,请先学习下。这种方法其实就是对上面方法的封装我们创建一个方法showImageByAsyncTask,这个方法同样需要两个参数一个是显示图片的ImageView一个是图片的路径Url

public void showImageByAsyncTask(ImageView imageView,String url){
		new NewsAsyncTask(imageView,url).execute(url);
	}
NewsAsync的代码如下
class NewsAsyncTask extends AsyncTask<String,Void,Bitmap>{
		
		private ImageView myImageView;
		private String mUrl;
		
		public NewsAsyncTask(ImageView imageView,String url){
			myImageView = imageView;
			mUrl = url;
		}
              //String...params是可变参数接受execute中传过来的参数
		@Override
		protected Bitmap doInBackground(String... params) {
			
			String url=params[0];
			//这里同样调用我们的getBitmapFromeUrl
			Bitmap bitmap = getBitmapFromUrl(params[0]);
			return bitmap;
		}
		//这里的bitmap是从doInBackgroud中方法中返回过来的
		@Override
		protected void onPostExecute(Bitmap bitmap) {
			super.onPostExecute(bitmap);
		        imageView.setImageBitmap(bitmap);
		}
	}

以上就是异步加载图片的两种常用的方式。

接下来我们上个实例,试试上面两个方法行不行啊,这里我们只将Adapter的getView方法方法贴出来我们可以

public View getView(int position, View convertView, ViewGroup parent) {
		
		ViewHolder viewHolder=null;
		if(convertView==null){
			viewHolder=new ViewHolder();
			convertView=mInflater.inflate(R.layout.item_imooc,null);
			viewHolder.ivImage=(ImageView) convertView.findViewById(R.id.iv_image);
			viewHolder.tvContent=(TextView) convertView.findViewById(R.id.tv_content);
			viewHolder.tvTitle=(TextView) convertView.findViewById(R.id.tv_tile);
			convertView.setTag(viewHolder);
		}else{
			viewHolder=(ViewHolder) convertView.getTag();
		}
		viewHolder.ivImage.setImageResource(R.drawable.ic_launcher);
		
		ImageLoader imageLoader = new ImageLoader();
		imageLoader.showImageByAsyncTask(mList.get(position).getPicSmall(),viewHolder.ivImage);
//		imageLoader.showImageByThead(mList.get(position).getPicSmall(),viewHolder.ivImage);
		viewHolder.tvTitle.setText(mList.get(position).getName());
		viewHolder.tvContent.setText(mList.get(position).getDescription());
		
		return convertView;
	}
注:上面的ImageLoader是我们自己创建的类,其中showImageByAsyncTask方法和showImageByThread方法是在ImageLoader这个类中的。我们来运行下看下效果


认真看上图,发现实现了图片的异步加载,但是有个问题,请仔细看图片是不是有图片被重复替换即:先显示一张图片然后又变成了另一张图片,看鼠标晃动的位置你会发现有的图片显示好了之后又被替换掉了,有的还不止被替换一次,这是为什么呢?这个问题就要追究到ListView的复用的问题上了,我们想一下当ListView中有100个Item时,它怎么加载?肯定不会傻到一次把100条全部加载完吧。其实它只是将当前可见的条目加载出来。这个思想跟手游有点像,有的手机游戏:控制一个小人不停的往前跑,画面是不断的变化的,其实它也是只加载当前可见的画面,并不是一次全部加载出来的。但是ListView又跟上面这个例子有很大的区别,因为它还有复用这一说,我们看看getView

public View getView(int position, View convertView, ViewGroup parent)

在上面的例子中每个条目的结构都是一样的,无非是显示的图片和两个TextView的文字内容不一样,这样就给ListView的复用提供了前提。在ListView中提供了一个Recycler机制,用来缓存Item的缓冲池,在创建ListView第一屏数据的时候每一个条目都是新创建的,当我们滑动时使最上面的条目滑出屏幕,这时它并不会白白的消失掉,它会到缓冲池中,这样缓冲池的item就不为空了,接着我们再次滑动,它首先会到缓冲池中look look有没有现成的item可以直接拿过来用,如果有,将它的数据清除,填充上我们设置的数据。 可能大家还不是特别清楚,接下来看看下面这张图


注:上图是我参考网上的一张图片画的,为什么不用原图,因为我觉得原图是有问题的,首先我们一共有7个Item当滑动时第8个Item刚漏出时,此时Item的缓存池中是没有Item的,因此这一个Item也是重新创建的,所以一共会创建8个item,当第9个Item过来的时候它会去Recyler的缓存池中去获取发现有就会复用。所以复用是在产生第9个Item开始的。


了解了Adapter的convertView复用之后我们来分析一下为什么会产生图片替换的现象。

由于网络请求是一个比较耗时的操作,当我们快速滑动ListView的时候有可能出现这样一种情况,某一个位置上的元素进入屏幕后开始开始请求网络图片,但是还没等图片下载完成,它就被移出了屏幕,这样会导致什么结果呢?因为被移出屏幕的Item将会很快被进入屏幕的元素重新利用,而如果这个时候被复用的这个Item的图片请求还没有完成(这个请求是在子线程的),也就是说被复用的Item中的图片的网络请求还没有完成就被复用了,当被复用的Item中的网络图片请求完成时就会显示在Item的ImageView上,而新进入的Item也同样会发出一条图片的网络请求但它比被复用的Item晚那么一点点,当它请求完成时同样也会显示在Item的ImageView上,由于这个Item是复用的上面的Item所以它将把复用的Item的数据清除然后显示自己的网络请求图片,所以我们会看到上图中ImageView先显示一张图片接着就变为另一张图片的这种现象。


知道了这种情况原因那么我们怎么解决这个问题呢?

其实解决方案有多种,这里我们采用给ImageView设置Tag的方式,将ImageView的url设置为它的Tag,这样在每次显示图片的时候判断ImageView的tag是不是我们设置的如果是就显示图片。这样Adapter中的getView方法的代码如下

public View getView(int position, View convertView, ViewGroup parent) {
		
		Log.i("convertView","convertView="+convertView);
		ViewHolder viewHolder=null;
		if(convertView==null){
			viewHolder=new ViewHolder();
			convertView=mInflater.inflate(R.layout.item_imooc,null);
			viewHolder.ivImage=(ImageView) convertView.findViewById(R.id.iv_image);
			viewHolder.tvContent=(TextView) convertView.findViewById(R.id.tv_content);
			viewHolder.tvTitle=(TextView) convertView.findViewById(R.id.tv_tile);
			convertView.setTag(viewHolder);
		}else{
			viewHolder=(ViewHolder) convertView.getTag();
		}
		viewHolder.ivImage.setImageResource(R.drawable.ic_launcher);
		//给当前的ImageView设置Tag
		viewHolder.ivImage.setTag(mList.get(position).getPicSmall());
		ImageLoader imageLoader = new ImageLoader();
		imageLoader.showImageByAsyncTask(mList.get(position).getPicSmall(),viewHolder.ivImage);
//		imageLoader.showImageByThead(mList.get(position).getPicSmall(),viewHolder.ivImage);
		viewHolder.tvTitle.setText(mList.get(position).getName());
		viewHolder.tvContent.setText(mList.get(position).getDescription());
		
		return convertView;
	}

这样我们在显示图片的时候就要做一个判断,判断当前的ImageView的tag是否和我们所设置的相对应,于是乎在我们的ImageLoader类上面两种加载图片的方法中应该修改如下

采用多线程的方式Handler修改如下:

private Handler mHandler = new Handler(){
		public void handleMessage(android.os.Message msg) {
			super.handleMessage(msg);
			if(mImageView.getTag().equals(mUrl)){
				mImageView.setImageBitmap((Bitmap) msg.obj);
			}
		};
	};

增加了一个mImageView.getTag().equals(mUrl)判断。

同理采用AsyncTask方式修改如下

private class NewsAsyncTask extends AsyncTask<String, Void,Bitmap>{
		ImageView mImageView;
		String mUrl;
		
		public NewsAsyncTask(String url,ImageView imageView) {
			mImageView=imageView;
			mUrl=url;
		}

		@Override
		protected Bitmap doInBackground(String... params) {
			Bitmap bitmap = getBitmapFromUrl(params[0]);
			return bitmap;
		}
		
		@Override
		protected void onPostExecute(Bitmap bitmap) {
			super.onPostExecute(bitmap);
			if(mImageView.getTag().equals(mUrl)){
				mImageView.setImageBitmap(bitmap);
			}
		}		
	}
根据我们刚才的分析这样会解决图片替换的问题,真的是这样吗?我们要理论联系实际,运行看看效果截图如下


是吧没有出现我们上面的先显示一张图片又被替换掉的那种现象。这样①和②就解决了。但是和文章最开始的那张图还有差别的,仔细看你会发现,当ListView显示了一屏后,此时滑动ListView再滑动回来图片又加载了一次,这样的用户体验太差,可能我滑过去之后想回来看看之前的条目,但是你又给我加载了一遍,一方面浪费了流量,另一方面体验也不好,那么我们怎么办呢?解决这个问题的办法是采用LRU cache。

3.采用LruCache缓存已经加载过的图片


看到Lru你是否感觉熟悉?LRU是(Least Recently Used缩写)即近期最少使用算法,在操作系统中它是一种页面置换算法,这里就不多扯了,大家知道就行了,其实现逻辑谷歌工程师都已经帮我们写好了,我们只需要拿过来用就可以了,它的主要原理就是把最近使用的对象存储在LinkedHashMap中,并且把最近最少使用的对象在缓存值达到预设的值之前从内存中移除。那么它怎么使用呢?先看一个例子

private LruCache<String,Bitmap> mMemoryCaches;
		
		int maxMemory = (int) Runtime.getRuntime().maxMemory();
		int cacheSizes = maxMemory/5;
		
		mMemoryCaches = new LruCache<String, Bitmap>(cacheSizes){
			@SuppressLint("NewApi") @Override
			protected int sizeOf(String key, Bitmap value) {
				return value.getByteCount();
			}
		};
首先我们需要创建一个LruCache对象,接着,我们需要为其分配内存,这个内存的大小我们通常需要通过Runtime来获取,得到的是系统分配给当前App的内存大小,我们需要将系统分配给App的内存的一部分用作LruCache缓存,LruCache中必须重写sizeOf方法,通过这个方法,LruCache可以获取每个缓存对象的大小,子类必须重写,因为默认的LruCache获取的是缓存的个数。

创建好LruCache后,要想实现缓存我们需要在我们上篇博客中创建的ImageLoader中创建两个方法getBitmapFromLrucache(String url)和addBitmapToLrucaches(String url,Bitmap bitmap)这两个方法的实现非常的简单,因为LruCache有点类似于Map,通过Key,Value的方式进行存储,所以我们操作起来很方便,注意这里我们不需要释放内存的方法Lru算法可以保证cacheSize不会OOM,一旦超过这个大小,GC就会回收时间最长的对象,释放空间。
getBitmapFromLrucache(String url)的代码如下
public Bitmap getBitmapFromLrucache(String url){
		return mMemoryCaches.get(url);
	}

我擦嘞,就一行代码,哈哈,简单吧,接着看看addBitmapToLrucaches(String url,Bitmap bitmap)的代码,能不能复杂一点来抚慰我们受伤的心灵。。
public void addBitmapToLrucaches(String url,Bitmap bitmap){
		if(getBitmapFromLrucache(url)==null){
			mMemoryCaches.put(url,bitmap);
		}
	}

我再擦,就两行代码,没错就两行代码,那么我们怎么将其运用到项目中去呢?其实也很简单,它的思路是这样的,每次在显示图片的之前我首先去Lru Cache中去根据Key去找图片,如果有的话就拿过来直接显示,如果没有的话,我就开启一个异步任务去下载,下载完成后将此Bitmap加入到Lru Cache中,这样就能保证图片最多只加载一次。它的思路如下图

图片

那么具体到代码是这样的,这里以showByAsyncTask方法为例

public void showImageByAsyncTask(ImageView imageView, String url) {
		//首先去LruCache中去找图片
		Bitmap bitmap = getBitmapFromLrucache(url);
		//如果不为空,说明LruCache中已经缓存了该图片,则读取缓存直接显示,
		if (bitmap != null) {
			imageView.setImageBitmap(bitmap);
		} else {
			//如果缓存中没有的话就开启异步任务去下载图片,
			new NewsAsyncTask(imageView, url).execute(url);
		}
	}
showByAsyncTask方法中可以看出在显示图片时首先去LruCache中去读取图片,如果有的话就直接显示,没有就开启异步任务去下载,下载完成后需要添加到LruCache中,因此在异步任务中需要增加将下载好的图片添加到LruCache中的方法,也很简单就是直接调用上面的addBitmapToLrucaches这个方法,代码如下

class NewsAsyncTask extends AsyncTask<String, Void, Bitmap> {

		private ImageView myImageView;
		private String mUrl;

		public NewsAsyncTask(ImageView imageView, String url) {
			myImageView = imageView;
			mUrl = url;
		}
		@Override
		protected Bitmap doInBackground(String... params) {

			String url = params[0];
			Bitmap bitmap;

			bitmap = getBitmapFromUrl(url);
			//下载完成之后将其加入到LruCache中这样下次加载的时候,就可以直接从LruCache中直接读取
			if (bitmap != null) {
				addBitmapToLrucaches(url, bitmap);
			}
			return bitmap;
		}
		@Override
		protected void onPostExecute(Bitmap bitmap) {
			super.onPostExecute(bitmap);
			if (myImageView.getTag().equals(mUrl)) {
				myImageView.setImageBitmap(bitmap);
			}
		}

	}
这样就实现了将加载过的图片缓存的功能,是不是呢?我们运行下看看。。。


以上就解决了缓存加载过的图片的功能,但是还有个问题,请仔细看图片,我故意让ListView上下不停的滑动了一会,发现在滑动的过程中它也在加载图片,另外当我很快滑到底部的时候你会发现屏幕的最上面一个Item过了一小会才加载进来,这样的用户体验是不好的,因为最好的用户体验是我在滑动的时候停止所有的加载,当我停止的时候只加载我当前可见的Item,而上面不是这样的,它是从上往下加载所以我一下滑到底部时它会等一会才加载到我当前可见的Item,那么问题来了,我们怎么解决以上问题呢?即滑动不加载,停止时加载,且只加载当前可见条目。

4.滑动不加载,停止加载并且只加载当前可见条目


这种功能应该怎么实现呢?这里我们只需要对当前的ListView设置滚动监听就行了,我们都知道ListView有个OnScrollListener,有两个方法

        @Override
	public void onScrollStateChanged(AbsListView view, int scrollState) {

	}

	@Override
	public void onScroll(AbsListView view, int firstVisibleItem,
			int visibleItemCount, int totalItemCount) {
		}
	}
在onScrollStateChanged方法中可以对ListView的滚动状态进行判断scrollState==SCROLL_STATE_IDLE表明当前ListView处于空闲状态,此时我们进行任务的加载,而在onScroll方法中我们看到有三个参数

①firstVisibleItem:它表示当前显示在手机屏幕上的第一个Item的位置

②visibleItemCount:它表示当前显示在手机屏幕上的可见条目的个数

③totalItemCount:它表示ListView条目的总数

所以firstVisibleItem+visibleItemCount的和就是当前ListView显示在屏幕上的条目的位置范围,举个简单的例子,比方说ListView有100条数据,在滑动ListView停止后ListView的第20个条目显示在了手机屏幕的最上面即firstVisibleItem=19(firstVisible从0开始),而我们手机一屏可以显示10个Item那么,我们可以推算出当前显示在手机屏幕上的Item是ListView中的第20~30的Item。鉴于以上分析我们的代码要改了因为和刚才实现的逻辑不一样了,这次我们要实现的加载从firstVisibleItem到firstVisibleItem+visibleItemCount的Item,所以在这里我们要在我们创建的ImageLoader中重新创建一个方法接收两个参数start和end代码如下

public void loadImages(int start, int end) {

		for (int i = start; i < end; i++) {
			String loadUrl = mUrls[i];
			if (getBitmapFromLrucache(loadUrl) != null) {
				ImageView imageView = (ImageView) mListView
						.findViewWithTag(loadUrl);

				imageView.setImageBitmap(getBitmapFromLrucache(loadUrl));
			} else {
				NewsAsyncTask mNewsAsyncTask = new NewsAsyncTask(loadUrl);
				mTasks.add(mNewsAsyncTask);
				mNewsAsyncTask.execute(loadUrl);
			}
		}
	}
这次的改动相对来说比较大点,但是很简单,在上面这个方法中大家可以有三个地方不是特别清楚

第一:mUrls是啥?因为你我们要加载从start到end的数据,所以刚才的那个传递ImageView和其url的方法肯定不能用了,所以针对url我们在ImageLoader类中创建一个变量,在Adapter中new ImageLoader的时候将所有的图片的路径存储到此数组,所以Adapter中会有如下代码

public NewsAdapter(Context context,List<NewsBean> data,ListView listView){
		
		mList=data;
		mInflater=LayoutInflater.from(context);
		mListView=listView;
		isFirstIn = true;
		
		imageLoader=new ImageLoader(mListView);
		imageLoader.mUrls = new String[mList.size()];
		for(int i=0;i<mList.size();i++){
			imageLoader.mUrls[i] = mList.get(i).getPicSmall();
		}
		mListView.setOnScrollListener(this);
	}
从上面的代码中可以看到通过一个for循环,将所有的图片路径存在了ImageLoader的mUrls数组中了,这样图片的路径的问题就解决了。

第二:因为我们这次是显示从start到end的数据,ImageView是哪一个啊?怎么和url对应?从上面Adapter的构造方法中可以看到我们将ListView传递给我ImageLoader,因为我们的ImageView是设置了Tag的,所以我们可以通过ImageView imageView = (ImageView) mListView.findViewWithTag(loadUrl);来找到要显示图片的ImageView,这样ImageView的问题就解决了,
第三:认真看loadImages方法的童鞋会发现,有这么一句mTasks.add(mNewsAsyncTask),它是弄啥嘞?它是一个集合,用来保存当前异步加载的对象,存在于ImageLoader中Set<NewsAsyncTask> mTasks,要它干啥?因为我们是只在滚动停止时才去加载,所以当滚动开始时我们要取消所有的加载任务,而mTasks就是保存当前加载图片的所有的任务的集合,于是在ImageLoader中我们有了cancelAllTask方法,代码如下

public void cancelAllAsyncTask() {
		if (mTasks != null) {
			for (NewsAsyncTask newsAsyncTask : mTasks) {
				newsAsyncTask.cancel(false);
			}
		}
	}
ok啦,这里在Adapter中实现ListView的滚动监听并添加代码如下:

       @Override
	public void onScrollStateChanged(AbsListView view, int scrollState) {
		
		if(scrollState==SCROLL_STATE_IDLE){
			imageLoader.loadImages(mStart,mEnd);
		}else{
			imageLoader.cancelAllAsyncTask();
		}
	}

	@Override
	public void onScroll(AbsListView view, int firstVisibleItem,
			int visibleItemCount, int totalItemCount) {
		
		mStart=firstVisibleItem;
		mEnd=firstVisibleItem+visibleItemCount;
		
		if(isFirstIn&&visibleItemCount>0){
			imageLoader.loadImages(mStart,mEnd);
			isFirstIn=false;
		}
		
	}
上面的firstIn是干啥的?因为我们将图片的加载放在了onScrollStateChanaged方法中,而界面初始化的时候此方法并不会去调用,它只有在状态改变的时候才去调用,所以第一屏数据无法加载,但是onScroll方法是在初始化的时候就会调用,所以第一屏数据我们放在onScroll方法中。因为加载图片 是在 onScrollStateChanaged方法中,所以在getView方法中显示图片时直接去ImageLoader类的LruCache方法中去找即可,getView方法
public View getView(int position, View convertView, ViewGroup parent) {
		
		ViewHolder viewHolder=null;
		if(convertView==null){
			viewHolder=new ViewHolder();
			convertView=mInflater.inflate(R.layout.item_imooc,null);
			viewHolder.ivImage=(ImageView) convertView.findViewById(R.id.iv_image);
			viewHolder.tvContent=(TextView) convertView.findViewById(R.id.tv_content);
			viewHolder.tvTitle=(TextView) convertView.findViewById(R.id.tv_tile);
			convertView.setTag(viewHolder);
		}else{
			viewHolder=(ViewHolder) convertView.getTag();
		}
		
		viewHolder.ivImage.setTag(mList.get(position).getPicSmall());
		viewHolder.ivImage.setImageResource(R.drawable.ic_launcher);
		
		imageLoader.showImage(viewHolder.ivImage,mList.get(position).getPicSmall());
		
		viewHolder.tvTitle.setText(mList.get(position).getName());
		viewHolder.tvContent.setText(mList.get(position).getDescription());
		
		return convertView;
	}
shoImage方法

public void showImage(ImageView imageView, String url) {

		Bitmap bitmap = getBitmapFromLrucache(url);
		if (bitmap == null) {
			imageView.setImageResource(R.drawable.ic_launcher);
		} else {
			imageView.setImageBitmap(bitmap);
		}
	}

好了关于ImageView的异步加载图片的讨论就到这里了,当然还有好多内容,以上讲解的也是项目中常用的,在后面将会有二级缓存以及加载大图片等等。


下载源码戳这里。。


如果这篇博客对你有帮助就顶一下呗,您的支持是我前进的动力。。。。。

转载注明出处:http://blog.csdn.net/dmk877/article/details/49366421

参考视频:

http://www.imooc.com/view/406

















猜你喜欢

转载自blog.csdn.net/dmk877/article/details/49366421