Skip to content

Instantly share code, notes, and snippets.

@ikai
Created March 30, 2013 21:31
Show Gist options
  • Save ikai/5278446 to your computer and use it in GitHub Desktop.
Save ikai/5278446 to your computer and use it in GitHub Desktop.
A tool I've used a few times in various Android samples. I've simplified and greatly generalized this from some code I helped write for a friend. This class helps fetch lots of images in a background thread pool, executing a callback on a main thread when complete. This code caches the images locally, opting to fetch first from the cache before …
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Environment;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
/**
* This class wraps a common pattern: fetching many images in a background
* thread pool for Android applications and caching them for future use.
*
* A very common requirement in Android applications is the need to create, for
* instance, a ListView containing lots of images which are loaded from remote
* URLs. Often times, these images are not expected to change, and it would be
* most useful to cache the images on local disk such that on future fetches of
* the URL, the data is fetched locally from disk cache rather than making an
* expensive and slow network call. This class wraps:
*
* <ul>
* <li>Creating a thread pool to manage outstanding requests so we don't thread
* bomb ourselves</li>
* <li>Passing URLs to fetch, executing a callback when the URL has been
* fetched. The fetcher instance then checks for the presence of the data in the
* cache. If it exists, the fetcher returns the cached data. If it does not
* exist, the fetcher fetches the data in a background thread, saves it to disk,
* then executes the completion callback.</li>
* </ul>
*
* Here's how this class might be used in a custom implementation of
* ArrayAdapter
*
* <pre>
* private static class MyCustomAdapter extends ArrayAdapter&lt;MyCustomClass&gt; {
* private Activity mContext;
* private int mLayoutResourceId;
*
* // You don't need many instances of this class
* private ImageFetcher mFetcher;
*
* public MyCustomAdapter(Context context, int textViewResourceId) {
* super(context, textViewResourceId);
* mContext = (Activity) context;
* mLayoutResourceId = textViewResourceId;
*
* mFetcher = new ImageFetcher(mContext);
* }
*
* &#064;Override
* public View getView(int position, View convertView, ViewGroup parent) {
* View rowView = convertView;
* if (rowView == null) {
* LayoutInflater inflater = mContext.getLayoutInflater();
* rowView = inflater.inflate(mLayoutResourceId, null);
* }
*
* // Make sure we are in bounds and draw the item
* if (position &lt; getCount()) {
* MyCustomClass myData = getItem(position);
*
* // Add an image fetch job to the queue
* // Assumes presence of an ImageView with ID thumbnail
* final ImageView thumbnail = (ImageView) rowView
* .findViewById(R.id.thumbnail);
*
* ImageFetcher.ImageFetchJob job = new ImageFetcher.ImageFetchJob(
* myData.getThumbnailUrl());
* job.setOnFetchCompleteListener(new ImageFetcher.OnFetchCompleteListener() {
*
* &#064;Override
* public void onFetchComplete(Bitmap result) {
* thumbnail.setImageBitmap(result);
* }
* });
*
* mFetcher.enqueue(job);
* }
* return rowView;
* }
* }
* </pre>
*
* @author Ikai Lan <[email protected]>
*/
public class ImageFetcher {
private static final String TAG = ImageFetcher.class.getSimpleName();
public final static int DEFAULT_THREAD_POOL_SIZE = 3;
private Context mContext;
private ExecutorService mExecutor;
/**
* Interface for implementing a callback to be executed on the UI thread
* when the data has been fetched either from the remote server or the local
* cache.
*
* @author Ikai Lan <[email protected]>
*/
public static interface OnFetchCompleteListener {
public void onFetchComplete(Bitmap result);
}
/**
* Dummy class to execute when we have completed the job. This is the
* default listener.
*
* @author Ikai Lan <[email protected]>
*/
private static class DefaultFetchCompleteListener implements
OnFetchCompleteListener {
@Override
public void onFetchComplete(Bitmap result) {
Log.d(TAG,
"Fetch completed. Override this if fetch should do something.");
}
}
/**
* This class represents a job to fetch some data and cache it locally.
* Invokes an instance of OnFetchCompleteListener when completed.
*/
public static class ImageFetchJob {
private final String mUrl;
private OnFetchCompleteListener mCallback;
/**
*
* @param url
* the url to fetch
*/
public ImageFetchJob(String url) {
mUrl = url;
mCallback = new DefaultFetchCompleteListener();
}
/**
* @return the URL this job is responsible for fetching
*/
public String getUrl() {
return mUrl;
}
/**
* Sets a callback to be executed when the data either has been fetched
* from the remote server or from the cache. The callback is executed on
* the UI thread.
*
* @param callback
* a callback to be executed on the UI thread
*/
public void setOnFetchCompleteListener(OnFetchCompleteListener callback) {
mCallback = callback;
}
/**
* @return the callback to be executed when this job has completed
*/
public OnFetchCompleteListener getOnFetchCompleteListener() {
return mCallback;
}
}
/**
* Creates a new ImageFetcher. Uses a thread pool size of
* {@link ImageFetcher#DEFAULT_THREAD_POOL_SIZE}.
*
* @param context
* the calling {@link Context}
*/
public ImageFetcher(Context context) {
this(context, DEFAULT_THREAD_POOL_SIZE);
}
/**
* Constructor that allows us to specific a thread pool size.
*
* @param context
* a calling context to provide a UI thread
* @param threadPoolSize
* a thread pool size to use
*/
public ImageFetcher(Context context, int threadPoolSize) {
mContext = context;
mExecutor = Executors.newFixedThreadPool(threadPoolSize);
}
/**
* Adds an ImageFetchJob to the queue. If the data has already been fetched
* and exists in our local cache, execute the onFetchCompleteListener
* callback immediately. Otherwise, queue it to be run on the UI thread.
*
* @param job
* the {@link ImageFetchJob} to enqueue
*/
public void enqueue(final ImageFetchJob job) {
if (!TextUtils.isEmpty(job.getUrl())) {
final Bitmap cachedBitmap = getCachedBitmap(job.getUrl());
if (cachedBitmap != null) {
Runnable callback = new Runnable() {
public void run() {
job.getOnFetchCompleteListener().onFetchComplete(
cachedBitmap);
}
};
((Activity) mContext).runOnUiThread(callback);
} else {
run(job);
}
}
}
/**
* In the thread poll we have instantiated, queue or run the
* {@link ImageFetchJob}. We will want to write the response to the cache
* for future calls of {@link #queueFetchJob(ImageFetchJob)} to simplly
* return the data.
*
* @param job
* a job to run on the thread pool
*/
private void run(final ImageFetchJob job) {
mExecutor.execute(new Runnable() {
public void run() {
File cacheFile = cacheFileFromUrl(job.getUrl());
try {
final byte[] respBytes = readDataFromNetwork(job.getUrl());
// Write response bytes to cache.
if (cacheFile != null) {
try {
cacheFile.getParentFile().mkdirs();
cacheFile.createNewFile();
FileOutputStream fos = new FileOutputStream(
cacheFile);
fos.write(respBytes);
fos.close();
} catch (FileNotFoundException e) {
Log.w(TAG, "Error writing to bitmap cache: "
+ cacheFile.toString(), e);
} catch (IOException e) {
Log.w(TAG, "Error writing to bitmap cache: "
+ cacheFile.toString(), e);
}
}
// Decode the bytes and return the bitmap.
final Bitmap bitmap = BitmapFactory.decodeByteArray(
respBytes, 0, respBytes.length);
// Create a runnable instance to be run on the UI thread
Runnable callback = new Runnable() {
public void run() {
job.getOnFetchCompleteListener().onFetchComplete(
bitmap);
}
};
((Activity) mContext).runOnUiThread(callback);
} catch (Exception e) {
Log.w(TAG, "Problem while loading data: " + e.toString(), e);
}
}
});
}
/**
* Given a URL for an Image, return a File representing where on our local
* disk the cached version should be saved.
*
* @param url
* to generate a file from
* @return a {@link File} instance representing the cached image
*/
private File cacheFileFromUrl(String url) {
// First compute the cache key and cache file path for this URL
File cacheFile = null;
String cacheKey;
try {
cacheKey = sha1Hash(url);
} catch (NoSuchAlgorithmException e) {
Log.w(TAG, "SHA-1 digest not available, falling back to Base64");
cacheKey = Base64.encodeToString(url.getBytes(), Base64.URL_SAFE);
}
if (Environment.MEDIA_MOUNTED.equals(Environment
.getExternalStorageState())) {
cacheFile = new File(Environment.getExternalStorageDirectory()
+ File.separator + "Android" + File.separator + "data"
+ File.separator + mContext.getPackageName()
+ File.separator + "cache" + File.separator + "bitmap_"
+ cacheKey + ".tmp");
}
return cacheFile;
}
/**
* Given a url, calculates the corresponding filename. If it exists in our
* cache, return it. If it does not, return null so we know we need to fetch
* it from the remote server.
*
* @param url
* the url to see if we have cached or not
* @return the cached Bitmap, or null on a cache miss
*/
private Bitmap getCachedBitmap(String url) {
File cacheFile = cacheFileFromUrl(url);
// TODO: Make this clear the cache periodically
if (cacheFile != null && cacheFile.exists()) {
Bitmap cachedBitmap = BitmapFactory
.decodeFile(cacheFile.toString());
if (cachedBitmap != null) {
return cachedBitmap;
}
}
return null;
}
/**
* Given a url, fetches the data and returns the data as a byte array.
*
* @param url
* The URL to read data from
* @return a byte array representing data read from the network
*/
public static byte[] readDataFromNetwork(String urlString) {
InputStream is = null;
URL url;
byte[] empty = new byte[] {};
try {
url = new URL(urlString);
} catch (MalformedURLException e) {
// This is the worst exception in the world
Log.e(TAG, "Somehow received a malformed URL: " + urlString, e);
return empty;
}
try {
URLConnection conn = url.openConnection();
conn.connect();
is = conn.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int read = 0;
while ((read = is.read(buffer, 0, buffer.length)) != -1) {
baos.write(buffer, 0, read);
}
baos.flush();
return baos.toByteArray();
} catch (MalformedURLException e) {
Log.e(TAG, "Bad URL", e);
} catch (IOException e) {
Log.e(TAG, "Could not fetch data from URL: " + url.toString(), e);
} finally {
try {
if (is != null)
is.close();
} catch (IOException e) {
Log.w(TAG, "Error closing stream.");
}
}
return empty;
}
// Helper methods
/**
* Returns a hex based String SHA-1 hash of the input message.
*
* @param message
* a String to hash
* @return a String representing input String's SHA1 hash
* @throws NoSuchAlgorithmException
* when the local system can't do SHA-1 digests
*/
private static String sha1Hash(String message)
throws NoSuchAlgorithmException {
MessageDigest mDigest = null;
mDigest = MessageDigest.getInstance("SHA-1");
mDigest.update(message.getBytes());
return bytesToHexString(mDigest.digest());
}
/**
* Converts a byte[] into a hex string. This is useful for generating SHA1
* hashes into a format that's more portable and useful in places that
* expect simply strings, such as filenames and URLs.
*
* @param bytes
* a byte array to convert into a string
* @return a hex string that's easier to store and pass around
*/
private static String bytesToHexString(byte[] bytes) {
// Source: http://stackoverflow.com/questions/332079
StringBuffer sb = new StringBuffer();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(0xFF & bytes[i]);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment