Created
March 30, 2013 21:31
-
-
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 …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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<MyCustomClass> { | |
* 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); | |
* } | |
* | |
* @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 < 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() { | |
* | |
* @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