Reusing Bitmap objects on Android

Memory management in Android can be a bit complicated if the application has heavy media demands. The Booking.com Android application had an issue explicitly dealing with memory management that required some ingenuity to give our users the positive experience we wanted to give them. Here is the background on how we solved one specific issue.

booking.development
Booking.com Engineering

--

Written by Sergii Rudchenko

We are testing a new feature in our Android application — a fancy photo gallery on the Hotel details screen:

Unfortunately, this nice feature increased the memory usage by 20%. In addition to the increased consumption, swiping back and forth through the photo gallery caused a noticeable visual stutter. We debugged this quite a bit and found out that the gallery experienced a jitter when the ViewPager loaded the next image because of the garbage collection (GC) process. Our application is quite memory intensive for three main reasons: it shows a lot of photos, our view hierarchies are complex, and the amount of data to process is particularly large for popular destinations. This means every big allocation triggers a garbage collection cycle to get a free chunk of RAM.

This is especially annoying when trying to make the gallery scroll automatically without user action since Android does not bump the UI thread priority to real-time.

In debugging why our app was lagging every time we loaded some photos we took a look at what Logcat said. Basically, we had a lack of memory every time we allocated a new Bitmap:

GC_FOR_ALLOC freed 3255K, 20% free 21813K/26980K, paused 62ms, total 62ms
GC_FOR_ALLOC freed 710K, 20% free 30242K/37740K, paused 72ms, total 72ms
GC_FOR_ALLOC freed <1K, 20% free 31778K/39280K, paused 74ms, total 74ms

This is telling us that whenever we allocate a new Bitmap object for a photo it blocks the whole application for around seventy milliseconds. Seventy milliseconds is the equivalent of five skipped frames of animation. If we want to give our users a smooth experience using our application, we need to bring that number down to under sixteen milliseconds.

In order to minimize the impact from repeated triggers of garbage collection we decided to leverage the reusable Bitmap object to decode a new image. There are some hurdles to using the class, though, because an image we decode into an existing Bitmap must be the same size as previously loaded image. And of course, dimensions of the hotel photos we display can vary by hundreds of pixels. Luckily, since Android 4.4, the new image can be the same size or smaller than the old one which means we can keep a Bitmap object large enough to accommodate every photo we show.

To take advantage of this API we implemented a ViewPager adapter which keeps a small pool of Bitmap objects which should have enough memory allocated for decoding our hotel photos. Whenever an ImageView gets scrolled out of the screen, the adapter puts the corresponding Bitmap buffer back to the pool to be used for subsequent photos. Now, using that new adapter required us to resolve a couple of accompanying problems such as Bitmap life cycle management and integration with our networking framework of choice.

Keeping track of Bitmap objects

Managing the memory for Bitmaps is tricky because we have to hook into the GC mechanism. To track usage of our Bitmaps we decided to implement some reference counting.

Here is an interface for a reference counted Bitmap:

package com.booking.util.bitmap;

import android.graphics.Bitmap;

/**
* A reference-counted Bitmap object. The Bitmap is not really recycled
* until the reference counter drops to zero.
*/
public interface IManagedBitmap {

/**
* Get the underlying {@link Bitmap} object.
* NEVER call Bitmap.recycle() on this object.
*/
Bitmap getBitmap();

/**
* Decrease the reference counter and recycle the underlying Bitmap
* if there are no more references.
*/
void recycle();

/**
* Increase the reference counter.
* @return self
*/
IManagedBitmap retain();
}

The BitmapPool keeps a collection of Bitmaps available for reuse and creates a new one if there is no Bitmap to serve a request. The pool provides its own implementation of IManagedBitmap which holds a reference to the BitmapPool that contains it. When client code releases a managed Bitmap it gets placed back into the pool instead being immediately disposed. This way the BitmapPool is aware of the leased IManagedBitmap objects and can track their life cycle.

