OutOfMemoryError: bitmap size exceeds VM budget: I Hate You So How Do I Fix You?

If there’s one thing that really annoys me about Android, it’s that working with a large amount of images really is a pain.

There have been two projects where this error drove me out of my mind.
In the first, there was a ListView, which was somehow endless. By scrolling down I would just load the next ten entries from the server. Each entry also had a picture. At some point the app would just crash with the above error. In this case it helped just to use the ImageDownloader.java from the Google Android Open Source Project.

This image downloader isn’t that bad at first sight, it also incorporates a caching-system and whenever the app starts being out of memory, it erases stuff, it even uses weak references.
The only issue I see here is that once an image has been removed it really is gone and has to be re-downloaded in case it is needed.
This works quite well for little thumbnails in a list, although it doesn’t really look nice.

This simply became useless for my second project. It was a live gaming app where you can answer a variety of questions. In between the questions, a series of full screen ads should be shown and fade into one another. In this case the last thing I want is to re-load the pretty big image each time from the web (because the internal memory just wasn’t capable of holding more than a couple of images at a time without crashing). It is inconvenient and makes me show a placeholder all the time, at each advertisement change.

So, there had to be some other solution!
I wrote my own downloader and my own caching system.
The whole idea behind it is to load the image from the web and not even have it in the ram, instead directly copy it from the web stream onto the hard disk in the doInBackground() method of the AsycTask:

InputStream inputStream = null;
OutputStream outputStream = null;
try {
inputStream = entity.getContent();
outputStream = ContentManager.getInstance().getContext().
openFileOutput(filename, Context.MODE_PRIVATE);

copy(inputStream, outputStream);
}...

It is stored on the hard disk under a cacheName and decoded on demand:

Drawable newDrawable:
FileInputStream input;
try {
input = ContentManager.getInstance().getContext().openFileInput(nextAd.getCacheFilename());
newDrawable = new BitmapDrawable(input);
input.close();
} ...

For this I used an ObjectWithImage object, containing the image URL, the size of the downloaded image (so that I could make sure it had been downloaded correctly and wasn’t empty) and the cacheName.
That’s what ObjectWithImage looks like:
ObjectWithImage.java

public class ObjectWithImage {
private String cacheFilename;
private String imageUrl;

private long size = 0;

public String getCacheFilename() {
return cacheFilename;
}

public void setCacheFilename(String cacheFilename) {
this.cacheFilename = cacheFilename;
}

public long getSize() {
return size;
}

public void setSize(long l) {
this.size = l;
}

public void setImageUrl(String url){
this.imageUrl = url;
}

public String getImageUrl(String url){
return imageUrl;
}

}

What you really need to keep in mind when using this type of caching system, is that at some point you need to erase images you no longer need! This is pretty easy. Say your cache name for the image is “advertisement1”, just call getContext().deleteFile("advertisement1"); in an Activity.

The MoreEfficientImageDownloader class might also be interesting because it also uses authentication/cookies. So if you’ve been wondering how that works, take a look at the code at the end of the post.
Several of these AsyncTasks ran at a time and needed to use the same cookie and the same HttpContext. So I just made a cookie manager and set the same CookieStore and HttpContext for all the AsyncTaks in the downloader.

The CookieManager is pretty simple. Just storing the HttpContext and the CookieStore as static variables:
CookieManager.java

import org.apache.http.client.CookieStore;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;

public class CookieManager {

public static final CookieStore cookiestore = new BasicCookieStore();
public static final HttpContext context = new BasicHttpContext();
}

Finally, here’s the full code for the MoreEfficientImageDownloader:
MoreEfficientImageDownloader.java

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.protocol.ClientContext;
import org.apache.http.protocol.HttpContext;

import android.content.Context;
import android.net.http.AndroidHttpClient;
import android.os.AsyncTask;
import android.util.Base64;
import android.util.Log;
import de.myapp.app.jsoncall.CookieManager;
import de.myapp.app.managers.ContentManager;
import de.gamedisk.app.model.ObjectWithImage;

