Android Image Downloading and Caching

One of the utility classes we created for the Singly Android SDK is a remote image downloader and cache. We wanted to be able to download images from remote sites, store them locally, and cache them in memory. We also wanted to be able to limit the number of concurrent images we were downloading, avoid duplicate downloads, avoid retrying bad images, and more importantly optimize the images for memory consumption. In this post we will detail our RemoteImageCache class that can be dropped into any Android application to enable easy remote image downloading and caching.

Supporting Classes

To start we have some supporting classes, ImageInfo, ImageCacheListener, and BitmapUtils. The ImageInfo class is a holder class for images that we are downloading and caching. It holds fields such as the url of the image to download and the size we want to display the image. It also has an ImageCacheListener field.

public class ImageInfo {
  public String id = null;
  public String imageUrl = null;
  public int width = -1;
  public int height = -1;
  public Bitmap.CompressFormat format;
  public int quality = 100;
  public boolean sample = true;
  public ImageCacheListener listener = null;
}

The ImageCacheListener is used to do callbacks when an image has completed the download process.

public class ImageCacheListener {

  public void onSuccess(ImageInfo imageInfo, Bitmap bitmap) {
  }

  public void onFailure(Throwable error, ImageInfo imageInfo) {
  }
}

The BitmapUtils holds utility methods for working with Bitmaps. The decodeAndScaleImage method is used to correctly optimize a downloaded image for the size at which it will be displayed. We determine the best sample rate for the Bitmap for the given width and height. Then decode the Bitmap using that sample rate. The Bitmap isn’t returned at the width and height, it is returned using a sampling rate that is appropriate for the desired width and height as Android will automatically scales Bitmaps in many components.

public class BitmapUtils {

  public static Bitmap decodeAndScaleImage(byte[] bytes, int width, int height) {

    try {

      // decode the image file
      BitmapFactory.Options scaleOptions = new BitmapFactory.Options();
      scaleOptions.inJustDecodeBounds = true;
      BitmapFactory.decodeByteArray(bytes, 0, bytes.length, scaleOptions);

      // find the correct scale value as a power of 2.
      int scale = 1;
      while (scaleOptions.outWidth / scale / 2 >= width
        && scaleOptions.outHeight / scale / 2 >= height) {
        scale *= 2;
      }

      // decode with the sample size
      BitmapFactory.Options outOptions = new BitmapFactory.Options();
      outOptions.inSampleSize = scale;
      return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, outOptions);
    }
    catch (Exception e) {
    }

    return null;
  }
}

The RemoteImageCache

The meat of the downloader is the RemoteImageCache class. Here is the shell.

public class RemoteImageCache {

  public static final String DEFAULT_CACHE_DIR = "_image_cache_";
  private static final String POISON_PILL = "_shutdown_";
  private File storageDir;
  private AtomicBoolean active = new AtomicBoolean(false);
  private final Semaphore throttle;
  private BlockingQueue<ImageInfo> queue = new LinkedBlockingQueue<ImageInfo>();
  private LruCache<String, Bitmap> imageCache;
  private Set<String> bad = Collections.synchronizedSet(new HashSet<String>());
  private Set<String> down = Collections.synchronizedSet(new HashSet<String>());
  private AsyncHttpClient httpClient = new AsyncHttpClient();
  private Handler handler = new Handler(Looper.getMainLooper());
  ...
  public RemoteImageCache(Context context, int maxParallelDown,
    String cacheDir, int cacheSize) {

    // Context and LRUCache for in memory Bitmaps
    Context appContext = context.getApplicationContext();
    File dataDir = appContext.getFilesDir();
    this.storageDir = new File(dataDir, cacheDir != null ? cacheDir
      : DEFAULT_CACHE_DIR);
    this.imageCache = new LruCache<String, Bitmap>(cacheSize);

    // semaphore to limit parallel downloads
    this.throttle = new Semaphore(maxParallelDown, true);
    this.active.set(true);

    // downloader thread that pulls from a queue
    DownloaderThread downloaderThread = new DownloaderThread();
    downloaderThread.setDaemon(true);
    downloaderThread.start();
  }
  ...
}

We have a lot going on here so let’s explain. We have multiple threads operating together. We start with a queue onto which ImageInfo objects are placed to be downloaded. That is the queue variable. Then we have a single worker thread that pulls Objects off the queue and hands them to async http clients to do the image downloading. We use a semaphore to limit the number of concurrent downloads and synchronized sets to hold the state of images being downloaded and that have errored.

Once images are downloaded, they are processed and stored in the local filesystem and then loaded into memory. We use an LruCache to limit the number of images stored in memory to optimize memory consumption. Once an image is downloaded it will not be re-downloaded. Instead it will be loaded from the local filesystem.

In our constructor we setup the local filesystem cache directory. We setup the Semaphore to throttle downloads and we startup the DownloaderThread which is our single worker thread.

