Android Development Tutorial: Asynchronous Lazy Loading and Caching of ListView Images
In my
last post I created a simple Android Twitter feed reader based on the Twitter Search API, demonstrating an application of custom ListView layouts and integration of internet data sources. Any readers who tried out the code would immediately notice that, though functional, the implementation produced significant lag in the UI when scrolling through the tweets. 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 the Twitter profile 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 the Twitter example to add asynchronous lazy loading and caching of images (Twitter profile images, in our case). 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. First, let's recall where we left off last time. We were using a custom ArrayAdapter to set the view components of each item in our custom ListView:
Each time getView() was called, a method getBitmap(url) was initiated, which reached out to the web to pull down the image required:
Since all 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:
- Images are being downloaded from the UI thread
- 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. Easy, right? 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. Ok, ok...it could lead to weird bugs that are tough to track down. Either way, undesirable. Additionally, if we have a lot of tweets in our ListView, we may end up downloading a large number of profile 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:
I've added the first things we know we will need, which are a map (imageMap) 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 "data/codehenge" 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:
I'm making this a private class in my ImageManager class, but you could separate it out if you wanted to. For the actual queue, I'm using a stack of ImageRef 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.
We need a way to add images to the queue, so let's write a small queueImage() method:
Using the method above we're able to push an imageRef 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 ImageQueueManager class:
This is a big one, but not too complex. Basically, this class is meant to run as a single background thread, so its implementing the Runnable interface and overriding its run() method. 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 imageRef 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. I've put a TODO comment here, we'll come back to it in a little bit.
For now, we know what getBitmap() needs to do, so let's define it:
If you remember my last tutorial, some of this will look familiar. Namely, I have taken the BitmapFactory code straight out of the custom TweetItemAdapter and dropped it here, with some added functionality around it. Now, if we have the bitmap file cached locally, get it from there. If not, we use BitmapFactory to download it, and write it to the cache for next time. Note that in the writeFile() method we are compressing the bitmap into PNG format at 80% quality to save storage space and time. You can tweak this however you like, and check out the performance difference for your application.
We're now done with ImageManager, 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 updated version of TweetItemAdapter:
There are a few changes to this class sincelast time, but nothing severe. Out adapter now needs an instance of our ImageManager, and it also needs a reference to its Activity object, which you'll recall has to be passed to the ImageManager 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 Tweet, 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 Tweet object and a View that are not null, we call a method called displayImage() through the ImageManager:
This is the place where we set the bitmap immediately if it is in our imageMap, or push it into our queue and instead put a placeholder there. The placeholder, R.drawable.icon, is a default Android icon you will probably find automatically included in your project. 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. So, if it hasn't, the placeholder will be displayed until we have a proper Twitter avatar. 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:
Now we have a class that can be run on a thread, that is instantiated with a bitmap and an ImageView it needs to be displayed in, whose run() method sets in the ImageView or replaces it with a placeholder image. Let's add this into the space we left for it in ImageQueueManager:
The ImageQueueManager class is now complete. 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().
Last, but not least, is our Activity class, which remains basically unchanged from the previous tutorial. Here it is, for the sake of completeness:
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.
I've put the full project from this tutorial up on
Github, so go ahead and grab it, fork it, try it out for yourself. Happy coding!
Get 50% off my Node.js course here
If you liked this article, help me out by sharing a 50% discount to my Node.js course here: Tweet Thanks!
You should follow me on Twitter here: Follow @AaronCois