среда, 22 июня 2011 г.

Asynchronous Lazy Loading and Caching of ListView Images

I am using a custom CursorAdapter for a ListView to display some images and captions associated with those images. I am getting the images from the Internet.
The implementation produced significant lag in the UI when scrolling through items. This is because the getView() method of a ListView adapter can be called one or more times each time a ListView item comes into view – we are given no guarantees on when or how this method will be called. Therefore, downloading images within getView() was extremely inefficient, as each image was being downloaded by the UI thread as the ListView item came into view, and was usually downloaded repeatedly after that.

Today I’ll refactor my app to add asynchronous lazy loading and caching of images. Some of the code included has been based on the excellent demonstration provided by Github user thest1. Through this example, I’ll demonstrate asynchronous operations in Android, using local storage for caching data, ViewHolders, and a few other advanced techniques for optimizing app performance.


Since all(downloading images) of this is happening on the UI thread, massive UI lag resulted, which would create a poor user experience for a production app. The root problems are:

  1. Images are being downloaded from the UI thread
  2. Images are being downloaded many, many more times than they need to be, since getView() is called as often as Android feels like calling it

So, what can we do?
First, Problem #1. The obvious solution is to download the image and set it to the ImageView in a separate thread. This is the first thing every developer tries when they encounter this problem. Unfortunately, its not so simple. The Android UI is absolutely, completely, not thread-safe. So, calling the ImageView in a worker thread could lead to your DOOM. Either way, undesirable.
Additionally, if we have a lot of items in our ListView, we may end up downloading a large number of  images that our user never scrolls down to see, wasting network data usage and battery power, two things we want to minimize use of in mobile apps. The best solution to this issue is to lazy load the images in a separate thread, while placing them in our ImageViews with the main UI thread. A good solution is to cache the images somewhere the UI thread can access them, and let the UI thread know when they are available for display. This approach has the added benefit of addressing Problem #2, as well as our inefficient network/battery use, by downloading each image once and storing it in a local cache.
To start, we’ll be adding a class to manage both our local image cache and a download queue for the images. Let’s call this class ImageManager, and get it started:
public class AvatarDownloader {
    
    //the simplest in-memory cache implementation. This should be replaced with something like SoftReference or BitmapOptions.inPurgeable(since 1.6)
    private HashMap cache=new HashMap();
    
    private File cacheDir;
    
    public AvatarDownloader(Context context){
        //Make the background thread low priority. This way it will not affect the UI performance
        photoLoaderThread.setPriority(Thread.NORM_PRIORITY-1);
        
        //Find the dir to save cached images
        if (android.os.Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED))
            cacheDir=new File(android.os.Environment.getExternalStorageDirectory(),"/download/myApp/avatars/");
        else
            cacheDir=context.getCacheDir();
        if(!cacheDir.exists())
            cacheDir.mkdirs();
    }
}

I’ve added the first things we know we will need, which are a map (cache) to store images for display, and reference to the directory where the longer-term image cache will be stored. In the constructor, find this directory by querying the device to see if external storage is mounted, and if not, by getting the default cache location. If we are using external storage (e.g. an SD card), we create a directory called “/download/myApp/avatars/” for our app’s cache.
Now we will need a few classes to manage a proper queue of images for download. For each image put into the queue, we need to know the URL to find it at, and we will eventually need to know the ImageView to put it in. Here’a an ImageRef class to take care of that:
//Task for the queue
    private class PhotoToLoad {
        public String url;
        public ImageView imageView;
        public String profilePic;
        public PhotoToLoad(String u, ImageView i, String profilePic){
            this.url   = u; 
            this.imageView  = i;
            this.profilePic = profilePic;
        }
    } 