One thing to keep in mind is that our implementation of BitmapPool is not thread-safe. That is okay though, because we always construct and destroy image views on the main thread. If we ever needed to allocate those Bitmaps in background threads, we would definitely need to introduce some synchronization to make it safe for concurrent usage.

Here is what our BitmapPool looks like:

package com.booking.util.bitmap;

import java.util.Stack;

import android.graphics.Bitmap;
import android.os.Handler;

/**
* A pool of fixed-size Bitmaps. Leases a managed Bitmap object
* which is tied to this pool. Bitmaps are put back to the pool
* instead of actual recycling.
*
* WARNING: This class is NOT thread safe, intended for use
* from the main thread only.
*/
public class BitmapPool {
private final int width;
private final int height;
private final Bitmap.Config config;
private final Stack<Bitmap> bitmaps = new Stack<Bitmap>();
private boolean isRecycled;

private final Handler handler = new Handler();

/**
* Construct a Bitmap pool with desired Bitmap parameters
*/
public BitmapPool(int bitmapWidth,
int bitmapHeight,
Bitmap.Config config)
{
this.width = bitmapWidth;
this.height = bitmapHeight;
this.config = config;
}

/**
* Destroy the pool. Any leased IManagedBitmap items remain valid
* until they are recycled.
*/
public void recycle() {
isRecycled = true;
for (Bitmap bitmap : bitmaps) {
bitmap.recycle();
}
bitmaps.clear();
}

/**
* Get a Bitmap from the pool or create a new one.
* @return a managed Bitmap tied to this pool
*/
public IManagedBitmap getBitmap() {
return new LeasedBitmap(bitmaps.isEmpty()
? Bitmap.createBitmap(width, height, config) : bitmaps.pop());
}

private class LeasedBitmap implements IManagedBitmap {
private int referenceCounter = 1;
private final Bitmap bitmap;

private LeasedBitmap(Bitmap bitmap) {
this.bitmap = bitmap;
}

@Override
public Bitmap getBitmap() {
return bitmap;
}

@Override
public void recycle() {
handler.post(new Runnable() {
@Override
public void run() {
if (--referenceCounter == 0) {
if (isRecycled) {
bitmap.recycle();
} else {
bitmaps.push(bitmap);
}
}
}
});
}

@Override
public IManagedBitmap retain() {
++referenceCounter;
return this;
}
}
}

Integrating with the networking layer

In the Booking.com Android application we use Volley to issue network requests. By default, image requests are handled by the ImageRequest Volley class which allocates a new Bitmap for every response parsed. And here is the catch. There is no way to override this behavior. We needed to implement a custom ImageRequest (ReusableImageRequest) which takes an IManagedBitmap as a destination for image decoding.

Another problem we ran into involves canceled requests. If we’ve already passed in an IManagedBitmap to our custom ReusableImageRequest and it gets canceled while in the request queue, we leak memory. We also needed to extend the Request class to add a new life cycle method called onFinished() to handle this case.

With our hacks in place the ViewPager adapter creates a BitmapPool and issues a ReusableImageRequest for every photo, passing an IManagedBitmap instance from the pool. ReusableImageRequest now safely takes ownership of a reusable Bitmap until the request is finished.

In a nutshell, here is the general flow of how we use Volley:

We do not provide the source code for ReusableImageRequest here because it is a substantial amount of code and most of it is replicating the original ImageRequest.

A little bonus

As long as we have a custom ImageRequest implementation we’re free to apply another hack to reduce the garbage collection during the gallery scrolling. We can pass a buffer for all the temporary data required during an image decoding through BitmapFactory.Options.inTempStorage. With this option Android will use the provided, pre-allocated memory chunk for all of the image decoder variables.

Conclusion

We dramatically improved the user experience for our customers by providing a much smoother photo gallery behavior with almost no GC happening. It only required some research and a little bit ingenuity. We hope our experience will inspire Android developers to create new, exciting applications and improve existing ones.

Check out our application on Google Play!

Would you like to be a Developer at Booking.com? Work with us!

--

--