public Bitmap getImage(ImageInfo imageInfo) {

  // no image info or image url or bad image don't bother further
  if (imageInfo == null || imageInfo.imageUrl == null
    || bad.contains(imageInfo.id)) {
    return null;
  }

  // try and get from the cache
  Bitmap image = imageCache.get(imageInfo.id);
  if (image != null) {
    return image;
  }

  // if the file exists then read the file from internal storage and turn
  // it into an Bitmap
  File imageFile = new File(storageDir, imageInfo.id + ".img");
  if (imageFile.exists()) {

    // try and first get from local storage
    try {

      byte[] imageBytes = FileUtils.readFileToByteArray(imageFile);
      Bitmap bitmap = BitmapFactory.decodeByteArray(imageBytes, 0,
        imageBytes.length);
      imageCache.put(imageInfo.id, bitmap);

      return bitmap;
    }
    catch (IOException e) {
    }
  }

  // two step synchronized check and add for downloading state
  synchronized (down) {
    if (down.contains(imageInfo.id)) {
      return null;
    }
    down.add(imageInfo.id);
  }

  // drop into the download queue
  try {
    queue.put(imageInfo);
  }
  catch (InterruptedException ie) {
    // shouldn't happen, the queue is non blocking
  }

  return null;
}

To get an image a call is made to the getImage method passing in an ImageInfo. We first check if it is a bad image and if so return null. If not we try to load the image from memory. If it doesn’t exist in memory we see if it exists on disk. If it doesn’t exist on disk then we start the image download process by placing the ImageInfo on the queue. We also add the image id to the set of downloading images to prevent duplicate downloads. If the image is already downloading null is returned.

The image will go through the download process and when it is finished the ImageCacheListener methods will be called.

public void shutdown() {
  
  active.set(false);
  imageCache.evictAll();
  
  try {
    ImageInfo poisonPill = new ImageInfo();
    poisonPill.id = POISON_PILL;
    queue.put(poisonPill);
  }
  catch (InterruptedException ie) {
    // shouldn't happen, the queue is non blocking
  }
}

The shutdown method is called to shutdown the instance, usually when the activity using the RemoteImageCache is being destroyed. We first cleach the memory cache. Then we use a method known as a poison pill to stop the DownloadedThread we started earlier. This involves placing a know stop object onto the queue. The DownloaderThread picks up the stop object and exits itself.

DownloaderThread

The DownloaderThread is responsible for the downloading of remote images. It blocks on the queue waiting for an ImageInfo object to come in. If the ImageInfo is the poison pill the thread exits, otherwise it attempts to acquire the semaphore. If it is successful it can start downloading, otherwise there are already max concurrent images downloading and the thread must wait, blocking on the semaphore.

The image file is downloaed using the async http class. On both error and success the semaphore is immediately released to allow images in wait to proceed. On success the image is scaled if necessary and then stored to disk and loaded into memory. The synchronized sets are updated. On an error the image is placed into the bad set so it won’t be redownloaded again this session.

private class DownloaderThread
  extends Thread {

  public void run() {

    // continue downloading while not shutdown
    while (active.get()) {

      ImageInfo nextImage = null;
      try {
        
        // wait until an image to download appears in the queue
        nextImage = queue.take();
        
        // poison pill for clean shutdown of blocking queue
        if (nextImage.id.equals(POISON_PILL)) {
          continue;
        }
      }
      catch (InterruptedException ie) {
        continue;
      }
      
      // acquire the semaphore to start downloading
      try {
        throttle.acquire();
      }
      catch (InterruptedException ie) {
        continue;
      }

      // download the image
      final ImageInfo imageInfo = nextImage;
      final File imageFile = new File(storageDir, imageInfo.id + ".img");

      httpClient.get(imageInfo.imageUrl, null,
        new BinaryHttpResponseHandler() {

          @Override
          public void onSuccess(byte[] bytes) {

            // image downloaded, release semaphore, let the next one go
            throttle.release();

            byte[] imageBytes = null;
            Bitmap bitmap = null;

            try {

              // if we are sampling, then sample the image and turn it into a
              // Bitmap, if not just turn it into a Bitmap
              if (imageInfo.sample) {

                bitmap = BitmapUtils.decodeAndScaleImage(bytes,
                  imageInfo.width, imageInfo.height);
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                bitmap.compress(imageInfo.format, imageInfo.quality, baos);
                imageBytes = baos.toByteArray();
              }
              else {

                imageBytes = bytes;
                bitmap = BitmapFactory.decodeByteArray(imageBytes, 0,
                  imageBytes.length);
              }

              // write the Bitmap bytes to local storage
              if (bitmap != null && imageBytes != null) {
                FileUtils.writeByteArrayToFile(imageFile, imageBytes);
                imageCache.put(imageInfo.id, bitmap);
              }
            }
            catch (Exception e) {
              // error converting bytes to image
            }
            finally {                
              // remove image from the downloading state
              down.remove(imageInfo.id);
            }

            // create a handler to ensure the callback listener runs in the
            // main UI thread, pass in the Bitmap and the original image info
            final Bitmap image = bitmap;
            handler.post(new Runnable() {

              @Override
              public void run() {
                
                // run the ImageCacheListner callback for success
                if (imageInfo.listener != null) {
                  imageInfo.listener.onSuccess(imageInfo, image);
                }
              }
            });
          }

          @Override
          public void onFailure(final Throwable error, byte[] bytes) {

            // image download failed, release semaphore, let the next one go
            throttle.release();
            
            // remove from the downloading state, add to bad images so we 
            // won't try to download again
            bad.add(imageInfo.id);
            down.remove(imageInfo.id);
            
            // create a handler to ensure the callback listener runs in the
            // main UI thread, pass in the error and the original image info
            handler.post(new Runnable() {

              @Override
              public void run() {
                
                // run the ImageCacheListner callback for failure
                if (imageInfo.listener != null) {
                  imageInfo.listener.onFailure(error, imageInfo);
                }
              }
            });
          }
        });
    }
  }
}