I’m making this a private class in my AvatarDownloader class, but you could separate it out if you wanted to. For the actual queue, I’m using a stack of PhotoToLoad objects. However, I’m wrapping this in its own class so I can add extra functionality. Because we know getView() can be called arbitrarily and often, for now I’ll add the ability to clear all ImageRef objects from the queue that are pointing to a given ImageView, so we don’t get too bottled up.
//stores list of photos to download
    class PhotosQueue{
        private Stack photosToLoad=new Stack();
        
        //removes all instances of this ImageView
        public void Clean(ImageView image)
        {
            for(int j=0 ;j<photosToLoad.size();){
                if(photosToLoad.get(j).imageView==image)
                    photosToLoad.remove(j);
                else
                    ++j;
            }
        }
    }
We need a way to add images to the queue, so let’s write a small queuePhoto() method:
private void queuePhoto(String url, Activity activity, ImageView imageView, String profilePic){
        //This ImageView may be used for other images before. So there may be some old tasks in the queue. We need to discard them. 
        photosQueue.Clean(imageView);
        PhotoToLoad p=new PhotoToLoad(url, imageView, profilePic);
        synchronized(photosQueue.photosToLoad){
            photosQueue.photosToLoad.push(p);
            photosQueue.photosToLoad.notifyAll();
        }
        
        //start thread if it's not started yet
        if(photoLoaderThread.getState()==Thread.State.NEW)
            photoLoaderThread.start();
    }

Using the method above we’re able to push an PhotoToLoad for an image into the queue (remember to lock this action!) and start the background imageLoaderThread, if it isn’t already started.
Now we’re ready to start some asynchronous coding. Basically, we need a thread to run in the background, watch the queue, and get images (either from our semi-persistent cache, or by downloading them) as they are queued. For this, let’s create an PhotosLoader class:
class PhotosLoader extends Thread {
        public void run() {
            try {
                while(true)
                {
                    //thread waits until there are any images to load in the queue
                    if(photosQueue.photosToLoad.size()==0)
                        synchronized(photosQueue.photosToLoad){
                            photosQueue.photosToLoad.wait();
                        }
                    if(photosQueue.photosToLoad.size()!=0)
                    {
                        PhotoToLoad photoToLoad;
                        synchronized(photosQueue.photosToLoad){
                            photoToLoad=photosQueue.photosToLoad.pop();
                        }
                        Bitmap bmp=getBitmap(photoToLoad.url, photoToLoad.profilePic);
                        cache.put(photoToLoad.url, bmp);
                        Object tag=photoToLoad.imageView.getTag();
                        if(tag!=null && ((String)tag).equals(photoToLoad.url)){
                            BitmapDisplayer bd=new BitmapDisplayer(bmp, photoToLoad.imageView);
                            Activity a=(Activity)photoToLoad.imageView.getContext();
                            a.runOnUiThread(bd);
                        }
                    }
                    if(Thread.interrupted())
                        break;
                }
            } catch (InterruptedException e) {
                //allow thread to exit
            }
        }
    }

This is a big one, but not too complex. When run, this thread process will loop until interrupted, waiting for an image to show up in the queue. When an image is queued, it pops each photoToLoad from the stack in turn and calls getBitmap() (which we have yet to define) to get an Bitmap object, puts the image in our map, and will, once we define it, fire off a process on the UI thread to display the image in the ListView. 
For now, we know what getBitmap() needs to do, so let’s define it:
private Bitmap getBitmap(String url, String profilePic){
        //I identify images by hashcode. Not a perfect solution, good for the demo.
        //String filename=String.valueOf(url.hashCode());
        //File f=new File(cacheDir, filename);
     
        File f=new File(cacheDir, profilePic);
        
        //from SD cache
        Bitmap b = decodeFile(f);
        if(b!=null)
            return b;
        
        //from web
        try {
            Bitmap bitmap=null;
            InputStream is=new URL(url).openStream();
            OutputStream os = new FileOutputStream(f);
            Utils.CopyStream(is, os);
            os.close();
            bitmap = decodeFile(f);
            return bitmap;
        } catch (Exception ex){
           ex.printStackTrace();
           return null;
        }
    }

