Tuesday, April 2, 2013

Automatically Managing Bitmaps from URLs via UrlBitmapDrawable

If you develop apps, you'll probably (at some point) need to load images from the internet.  Sometimes you'll load many images at once, as in a ListView or Gallery.  Your first foray will be easy, using BitmapFactory to decode a stream - then you run into the dreaded OutOfMemory error and are suddenly grounded back to the reality that is limited memory space.

There are two equally important problems to handle when using images: loading an image to view it, and unloading it later to save memory.  Last time I looked there were plenty of libraries out there for solving the first problem, but I was unsatisfied with their solutions to the second.  A lot of them simply blow away loaded Bitmaps at some point in time - but what if you still wanted to use it?  Then you end up in this complex tightrope walk, where you're having to constantly check and re-check your Bitmaps for whether they still exist.  It's a gigantic pain and I've been doing this dance forever.

I set out to solve the problem in a better way, such that it would do three things:

1. Load images from a URL into a View.

2. Unload that image later (for memory's sake).

3. Re-load that URL into the View if we ever see it again.

I was able to accomplish all three with the help of a class I wrote, UrlBitmapDrawable.

Introducing UrlBitmapDrawable


You can check out the code here: https://gist.github.com/dlew/69e6557604926d7e1513

You can use it just about anywhere as a Drawable.  Just instantiate it then use it:

UrlBitmapDrawable drawable = new UrlBitmapDrawable(res, myUrl, myFallback);
myView.setBackgroundDrawable(drawable);

With ImageViews there's a bit of a hack I needed to do (in order not to have to use a custom ImageView).  So I setup another method for using UrlBitmapDrawable with ImageViews:

UrlBitmapDrawable.loadImageView(myImageUrl, myImageView, myFallback);

Highlights


This solution has made my life easier in four ways:

It's Simple - All you need to is provide it a URL and it takes care of the rest.  You don't ever have to worry about its state; if the underlying Bitmap is recycled, it will fallback to the default image and start reloading the URL.

It's a Drawable - By making it a Drawable, it meant I could attach it to any View.  Tying it to a custom View would have vastly limited its potential.

It's Compatible - It's not tied to any particular implementation for loading images.  Retrieving images could be a simple network call, or you could hook it up to a complex LRU memory/disk cache.

It's Extensible - The version I've provided is simple; internally we've added some bells and whistles to it.  See "Areas for Improvement" below.

Disadvantages


It's not all sunshine and daisies.  There are two problems with UrlBitmapDrawable; however, I considered them minor in comparison to the larger problem I was trying to solve.

BitmapDrawable - BitmapDrawable does not let you access the underlying Bitmap by default, so you'll have to import your own version that opens it up.  Here's the source code from github.

ImageView Hack - In order to get ImageView to re-measure the Drawable's width/height after loading you have to trick it into thinking the Drawable has changed (by nulling it then resetting it back).  To be honest, there might be a better solution here, but I haven't found it.

Areas for Improvement


Here's ways that we've tricked out our UrlBitmapDrawable:

Default Resources via Id - A default resource is important (so we can show the user something before we load the asset).  In the sample code the UrlBitmapDrawable holds a Bitmap; this is a fine example, but if you're inflating a new default Bitmap per UrlBitmapDrawable, that can wreak its own memory havoc.

Internally, we've gone a more complex route which uses Resources and resIds, and loads the Bitmap automatically from an in-memory cache.  It keeps us from spending a lot of time (and memory) reloading the same default bitmaps.

Fallback URLs - We are sometimes given a list of URLs at differing quality levels.  It's pretty easy to hook that into this system; each time a download fails, try the next URL.

Recycling Bitmaps - If you read the code carefully you may notice that I never actually dealt with the second step - unloading Bitmaps from memory.  We use an LruCache to handle this; as a result of UrlBitmapDrawable it can recycle Bitmaps with impunity.  It also means you can evict the entire cache at any time if you need the memory.

2 comments:

  1. Nice article. It reminds me an old snippet of code of mine I write several years ago to do the exact same thing (https://github.com/cyrilmottier/DrawablePresentation/tree/master/RemoteDrawable). I ended up not using it because of the hack you mentioned.

    First, you can use Drawable.Callback to invalidate the enclosing View (see my talk about Drawables - https://speakerdeck.com/cyrilmottier/mastering-android-drawables). It would first let you use your UrlBitmapDrawable in every View (not only an ImageView).

    What I don't like about this solution though is that (as you mentioned) there is no way for a Drawable to notify the client (ImageView, View, etc.) it needs to relayout itself. Why ? Simply because it is up to the View to do that. A Drawable can only draw itself in the bounds defined by the enclosing View.

    ReplyDelete
    Replies
    1. I wish there was a better solution for ImageView. The problem is not that ImageView doesn't know the Drawable has been updated, but that ImageView's mDrawableWidth and mDrawableHeight is only updated when the underlying resource has changed. Even if you use Drawable.Callback to notify the View it doesn't update its cached width/height.

      That said, I believe it works with every View already (though perhaps I should test that more thoroughly); I've used it with View.setBackgroundDrawable() before and my test suite shows it working in that case.

      As for your last point - I'm not overly concerned about the Drawable not being able to inform the View to relayout itself out; in fact, that's intended. I want the View to define the area, and the Drawable to simply occupy it. It is not often the case where I want the size of the image I've loaded to define the size of the View it resides in; more often, I've got something like a ListView with ImageViews of a set width/height.

      Delete