The Handler and the UI Thread

The last piece is the handler. UI updates happen in the main UI thread of an android application. In our RemoteImageCache we have threads handing off to threads. We want to tell our application that our image is done downloading but we also want it to run the callback listener in the main UI thread. The way to do this is through a handler.

The UI thread works off of a queue of events. Events that update the UI, such as clicks or redraws, are pulled off the queue and handled one at a time by the UI thread. This is why if you have a long running operation inside the main UI thread it can freeze your application. This is also why Android doesn’t allow network operations or other long running operations on the UI thread. We have to do our image downloading in a separate thread but then do any UI updates back in the main thread.

The handler.post method allows us to place a Runnable object onto the UI thread queue. In our case we run the ImageCacheListener that the application has specified. Usually this will update the UI with the image that was downloaded. More importantly these updates happen in the main UI thread.

Using the RemoteImageCache

Here is an example of how the RemoteImageCache could be used in the getView method of a ListAdapter. In this example we have rows in a list each with their own image. The images would be downloaded in the background and as each image is downloaded only that specific image is updated in the UI.

public View getView(int position, View row, ViewGroup parent) {
  ...
  // setup the image to get or download, we want a 32x32 image
  ImageInfo imageInfo = new ImageInfo();
  String id = StringUtils.replace(friend.name, " ", "_");
  id = StringUtils.lowerCase(id);
  imageInfo.id = id;
  imageInfo.imageUrl = friend.imageUrl;
  imageInfo.width = 42;
  imageInfo.height = 42;
  imageInfo.format = Bitmap.CompressFormat.JPEG;
  imageInfo.quality = 80;
  imageInfo.sample = true;

  // setup a listener to just change the one image view instead of
  // calling notifyDataSetChanged which redraws the entire list view screen
  final ListView mainListView = (ListView)parent;
  final int curPosition = position;

  // optimization to only change the single image in the single row
  // should the row still be visible when the image is done downloading
  // in the background
  imageInfo.listener = new ImageCacheListener() {

    @Override
    public void onSuccess(ImageInfo imageInfo, Bitmap bitmap) {

      // get the start and end visible rows in the list view
      int startRow = mainListView.getFirstVisiblePosition();
      int endRow = mainListView.getLastVisiblePosition();

      // if current position is visible get the specific row and change
      // just the image in that row. This will cause the image to update
      // on the fly even if no scrolling is happening. Nothing else in
      // the row will be invalidated
      if (curPosition >= startRow && curPosition <= endRow) {
        View rowView = mainListView.getChildAt(curPosition - startRow);
        ImageView imageView = (ImageView)rowView
          .findViewById(R.id.friendImage);
        imageView.setImageBitmap(bitmap);
      }
    }
  };
  ...
}

When the image is downloaded the listener is run. We use an optimization trick where if the image is inside the set of visible rows on the list view, then we get the single image view and update its bitmap with the downloaded image. This will only update the single row as opposed to doing something like notifyDataSetChanged which updates multiple rows. If you would like to see a further example, check out the Friends List in the Singly Android SDK examples.

Here is the RemoteImageCache code listing. This and other components and utilities can be found in the Singly Android SDK. Check it out if you want drop in support for authentication to multiple networks and easy access to social data. Connect with me if you are interested in or have questions about the Singly Android SDK.

Share and Enjoy

4 thoughts on “Android Image Downloading and Caching

    • The scaling reduces the in-memory size of the image according to the image’s display size. Android will scale the image to the canvas size. But because of that the image could be significantly larger than it needs to be for its on-screen display size. The scaling lets a bigger image be sampled down to a smaller size because it will be displayed in a smaller screen region. This conserves memory and allows the image to load faster.

  1. Thanks Dessis for nice tutorial. I am facing problem with getView() example code as you are modifying rowView inside inner call how one should return the reference back as getView needs view as return type.

Comments are closed.