Now, if we have the bitmap file cached locally, get it from there. If not, we download it, and write it to the cache for next time. 
We’re now done with AvatarDownloader, and can move on to integrating it with our UI components.
There are two paths we can take to display our image. First, if we have it sitting in our cache and available when getView() asks for it, we can just display it and move on. Alternatively, if we have to queue the download of the image, we need to be able to jump back into the UI thread as soon as the image is available to push it into view. Starting with the first path, let’s look at an version of my CursorAdapter:
public class ContactsAdapter extends CursorAdapter {

 private LayoutInflater mInflater;
 private Context activityContext;
 private Context baseContext;
 //
 public AvatarDownloader imageLoader;
 private Activity activity;
 private ViewHolder holder;
 
 public ContactsAdapter(Context mContext, Context aContext, Cursor cursor, Activity activity) {
        super(mContext, cursor);
        mInflater = LayoutInflater.from(mContext); 
        activityContext = aContext;
        baseContext = mContext;
        //
        imageLoader=new AvatarDownloader(baseContext);
        this.activity = activity;
    }  

 public static class ViewHolder{
     public TextView username;
     public TextView status;
     public TextView timetext;
     public ImageView avatar;
     public ImageView statusImg;
   }
 
 @Override
 public void bindView(View v, Context context, Cursor c) { 
  holder=(ViewHolder)v.getTag();
       
     //bind here
 }
   
   private void putAvatarImage(ImageView iv, String profilePic, String hereId, Context context) {
    String urlString = "http://link.to.site.here?email=" + hereId;
  urlString = urlString.replace(" ", "%20");
  iv.setTag(urlString);
    imageLoader.DisplayImage(urlString, profilePic, activity, iv);    
   }

 private boolean isIntNumber(String num) {
  try {
   Integer.parseInt(num);
  } catch (NumberFormatException nfe) {
   return false;
  }
  return true;
 }
 
 @Override
 public View newView(Context context, Cursor cursor, ViewGroup parent) {
  final LayoutInflater inflater = LayoutInflater.from(context);
        View v = inflater.inflate(R.layout.item_tab_friends, parent, false);  
  
        holder = new ViewHolder();
     holder.username = (TextView) v.findViewById(R.id.namefriendtext);
     holder.status = (TextView) v.findViewById(R.id.statustext);
     holder.timetext = (TextView) v.findViewById(R.id.timetext);
     holder.avatar = (ImageView) v.findViewById(R.id.friendicon);
     holder.statusImg = (ImageView) v.findViewById(R.id.statusicon);
     v.setTag(holder);     
  bindView(v, context, cursor);
        return v;  
 }
 }

Our adapter now needs an instance of our AvatarDownloader, and it also needs a reference to its Activity object, which you’ll recall has to be passed to the AvatarDownloader when it displays an image. We’ve also introduced the usage of a ViewHolder, which is a handy tool that optimizes performance a bit. Using a ViewHolder basically means we don’t have to call findViewById for every single view, every time we want it, which adds up to a decent amount of computational savings. For some more information on ViewHolders and why you want to use them, check out this post by Charlie Collins. You’ll also notice that when initially populating the View for a given images, I now set the tag of the ImageView to the url of the image to be displayed. We’ll use this later to verify that we are setting the image in the correct ImageView.
Look at the end of getView() and you’ll see where we address the “display the image immediately is possible” path. When we have both a image object and a View that are not null, we call a method called displayImage() through the AvatarDownloader:
public void DisplayImage(String url, String profilePic, Activity activity, ImageView imageView){
        if(cache.containsKey(url))
            imageView.setImageBitmap(cache.get(url));
        else
        {
            queuePhoto(url, activity, imageView, profilePic);
            imageView.setImageResource(stub_id);
        }    
}

