ListView 之异步加载图片乱序

文章内容摘取自

Android ListView异步加载图片乱序问题,原因分析及解决方案

场景描述

使用 ListView 异步加载图片的具体代码没有贴出,但程序的思路就是在 ListView 的 getView() 方法中,开异步请求(BitmapWorkerTask),从网络上获取图片,当图片获取成功后就将图片显示到 ImageView 上。

效果,当我们滑动 ListView 时,图片会自动变来变去,而且图片显示的位置也不正确,简直快乱成一锅粥了。

这里写图片描述

原因分析

理解内部原理,很多之前难以解释的问题就变得有理有据了。

ListView 之所以能够实现加载成百上千条数据都不会 OOM,最主要在于它内部优秀的实现机制。ListView 在借助 RecycleBin 机制的帮助下,实现了一个生产者和消费者的模式,不管有任意多条数据需要显示,ListView 中的子 View 其实来来回回就那么几个,移出屏幕的子 View 会很快被移入屏幕的数据重新利用起来,原理示意图如下所示:

这里写图片描述

那么思考一下,目前数据源就是很多图片的 URL 地址,而根据 ListView 的工作原理,显然不可能为每张图片都单独分配一个 ImageView 控件,ImageView 控件的个数其实就比一屏幕显示的图片数量稍微多一点而已,移出屏幕的 ImageView 控件会进入到 RecycleBin 当中,而新进入屏幕的元素则会从 RecycleBin 中获取 ImageView 控件。

每当有新的元素进入界面时就会回调 getView() 方法,而在 getView() 方法中会开启异步请求从网络上获取图片,注意网络操作都是比较耗时的,也就是说当我们快速滑动 ListView 的时候就很有可能出现这样一种情况,某一个位置上的元素进入屏幕后开始从网络上请求图片,但是还没有等到图片下载完成,它就又被移出了屏幕。这种情况下会产生什么样的现象呢?根据 ListView 的工作原理,就会将刚才位置上的图片显示到当前的位置上,因为虽然它们位置不同,但都是共用的同一个 ImageView 实例,这样就出现了图片乱序的情况。

继续,新进入屏幕的元素它也会发起一条网络请求来回去当前位置的图片,等到图片下载完的时候会设置到同样的 ImageView 上面,因此就会出现先显示一张图片,然后又变成了另外一张图片的情况,那么我们刚才看到的图片会自动变来变去的情况也就得到了解释。

解决方案

ListView 异步加载图片的问题没有标准的解决方案,很多人都有自己的一套解决思路,下面说明三种比较经典的解决方法,每学习一种思路,水平就能够更进一步的提高。

方案一 使用 findViewWithTag

操作

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 的实例,那么我们怎么才能拿到 ListView 的实例呢?getView() 方法中传入的第三个参数 ViewGroup parent 就是 ListView 的实例(ListView 本身并不负责绘制,而是由 ListView 当中的子元素来进行绘制的。而每一条 Item 都是通过 getView() 方法来完成的)。那么这里我们定义一个全局变量 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 之间建立一个双向关联,互相持有对方的引用,再通过适当的逻辑判断来解决图片乱序问题,然后为了防止出现内存泄漏的情况,双方关联要使用弱引用的方式建立。

相比于第一种解决方案,第二种解决方案要明显复杂不少,但在性能和效率方面都会有更好的表现。

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 进行一层包装。

但是我们很难将 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

NetworkImageView 是 Volley 当中提供的控件。操作很简单,我们只需要把 Imageview 替换成 NetworkImageView ,然后在 ImageAdapter 作相应的修改即可。

我们不需要自己再去写一个 BitmapWorkerTask 来处理图片的下载和显示,也不需要自己再去管理 LruCache 的逻辑,一切 NetworkImageView 都帮我们做好了。不需要额外的逻辑,也根本不会出现图片乱序的情况。

NetworkImageView 中开始加载图片的代码是 setImageUrl() 方法,源码如下:

/**
 * @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);  
}  

具体加载逻辑在 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;  
}  

ImageLoader 的 get() 方法用来请求图片,返回一个 ImageContainer 对象,该对象封装了图片请求地址、Bitmap 等数据,每个 NetworkImageView 中都会对应一个 ImageContainer。

从 ImageContainer 对象中获取封装的图片请求地址,并拿来和当前的请求地址做对比,如果相同的话说明这是一条重复的请求,就直接 return 掉,如果不同的话就调用 cancelRequest() 方法将请求取消掉,然后将图片设置为默认图片并重新发起请求。

NetworkImageView 解决图片乱序的核心逻辑就是如果该控件已经被移出了屏幕且被重新利用了,就把之前的请求取消掉。

但是,通畅情况下,java 线程无法保证一定可以中断,即使像第二种解决方案里使用 BitmapWorkerTask 的 cancel() 方法也不能保证一定可以把请求取消掉,所以还需要使用弱引用关联的处理方式。而在 NetworkImageView 中,仅仅调用 cancelRequest() 方法把请求取消掉姐可以了,这主要得益于 Volley 的出色设计,它保证只要是取消掉的请求,就绝对不会进行回调,既然不会回调,那么也就不会回到 NetworkImageView 当中,自然就不会出现乱序的情况。

需要注意的是,Volley 只是保证取消掉的请求不会进行回调而已,但并没有说可以中断请求。由此可见即使是 Volley 也无法做到中断一个正在执行的线程,如果有一个线程正在执行,Volley 只会保证在它执行完之后不会进行回调,但在调用者看来,就好像是这个请求就被取消掉了一样。

文章只是作为个人记录学习使用,如有不妥之处请指正,谢谢。

文章内容摘取自

Android ListView异步加载图片乱序问题,原因分析及解决方案

猜你喜欢

转载自blog.csdn.net/modurookie/article/details/80222982