Wednesday, 18 March 2015

Android - Properly Loading Web Images in a ListView


Here's demo of how to properly fetch images asynchronously into an Android ListView.
Read more for how to overcome the pitfalls of a rudimentary implementation.
Full source code and demo provided.

Google Play Demo

Follow this link to download the sample demo from Google Play for your device, or scan the QR code below.

The Problem

Across various blog posts, I've been seeing a rudimentary implementation of an Android ListView which pulls images from the web, and it looks something like this:

// Inside the Row Adapter class:

@Override
public View getView(int position, View convertView, ViewGroup parent) {

    View view = convertView;

    if (view == null) {
        view = inflater.inflate(R.layout.listview_row, null);
    }

    final ImageView imageView = (ImageView) view
            .findViewById(R.id.imageView);
    final String imageURL = data[position];

    //
    // DON'T DO THIS! - see sections below
    //

    AsyncTask<Params, Progress, Result> asyncTask = new AsyncTask() {
        
        @Override
        protected Object doInBackground(Object... params) {

            try {
                HttpURLConnection connection = (HttpURLConnection) new URL(
                        imageURL).openConnection();
                connection.connect();
                InputStream input = connection.getInputStream();
                bmp = BitmapFactory.decodeStream(input);
                imageView.setImageBitmap(bmp);
            } catch (Exception e) {
                imageView.setImageResource(R.drawable.not_found);
            }

            return null;
        }
        
    };

    asyncTask.execute();

    return view;
}

//
// DON'T DO THIS! - see below :)
//

Doing things this way will lead to a couple of issues:
  1. Since Views in the ListView are reused when the list is scrolled, multiple threads will simultaneously be fetching an image for the same View. And since we have no control over which thread finishes the download first, it is possible that we might end up with an image in the incorrect View.
  2. Every time a view is updated (ie, for every list item that is scrolled past), an AsyncTask will be created, queueing up a new thread. This can max out the thread limit and stall subsequent AsyncTasks. Also, it will fetch images that may no longer be on the user's screen if the user is quickly scrolling through.

The Solution

  1. The main thread (UI thread) needs to determine if the image retrieved from an asynchronous thread is in fact the image we want.
  2. We should be creating at most the same number of AsyncTasks as there are active views for the ListView displayed.

The Code

We can create a singleton class that can manage all web image request threads, and associate them with the correct Views.

Please note that this post/code is not about image caching, although it can be easily added to the following implementation.

Without further ado here's some code (a link to the entire sample project source is included below):

// In the Row Adapter class:

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    
    View view = convertView;
    if (view == null) {
        view = inflater.inflate(R.layout.listview_row, null);
    }

    final ImageView imageView = (ImageView) view
            .findViewById(R.id.imageView);

    WebImageLoader.getInstance().bindWebImageToImageView(data[position],
            imageView);

    return view;
}


// Singleton class that launches async tasks
// (Some code omitted - see below for link to full code)

public class WebImageLoader {

    // (Some code omitted - see below for link to full code)
    
    // Hold a map of ImageView to AsyncTasks, to ensure that each view only has
    // one active AsyncTask
    private final HashMap<ImageView, WebImageAsyncTask> imageThreadMap;

    public void bindWebImageToImageView(final String imageUrl,
            final ImageView imageView) {

        WebImageAsyncTask imageThread = imageThreadMap.get(imageView);

        if (imageThread == null) {
            // Spawn an image download thread if one doesn't already exist for
            // the image view
            imageThread = new WebImageAsyncTask(imageView, imageThreadMap);
            imageThread.pushUrl(imageUrl);
            imageThreadMap.put(imageView, imageThread);
            imageThread.execute();
        } else {
            // If image download thread already exists, then push the new url to
            // the thread
            imageThread.pushUrl(imageUrl);
        }
    }
}


// The asynchronous task class
// (Some code omitted - see below for link to full code)

class WebImageAsyncTask extends AsyncTask {
    
    // (Some code omitted - see below for link to full code)
    
    private String latestURL;
    private long lastQueueTime;

    public void pushUrl(String queueURL) {
        latestURL = queueURL;
        lastQueueTime = System.currentTimeMillis();
    }

    @Override
    protected Object doInBackground(Object... params) {
        boolean isImageViewSet = false;

        while (!isImageViewSet) {
            // Sleep until ImageView has stabilized (ie, list is not scrolling)
            while ((System.currentTimeMillis() - lastQueueTime) < DELAY_TIME) {
                sleep(50);
            }

            String downloadURL = latestURL;
            Bitmap bmp = null;

            // Fetch the image from URL
            try {
                HttpURLConnection connection = (HttpURLConnection) new URL(
                        downloadURL).openConnection();
                connection.connect();
                InputStream input = connection.getInputStream();
                bmp = BitmapFactory.decodeStream(input);
            } catch (Exception e) {
                Log.d(TAG, e.toString());
            }

            // Set the image to image view if it is the valid image
            isImageViewSet = setImageViewInMainThread(imageView, bmp,
                    downloadURL);

            // If setImageViewInMainThread returns false, then a new image url
            // was pushed while one was being downloaded, and no bitmap was set
            // on the image view, in that case, we continue the loop to download
            // the image from the newly pushed url.
        }
        return null;
    }
    
    
    private boolean setImageViewInMainThread(final ImageView imageView,
            final Bitmap bmp, final String downloadedURL) {

        final Semaphore semaphore = new Semaphore(0);
        final Status isNewImageSet = new Status();

        // Handle the UI update in the main thread
        Handler handler = new Handler(Looper.getMainLooper());
        handler.post(new Runnable() {
            @Override
            public void run() {
                if (downloadedURL.equals(latestURL)) {

                    // If the downloaded image url mathes the latest url pushed,
                    // then this thread has retrieved the latest (correct)
                    // image. So we set this image to the ImageView.
                    if (bmp != null) {
                        imageView.setImageBitmap(bmp);
                    } else {
                        imageView.setImageResource(R.drawable.not_found);
                    }

                    // Remove the reference to this thread in thread map so that
                    // it can be garbage collected.
                    imageThreadMap.remove(imageView);
                    isNewImageSet.status = true;

                } else {
                    isNewImageSet.status = false;
                }
                semaphore.release();
            }
        });

        // Block until main thread is done
        try {
            semaphore.acquire();
        } catch (Exception e) {
            Log.d(TAG, e.toString());
        }
        return isNewImageSet.status;
    }
}

Full source

The source code for the entire sample app can be downloaded here from GitHub.