This is the place where we set the bitmap immediately if it is in our cache, or push it into our queue and instead put a placeholder there. The placeholder, stub_id, is a default icon you will set if image for this account not exists. The reason for the placeholder is that we expect this method to be invoked anytime getView() is called, whether or not the image has been downloaded yet. If you like, you can make this placeholder a different image, a blank image, or nothing at all.
Now we need address our second path by writing some code to display the bitmap in the ListView, using the UI thread. As before, this is easily implemented as a class using the Runnable interface, like so:
//Used to display bitmap in the UI thread
class BitmapDisplayer implements Runnable  {
        Bitmap bitmap;
        ImageView imageView;
        public BitmapDisplayer(Bitmap b, ImageView i){bitmap=b;imageView=i;}
        public void run()
        {
            if(bitmap!=null)
                imageView.setImageBitmap(bitmap);
            else
                imageView.setImageResource(stub_id);
        }
}
Remember, when we have images in the queue, we get the bitmap from cache or download, then put it in our map. Now we continue by checking the tag to verify the bitmap we have belongs in this ImageView, create a new BitmapDisplayer object, get the activity from the ImageView, and use it to run the BitmapDisplayer operations in the UI thread. Notice the check of the ImageView tag: This allows us to be absolutely certain that we are putting the bitmap we have into the ImageView that wants it, which is basically our last stand against the inexplicable behavior of ListViews and getView().


And we’re done! The code is complete, and ready to run. You will see a notable increase in UI responsiveness, and should really not notice any lag at all unless you swipe through a large list at warp speed. 


Last, is our AvatarDownloader class:
public class AvatarDownloader {
    
    //the simplest in-memory cache implementation. This should be replaced with something like SoftReference or BitmapOptions.inPurgeable(since 1.6)
    private HashMap cache=new HashMap();
    
    private File cacheDir;
    