public class MoreEfficientImageDownloader {

/**
* The idea of this class is to fetch an image from an url convert it into a
* byte array and store it on hard disk
*/

private String LOG_TAG = "MoreEfficientImageDownloader";

public void downloadImage(ObjectWithImage obj) {
if (obj.getCacheFilename() == null) {
BitmapDownloaderTask task = new BitmapDownloaderTask(obj);
task.execute(obj.getImageUrl());
}
}

public boolean eraseImageForObject(ObjectWithImage obj) {
String filename = obj.getCacheFilename();
return ContentManager.getInstance().getContext().deleteFile(filename);
}

/**
* The actual AsyncTask that will asynchronously download the image.
*/
class BitmapDownloaderTask extends AsyncTask<String, Void, byte[]> {

private static final int IO_BUFFER_SIZE = 4 * 1024;

private String url;
private String redirectUrl;
private String filename;

private ObjectWithImage objectwithImage;

public BitmapDownloaderTask(ObjectWithImage obj) {
this.objectwithImage = obj;
}

/**
* Actual download method.
*/
@Override
protected byte[] doInBackground(String... params) {

final AndroidHttpClient client = AndroidHttpClient.newInstance("Android");
url = params[0];

if (url.indexOf("/") != -1) {
filename = url.substring(url.lastIndexOf("/") + 1);
} else {
// Log.w(LOG_TAG, "Could not determine filename from url: " + url);
return null;
}

final HttpGet getRequest = new HttpGet(url);

String source = Settings.AUTH_USER + ":" + Settings.AUTH_PW;
// String ret = "Basic " + Base64.encodeToString(source.getBytes(), Base64.URL_SAFE | Base64.NO_WRAP);
String ret="Basic "+String.valueOf(Base64Coder.encode(source.getBytes()));
getRequest.setHeader("Authorization", ret);

HttpContext localContext = CookieManager.context;
// Bind custom cookie store to the local context
localContext.setAttribute(ClientContext.COOKIE_STORE, CookieManager.cookiestore);

try {
HttpResponse response = client.execute(getRequest, localContext);
final int statusCode = response.getStatusLine().getStatusCode();

if (statusCode != HttpStatus.SC_OK && statusCode != 302) {
// Log.w("ImageDownloader", "Error " + statusCode + " while retrieving bitmap from " + url);
return null;
}

final HttpEntity entity = response.getEntity();
if (statusCode == 302) {
// re-start download with new location
Header h = response.getFirstHeader("Location");
redirectUrl = h.getValue();
return null;
}

if (entity != null) {
InputStream inputStream = null;
OutputStream outputStream = null;
try {
inputStream = entity.getContent();
outputStream = ContentManager.getInstance().getContext().openFileOutput(filename, Context.MODE_PRIVATE);

copy(inputStream, outputStream);

// probably not required
// outputStream.flush();
} finally {
if (inputStream != null) {
inputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
entity.consumeContent();
}
}
} catch (IOException e) {
getRequest.abort();
// Log.w(LOG_TAG, "I/O error while retrieving bitmap from " + url, e);
} catch (IllegalStateException e) {
getRequest.abort();
// Log.w(LOG_TAG, "Incorrect URL: " + url);
} catch (Exception e) {
getRequest.abort();
// Log.w(LOG_TAG, "Error while retrieving bitmap from " + url, e);
} finally {
if (client != null) {
client.close();
}
}
return null;
}

@Override
protected void onPostExecute(byte[] bitmapdata) {

if (redirectUrl != null) {
BitmapDownloaderTask task = new BitmapDownloaderTask(objectwithImage);
task.execute(redirectUrl);
} else {
objectwithImage.setSize(ContentManager.getInstance().getContext().getFileStreamPath(filename).length());
objectwithImage.setCacheFilename(filename);
ContentManager.getInstance().updateCacheFilenameForDownloadedAds(filename);
// Log.i(LOG_TAG, "downloaded image " + url);
}
}

public void copy(InputStream in, OutputStream out) throws IOException {
byte[] b = new byte[IO_BUFFER_SIZE];
int read;
while ((read = in.read(b)) != -1) {
out.write(b, 0, read);
}
}
}

}