    public AvatarDownloader(Context context){
        //Make the background thread low priority. This way it will not affect the UI performance
        photoLoaderThread.setPriority(Thread.NORM_PRIORITY-1);
        
        //Find the dir to save cached images
        if (android.os.Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED))
            cacheDir=new File(android.os.Environment.getExternalStorageDirectory(),"/download/myApp/avatars/");
        else
            cacheDir=context.getCacheDir();
        if(!cacheDir.exists())
            cacheDir.mkdirs();
    }
    
    final int stub_id=R.drawable.icon_contact_small;
    public void DisplayImage(String url, String profilePic, Activity activity, ImageView imageView)
    {
        if(cache.containsKey(url))
            imageView.setImageBitmap(cache.get(url));
        else
        {
            queuePhoto(url, activity, imageView, profilePic);
            imageView.setImageResource(stub_id);
        }    
    }
        
    private void queuePhoto(String url, Activity activity, ImageView imageView, String profilePic)
    {
        //This ImageView may be used for other images before. So there may be some old tasks in the queue. We need to discard them. 
        photosQueue.Clean(imageView);
        PhotoToLoad p=new PhotoToLoad(url, imageView, profilePic);
        synchronized(photosQueue.photosToLoad){
            photosQueue.photosToLoad.push(p);
            photosQueue.photosToLoad.notifyAll();
        }
        
        //start thread if it's not started yet
        if(photoLoaderThread.getState()==Thread.State.NEW)
            photoLoaderThread.start();
    }
    
    private Bitmap getBitmap(String url, String profilePic) 
    {
        //I identify images by hashcode. Not a perfect solution, good for the demo.
        //String filename=String.valueOf(url.hashCode());
        //File f=new File(cacheDir, filename);
     
        File f=new File(cacheDir, profilePic);
        
        //from SD cache
        Bitmap b = decodeFile(f);
        if(b!=null)
            return b;
        
        //from web
        try {
            Bitmap bitmap=null;
            InputStream is=new URL(url).openStream();
            OutputStream os = new FileOutputStream(f);
            Utils.CopyStream(is, os);
            os.close();
            bitmap = decodeFile(f);
            return bitmap;
        } catch (Exception ex){
           ex.printStackTrace();
           return null;
        }
    }

    //decodes image and scales it to reduce memory consumption
    private Bitmap decodeFile(File f){
        try {
            //decode image size
            BitmapFactory.Options o = new BitmapFactory.Options();
            o.inJustDecodeBounds = true;
            BitmapFactory.decodeStream(new FileInputStream(f),null,o);
            
            //Find the correct scale value. It should be the power of 2.
            final int REQUIRED_SIZE=70;
            int width_tmp=o.outWidth, height_tmp=o.outHeight;
            int scale=1;
            while(true){
                if(width_tmp/2<REQUIRED_SIZE || height_tmp/2<REQUIRED_SIZE)
                    break;
                width_tmp/=2;
                height_tmp/=2;
                scale*=2;
            }
            
            //decode with inSampleSize
            BitmapFactory.Options o2 = new BitmapFactory.Options();
            o2.inSampleSize=scale;
            return BitmapFactory.decodeStream(new FileInputStream(f), null, o2);
        } catch (FileNotFoundException e) {}
        return null;
    }
    
    //Task for the queue
    private class PhotoToLoad
    {
        public String url;
        public ImageView imageView;
        public String profilePic;
        public PhotoToLoad(String u, ImageView i, String profilePic){
            this.url   = u; 
            this.imageView  = i;
            this.profilePic = profilePic;
        }
    }
    
    PhotosQueue photosQueue=new PhotosQueue();
    
    public void stopThread()
    {
        photoLoaderThread.interrupt();
    }
    
    //stores list of photos to download
    class PhotosQueue
    {
        private Stack photosToLoad=new Stack();
        
        //removes all instances of this ImageView
        public void Clean(ImageView image)
        {
            for(int j=0 ;j<photosToLoad.size();){
                if(photosToLoad.get(j).imageView==image)
                    photosToLoad.remove(j);
                else
                    ++j;
            }
        }
    }
    
    class PhotosLoader extends Thread {
        public void run() {
            try {
                while(true)
                {
                    //thread waits until there are any images to load in the queue
                    if(photosQueue.photosToLoad.size()==0)
                        synchronized(photosQueue.photosToLoad){
                            photosQueue.photosToLoad.wait();
                        }
                    if(photosQueue.photosToLoad.size()!=0)
                    {
                        PhotoToLoad photoToLoad;
                        synchronized(photosQueue.photosToLoad){
                            photoToLoad=photosQueue.photosToLoad.pop();
                        }
                        Bitmap bmp=getBitmap(photoToLoad.url, photoToLoad.profilePic);
                        cache.put(photoToLoad.url, bmp);
                        Object tag=photoToLoad.imageView.getTag();
                        if(tag!=null && ((String)tag).equals(photoToLoad.url)){
                            BitmapDisplayer bd=new BitmapDisplayer(bmp, photoToLoad.imageView);
                            Activity a=(Activity)photoToLoad.imageView.getContext();
                            a.runOnUiThread(bd);
                        }
                    }
                    if(Thread.interrupted())
                        break;
                }
            } catch (InterruptedException e) {
                //allow thread to exit
            }
        }
    }
    
    PhotosLoader photoLoaderThread=new PhotosLoader();
    
    //Used to display bitmap in the UI thread
    class BitmapDisplayer implements Runnable
    {
        Bitmap bitmap;
        ImageView imageView;
        public BitmapDisplayer(Bitmap b, ImageView i){bitmap=b;imageView=i;}
        public void run()
        {
            if(bitmap!=null)
                imageView.setImageBitmap(bitmap);
            else
                imageView.setImageResource(stub_id);
        }
    }

    public void clearCache() {
        //clear memory cache
        cache.clear();
        
        //clear SD cache
        File[] files=cacheDir.listFiles();
        for(File f:files)
            f.delete();
    }
}

Комментариев нет:

Отправить комментарий