From 270d4738c23f493043157406a4a4ebfee96fde43 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Sun, 2 Dec 2012 15:47:45 -0500 Subject: [PATCH 01/16] Fixes documentation --- .../mit/mobile/android/imagecache/ImageLoaderAdapter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index dcea8a8..75eed26 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -41,9 +41,9 @@ *

* To use, pass in a ListAdapter that generates ImageViews in the layout hierarchy of getView(). * ImageViews are searched for using the IDs specified in imageViewIDs. When found, - * {@link ImageView#getTag()} is called and should return a {@link Uri} referencing a local or - * remote image. See {@link ImageCache#loadImage(long, Uri, int, int)} for details on the types of - * URIs and images supported. + * {@link ImageView#getTag(R.id.ic__load_id)} is called and should return a {@link Uri} referencing + * a local or remote image. See {@link ImageCache#loadImage(long, Uri, int, int)} for details on the + * types of URIs and images supported. *

* * @author Steve Pomeroy From b3981fd859694b20401943380af95e79d6331ffe Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Sun, 2 Dec 2012 16:41:05 -0500 Subject: [PATCH 02/16] Adds register/unregister image cache methods --- .../android/imagecache/ImageLoaderAdapter.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index 75eed26..97ab0bd 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -122,11 +122,24 @@ public ImageLoaderAdapter(ListAdapter wrapped, ImageCache cache, int[] imageView @Override protected void finalize() throws Throwable { - // TODO this should probably be in its own method, so it can be called in onPause / onResume - mCache.unregisterOnImageLoadListener(this); + unregisterOnImageLoadListener(); super.finalize(); } + /** + * This can be called from your onResume() method. + */ + public void registerOnImageLoadListener() { + mCache.registerOnImageLoadListener(this); + } + + /** + * This can be called from your onPause() method. + */ + public void unregisterOnImageLoadListener() { + mCache.unregisterOnImageLoadListener(this); + } + @Override public View getView(int position, View convertView, ViewGroup parent) { final View v = super.getView(position, convertView, parent); From a7d26b27c1dfb7762dc7ed999f4976ad89de6bd3 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Tue, 4 Dec 2012 06:06:46 -0500 Subject: [PATCH 03/16] Adds constructor to disable autosizing based on view size --- .../imagecache/ImageLoaderAdapter.java | 106 ++++++++++++------ 1 file changed, 73 insertions(+), 33 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index 97ab0bd..810df2f 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -59,7 +59,9 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnI private final int mDefaultWidth, mDefaultHeight; - private final SparseArray mViewDimensionCache = new SparseArray(); + private final boolean mAutosize; + + private final SparseArray mViewDimensionCache; public static final int UNIT_PX = 0, UNIT_DIP = 1; @@ -80,12 +82,44 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnI */ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache, int[] imageViewIDs, int defaultWidth, int defaultHeight, int unit) { + this(context, wrapped, cache, imageViewIDs, defaultWidth, defaultHeight, unit, true); + } + + /** + * @param context + * @param wrapped + * @param cache + * @param imageViewIDs + * a list of resource IDs matching the ImageViews that should be scanned and loaded. + * @param defaultWidth + * the default maximum width, in the specified unit. This size will be used if the + * size cannot be obtained from the view. + * @param defaultHeight + * the default maximum height, in the specified unit. This size will be used if the + * size cannot be obtained from the view. + * @param unit + * one of UNIT_PX or UNIT_DIP + * @param autosize + * if true, the view's dimensions will be cached the first time it's loaded and an + * image of the appropriate size will be requested the next time an image is loaded. + * False uses defaultWidth and defaultHeight only. + */ + public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache, + int[] imageViewIDs, int defaultWidth, int defaultHeight, int unit, boolean autosize) { super(wrapped); mImageViewIDs = imageViewIDs; mCache = cache; mCache.registerOnImageLoadListener(this); + mAutosize = autosize; + + if (autosize) { + mViewDimensionCache = new SparseArray(); + } else { + mViewDimensionCache = null; + } + switch (unit) { case UNIT_PX: mDefaultHeight = defaultHeight; @@ -160,45 +194,51 @@ public View getView(int position, View convertView, ViewGroup parent) { if (iv == null) { continue; } - ViewDimensionCache mViewDimension = mViewDimensionCache.get(id); - if (mViewDimension == null) { - final int w = iv.getMeasuredWidth(); - final int h = iv.getMeasuredHeight(); - if (w > 0 && h > 0) { - mViewDimension = new ViewDimensionCache(); - mViewDimension.width = w; - mViewDimension.height = h; - mViewDimensionCache.put(id, mViewDimension); + ViewDimensionCache viewDimension = null; + + if (mAutosize) { + viewDimension = mViewDimensionCache.get(id); + if (viewDimension == null) { + final int w = iv.getMeasuredWidth(); + final int h = iv.getMeasuredHeight(); + if (w > 0 && h > 0) { + viewDimension = new ViewDimensionCache(); + viewDimension.width = w; + viewDimension.height = h; + mViewDimensionCache.put(id, viewDimension); + } } } final Uri tag = (Uri) iv.getTag(R.id.ic__uri); - if (tag != null) { - final long imageID = mCache.getNewID(); - iv.setTag(R.id.ic__load_id, imageID); - // attempt to bypass all the loading machinery to get the image loaded as quickly - // as possible - Drawable d = null; - try { - if (mViewDimension != null && mViewDimension.width > 0 - && mViewDimension.height > 0) { - d = mCache.loadImage(imageID, tag, mViewDimension.width, - mViewDimension.height); - } else { - d = mCache.loadImage(imageID, tag, mDefaultWidth, mDefaultHeight); - } - } catch (final IOException e) { - e.printStackTrace(); - } - if (d != null) { - iv.setImageDrawable(d); + // short circuit if there's no tag + if (tag == null) { + return v; + } + + final long imageID = mCache.getNewID(); + iv.setTag(R.id.ic__load_id, imageID); + // attempt to bypass all the loading machinery to get the image loaded as quickly + // as possible + Drawable d = null; + try { + if (viewDimension != null && viewDimension.width > 0 && viewDimension.height > 0) { + d = mCache.loadImage(imageID, tag, viewDimension.width, viewDimension.height); } else { - if (ImageCache.DEBUG) { - Log.d(TAG, "scheduling load with ID: " + imageID + "; URI;" + tag); - } - mImageViewsToLoad.put(imageID, new SoftReference(iv)); + d = mCache.loadImage(imageID, tag, mDefaultWidth, mDefaultHeight); + } + } catch (final IOException e) { + e.printStackTrace(); + } + if (d != null) { + iv.setImageDrawable(d); + } else { + if (ImageCache.DEBUG) { + Log.d(TAG, "scheduling load with ID: " + imageID + "; URI;" + tag); } + mImageViewsToLoad.put(imageID, new SoftReference(iv)); } + } return v; } From 21913d55e1d94382fda61b324bdab939bfdd4b6d Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Tue, 11 Dec 2012 14:45:10 -0500 Subject: [PATCH 04/16] Adds documentation --- src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index 810df2f..c1fc2a0 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -87,7 +87,11 @@ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache /** * @param context + * a context for getting the display density. You don't need to worry about this + * class holding on to a reference to this: it's only used in the constructor. * @param wrapped + * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of + * using this adapter wrapper. * @param cache * @param imageViewIDs * a list of resource IDs matching the ImageViews that should be scanned and loaded. From a3ecd8a4804e1a1e74454c19b5c104c006d15077 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Tue, 11 Dec 2012 16:16:11 -0500 Subject: [PATCH 05/16] Adds hash-based key cache to reduce superfluous memory allocation --- .../mobile/android/imagecache/ImageCache.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageCache.java b/src/edu/mit/mobile/android/imagecache/ImageCache.java index 9b8d781..b56fb8e 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageCache.java +++ b/src/edu/mit/mobile/android/imagecache/ImageCache.java @@ -62,6 +62,7 @@ import android.os.Handler; import android.os.Message; import android.util.Log; +import android.util.SparseArray; import android.widget.ImageView; /** @@ -459,6 +460,8 @@ public Drawable getImage(Uri uri, int width, int height) throws ClientProtocolEx } } + private final SparseArray mKeyCache = new SparseArray(); + /** * Returns an opaque cache key representing the given uri, width and height. * @@ -471,8 +474,16 @@ public Drawable getImage(Uri uri, int width, int height) throws ClientProtocolEx * @return a cache key unique to the given parameters */ public String getKey(Uri uri, int width, int height) { - return uri.buildUpon().appendQueryParameter("width", String.valueOf(width)) - .appendQueryParameter("height", String.valueOf(height)).build().toString(); + // collisions are possible, but unlikely. + final int hashId = uri.hashCode() + width + height * 10000; + + String key = mKeyCache.get(hashId); + if (key == null) { + key = uri.buildUpon().appendQueryParameter("width", String.valueOf(width)) + .appendQueryParameter("height", String.valueOf(height)).build().toString(); + mKeyCache.put(hashId, key); + } + return key; } @Override @@ -481,6 +492,8 @@ public synchronized boolean clear() { mMemCache.evictAll(); + mKeyCache.clear(); + return success; } From 8e8a2710abc571470e4d306281cbe5df437f024a Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Thu, 13 Dec 2012 17:54:01 -0500 Subject: [PATCH 06/16] Changes ID from long to int; API-breaking change In order to play a bit more nicely with Android, IDs are now ints instead of longs. At 500 loads/second, it would take an int 49 days to wrap. So this seems reasonable. Additionally, this means that one can use SparseArrays and resource IDs without casting. Deprecated markers have been placed on the old methods. --- .../mobile/android/imagecache/ImageCache.java | 89 +++++++++++++++---- .../imagecache/ImageLoaderAdapter.java | 15 ++-- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageCache.java b/src/edu/mit/mobile/android/imagecache/ImageCache.java index b56fb8e..33105eb 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageCache.java +++ b/src/edu/mit/mobile/android/imagecache/ImageCache.java @@ -50,6 +50,7 @@ import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; import org.apache.http.params.HttpParams; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.res.Resources; @@ -100,7 +101,7 @@ public class ImageCache extends DiskCache { private DrawableMemCache mMemCache = new DrawableMemCache(DEFAULT_CACHE_SIZE); - private Long mIDCounter = (long) 0; + private Integer mIDCounter = 0; private static ImageCache mInstance; @@ -109,8 +110,11 @@ public class ImageCache extends DiskCache { private final ThreadPoolExecutor mExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new PriorityBlockingQueue()); - private final Map jobs = Collections - .synchronizedMap(new HashMap()); + + // ignored as SparseArray isn't thread-safe + @SuppressLint("UseSparseArrays") + private final Map jobs = Collections + .synchronizedMap(new HashMap()); private final HttpClient hc; @@ -229,7 +233,7 @@ private static String getExtension(CompressFormat format) { * * @return a new unique ID */ - public long getNewID() { + public int getNewID() { synchronized (mIDCounter) { return mIDCounter++; } @@ -313,8 +317,8 @@ private HttpClient getHttpClient() { /** *

* Registers an {@link OnImageLoadListener} with the cache. When an image is loaded - * asynchronously either directly by way of {@link #scheduleLoadImage(long, Uri, int, int)} or - * indirectly by {@link #loadImage(long, Uri, int, int)}, any registered listeners will get + * asynchronously either directly by way of {@link #scheduleLoadImage(int, Uri, int, int)} or + * indirectly by {@link #loadImage(int, Uri, int, int)}, any registered listeners will get * called. *

* @@ -344,14 +348,14 @@ public void unregisterOnImageLoadListener(OnImageLoadListener onImageLoadListene } private class LoadResult { - public LoadResult(long id, Uri image, Drawable drawable) { + public LoadResult(int id, Uri image, Drawable drawable) { this.id = id; this.drawable = drawable; this.image = image; } final Uri image; - final long id; + final int id; final Drawable drawable; } @@ -397,7 +401,7 @@ public void putDrawable(String key, Drawable drawable) { /** * A blocking call to get an image. If it's in the cache, it'll return the drawable immediately. * Otherwise it will download, scale, and cache the image before returning it. For non-blocking - * use, see {@link #loadImage(long, Uri, int, int)} + * use, see {@link #loadImage(int, Uri, int, int)} * * @param uri * @param width @@ -507,13 +511,13 @@ public synchronized boolean clear(String key) { } private class ImageLoadTask implements Runnable, Comparable { - private final long id; + private final int id; private final Uri uri; private final int width; private final int height; private final long when = System.nanoTime(); - public ImageLoadTask(long id, Uri image, int width, int height) { + public ImageLoadTask(int id, Uri image, int width, int height) { this.id = id; this.uri = image; this.width = width; @@ -582,7 +586,7 @@ private void oomClear() { * @return the cached bitmap if it's available immediately or null if it needs to be loaded * asynchronously. */ - public Drawable loadImage(long id, Uri image, int width, int height) throws IOException { + public Drawable loadImage(int id, Uri image, int width, int height) throws IOException { if (DEBUG) { Log.d(TAG, "loadImage(" + id + ", " + image + ", " + width + ", " + height + ")"); } @@ -597,6 +601,21 @@ public Drawable loadImage(long id, Uri image, int width, int height) throws IOEx return res; } + /** + * Deprecated to make IDs ints instead of longs. See {@link #loadImage(int, Uri, int, int)}. + * + * @param id + * @param image + * @param width + * @param height + * @return + * @throws IOException + */ + @Deprecated + public Drawable loadImage(long id, Uri image, int width, int height) throws IOException { + return loadImage(id, image, width, height); + } + /** * Schedules a load of the given image. When the image has finished loading and scaling, all * registered {@link OnImageLoadListener}s will be called. @@ -613,7 +632,7 @@ public Drawable loadImage(long id, Uri image, int width, int height) throws IOEx * @param height * the maximum height of the resulting image */ - public void scheduleLoadImage(long id, Uri image, int width, int height) { + public void scheduleLoadImage(int id, Uri image, int width, int height) { if (DEBUG) { Log.d(TAG, "executing new ImageLoadTask in background..."); } @@ -623,6 +642,19 @@ public void scheduleLoadImage(long id, Uri image, int width, int height) { mExecutor.execute(imt); } + /** + * Deprecated in favour of {@link #scheduleLoadImage(int, Uri, int, int)}. + * + * @param id + * @param image + * @param width + * @param height + */ + @Deprecated + public void scheduleLoadImage(long id, Uri image, int width, int height) { + scheduleLoadImage(id, image, width, height); + } + /** * Cancels all the asynchronous image loads. Note: currently does not function properly. * @@ -632,7 +664,7 @@ public void cancelLoads() { mExecutor.getQueue().clear(); } - public void cancel(long id) { + public void cancel(int id) { synchronized (jobs) { final Runnable job = jobs.get(id); if (job != null) { @@ -645,6 +677,16 @@ public void cancel(long id) { } } + /** + * Deprecated in favour of {@link #cancel(int)}. + * + * @param id + */ + @Deprecated + public void cancel(long id) { + cancel(id); + } + /** * Blocking call to scale a local file. Scales using preserving aspect ratio * @@ -764,6 +806,7 @@ protected void downloadImage(String key, Uri uri) throws ClientProtocolException private void notifyListeners(LoadResult result) { for (final OnImageLoadListener listener : mImageLoadListeners) { listener.onImageLoaded(result.id, result.image, result.drawable); + listener.onImageLoaded((long) result.id, result.image, result.drawable); } } @@ -780,13 +823,27 @@ public interface OnImageLoadListener { * Called when the image has been loaded and scaled. * * @param id - * the ID provided by {@link ImageCache#loadImage(long, Uri, int, int)} or - * {@link ImageCache#scheduleLoadImage(long, Uri, int, int)} + * the ID provided by {@link ImageCache#loadImage(int, Uri, int, int)} or + * {@link ImageCache#scheduleLoadImage(int, Uri, int, int)} * @param imageUri * the uri of the image that was originally requested * @param image * the loaded and scaled image */ + public void onImageLoaded(int id, Uri imageUri, Drawable image); + + /** + * Deprecated to change id from {@code long} to {@code int} in order to integrate better + * into Android. This will still be called as long as you read this message, however it will + * go away soon. Please change your signature to {@link #onImageLoaded(int, Uri, Drawable)}. + * This can be a no-op: both callbacks will be called. + * + * @param id + * @param imageUri + * @param image + * @see #onImageLoaded(int, Uri, Drawable) + */ + @Deprecated public void onImageLoaded(long id, Uri imageUri, Drawable image); } } diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index c1fc2a0..4d67bd7 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -19,7 +19,6 @@ */ import java.io.IOException; import java.lang.ref.SoftReference; -import java.util.HashMap; import android.content.Context; import android.graphics.drawable.Drawable; @@ -52,7 +51,7 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnImageLoadListener { private static final String TAG = ImageLoaderAdapter.class.getSimpleName(); - private final HashMap> mImageViewsToLoad = new HashMap>(); + private final SparseArray> mImageViewsToLoad = new SparseArray>(); private final int[] mImageViewIDs; private final ImageCache mCache; @@ -187,7 +186,7 @@ public View getView(int position, View convertView, ViewGroup parent) { if (convertView != null) { final ImageView iv = (ImageView) convertView.findViewById(id); if (iv != null) { - final Long tagId = (Long) iv.getTag(R.id.ic__load_id); + final Integer tagId = (Integer) iv.getTag(R.id.ic__load_id); if (tagId != null) { mCache.cancel(tagId); } @@ -220,7 +219,7 @@ public View getView(int position, View convertView, ViewGroup parent) { return v; } - final long imageID = mCache.getNewID(); + final int imageID = mCache.getNewID(); iv.setTag(R.id.ic__load_id, imageID); // attempt to bypass all the loading machinery to get the image loaded as quickly // as possible @@ -248,7 +247,7 @@ public View getView(int position, View convertView, ViewGroup parent) { } @Override - public void onImageLoaded(long id, Uri imageUri, Drawable image) { + public void onImageLoaded(int id, Uri imageUri, Drawable image) { final SoftReference ivRef = mImageViewsToLoad.get(id); if (ivRef == null) { return; @@ -271,4 +270,10 @@ private static class ViewDimensionCache { int width; int height; } + + @Override + @Deprecated + public void onImageLoaded(long id, Uri imageUri, Drawable image) { + // XXX + } } From b42d9ff9eea011f12872d5046da06bdbf867df4c Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Thu, 31 Jan 2013 18:39:32 -0500 Subject: [PATCH 07/16] Adds first pass at trim() function In this implementation, trimming is done based on when the item was written to the cache. --- .../mobile/android/imagecache/DiskCache.java | 99 ++++++++++++++++- .../imagecache/test/ImageCacheJunitTest.java | 104 ++++++++++++++---- 2 files changed, 180 insertions(+), 23 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/DiskCache.java b/src/edu/mit/mobile/android/imagecache/DiskCache.java index 79317fd..a30c5b5 100644 --- a/src/edu/mit/mobile/android/imagecache/DiskCache.java +++ b/src/edu/mit/mobile/android/imagecache/DiskCache.java @@ -1,7 +1,7 @@ package edu.mit.mobile.android.imagecache; /* - * Copyright (C) 2011 MIT Mobile Experience Lab + * Copyright (C) 2011-2013 MIT Mobile Experience Lab * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -28,6 +28,10 @@ import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import android.util.Log; @@ -50,6 +54,8 @@ public abstract class DiskCache { private final File mCacheBase; private final String mCachePrefix, mCacheSuffix; + private long mCacheSize; + /** * Creates a new disk cache with no cachePrefix or cacheSuffix * @@ -88,6 +94,13 @@ public DiskCache(File cacheBase, String cachePrefix, String cacheSuffix) { } } + /** + * @param maxSize maximum size of the cache, in bytes. + */ + public void setCacheMaxSize(long maxSize) { + mCacheSize = maxSize; + } + /** * Gets the cache filename for the given key. * @@ -242,12 +255,32 @@ public synchronized boolean clear() { } /** - * @return the size of the cache as it is on disk. + * @return the number of files in the cache + * @deprecated please use {@link #getCacheEntryCount()} or {@link #getCacheDiskUsage()} instead. */ + @Deprecated public int getCacheSize() { + return getCacheEntryCount(); + } + + /** + * @return the number of files in the cache as it is on disk. + */ + public int getCacheEntryCount() { return mCacheBase.listFiles(mCacheFileFilter).length; } + /** + * @return the size of the cache in bytes, as it is on disk. + */ + public synchronized long getCacheDiskUsage() { + long usage = 0; + for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)) { + usage += cacheFile.length(); + } + return usage; + } + private final CacheFileFilter mCacheFileFilter = new CacheFileFilter(); private class CacheFileFilter implements FileFilter { @@ -259,6 +292,68 @@ public boolean accept(File pathname) { } }; + final Comparator mLastModifiedOldestFirstComparator = new Comparator() { + + @Override + public int compare(File lhs, File rhs) { + return Long.valueOf(lhs.lastModified()).compareTo(rhs.lastModified()); + } + }; + + + /** + * Clears out cache entries in order to reduce the on-disk size to the desired max size. This is + * a somewhat expensive operation, so it should be done on a background thread. + * + * @return the number of bytes worth of files that were trimmed. + */ + public synchronized long trim() { + + long desiredSize; + if (mCacheSize > 0) { + desiredSize = mCacheSize; + } else { + desiredSize = mCacheBase.getUsableSpace() / 10; // 1/10th of the free space. + } + + final long sizeToTrim = Math.max(0, getCacheDiskUsage() - desiredSize); + + if (sizeToTrim == 0) { + return 0; + } + + long trimmed = 0; + + final List sorted = Arrays.asList(mCacheBase.listFiles(mCacheFileFilter)); + Collections.sort(sorted, mLastModifiedOldestFirstComparator); + + long size; + for (final File cacheFile : sorted) { + size = cacheFile.length(); + + if (cacheFile.delete()) { + trimmed += size; + if (BuildConfig.DEBUG) { + Log.d(TAG, "trimmed " + cacheFile.getName() + " from cache."); + } + } else { + Log.e(TAG, "error deleting " + cacheFile); + } + if (trimmed >= sizeToTrim) { + break; + } + } + if (BuildConfig.DEBUG) { + Log.d(TAG, "trimmed a total of " + trimmed + " bytes from cache."); + } + return trimmed; + } + + protected boolean touch(K key) { + final File f = getFile(key); + return f.setLastModified(System.currentTimeMillis()); + } + /** * Implement this to do the actual disk writing. Do not close the OutputStream; it will be * closed for you. diff --git a/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java b/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java index e19c4d0..1d59c54 100644 --- a/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java +++ b/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java @@ -1,6 +1,7 @@ package edu.mit.mobile.android.imagecache.test; + /* - * Copyright (C) 2011 MIT Mobile Experience Lab + * Copyright (C) 2011-2013 MIT Mobile Experience Lab * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -30,6 +31,7 @@ import android.graphics.drawable.Drawable; import android.net.Uri; import android.test.InstrumentationTestCase; +import android.test.suitebuilder.annotation.LargeTest; import edu.mit.mobile.android.imagecache.ImageCache; import edu.mit.mobile.android.imagecache.ImageCacheException; @@ -46,13 +48,13 @@ protected void setUp() throws Exception { imc = ImageCache.getInstance(getInstrumentation().getTargetContext()); } - public void testPreconditions(){ + public void testPreconditions() { assertNotNull(imc); } - public void testClear(){ + public void testClear() { assertTrue(imc.clear()); - assertEquals(0, imc.getCacheSize()); + assertEquals(0, imc.getCacheEntryCount()); } public void testGetPut() throws IOException { @@ -68,7 +70,7 @@ public void testGetPut() throws IOException { imc.put(key01, bmp); - assertEquals(1, imc.getCacheSize()); + assertEquals(1, imc.getCacheEntryCount()); Bitmap bmpResult = imc.get(key01); assertNotNull(bmpResult); @@ -79,14 +81,15 @@ public void testGetPut() throws IOException { // check contents to ensure it's the same // TODO - bmp = BitmapFactory.decodeResource(contextInst.getResources(), android.R.drawable.ic_dialog_alert); + bmp = BitmapFactory.decodeResource(contextInst.getResources(), + android.R.drawable.ic_dialog_alert); assertTrue(bmp.getHeight() > 0); assertTrue(bmp.getWidth() > 0); // call it again, ensure we overwrite imc.put(key01, bmp); - assertEquals(1, imc.getCacheSize()); + assertEquals(1, imc.getCacheEntryCount()); bmpResult = imc.get(key01); assertNotNull(bmpResult); @@ -100,27 +103,32 @@ public void testGetPut() throws IOException { testClear(); } - private void assertBitmapMaxSize(int maxExpectedWidth, int maxExpectedHeight, Drawable actual){ + private void assertBitmapMaxSize(int maxExpectedWidth, int maxExpectedHeight, Drawable actual) { assertTrue(maxExpectedWidth >= actual.getIntrinsicWidth()); assertTrue(maxExpectedHeight >= actual.getIntrinsicHeight()); } - private void assertBitmapMinSize(int minExpectedWidth, int minExpectedHeight, Drawable actual){ + private void assertBitmapMinSize(int minExpectedWidth, int minExpectedHeight, Drawable actual) { assertTrue(minExpectedWidth <= actual.getIntrinsicWidth()); assertTrue(minExpectedHeight <= actual.getIntrinsicHeight()); } - private void assertBitmapEqual(Bitmap expected, Bitmap actual){ + private void assertBitmapEqual(Bitmap expected, Bitmap actual) { assertEquals(expected.getHeight(), actual.getHeight()); assertEquals(expected.getWidth(), actual.getWidth()); } static final int LOCAL_SCALE_SIZE = 100; - public void testLocalFileLoad() throws IOException, ImageCacheException { - testClear(); + /** + * Loads a file from the assets and saves it to a public location. + * + * @return + * @throws IOException + */ + private Uri loadLocalFile() throws IOException { final String testfile = "logo_locast.png"; final Context contextInst = getInstrumentation().getContext(); @@ -133,10 +141,10 @@ public void testLocalFileLoad() throws IOException, ImageCacheException { assertNotNull(fos); - int read=0; + int read = 0; final byte[] bytes = new byte[1024]; - while((read = is.read(bytes))!= -1){ + while ((read = is.read(bytes)) != -1) { fos.write(bytes, 0, read); } @@ -148,6 +156,13 @@ public void testLocalFileLoad() throws IOException, ImageCacheException { final Uri fileUri = Uri.fromFile(outFile); assertNotNull(fileUri); + return fileUri; + } + + public void testLocalFileLoad() throws IOException, ImageCacheException { + testClear(); + + final Uri fileUri = loadLocalFile(); final Drawable img = imc.getImage(fileUri, LOCAL_SCALE_SIZE, LOCAL_SCALE_SIZE); @@ -155,16 +170,62 @@ public void testLocalFileLoad() throws IOException, ImageCacheException { // the thumbnails produced by this aren't precisely the size we request, due to efficiencies // in decoding the image. - assertBitmapMaxSize(LOCAL_SCALE_SIZE*2, LOCAL_SCALE_SIZE*2, img); + assertBitmapMaxSize(LOCAL_SCALE_SIZE * 2, LOCAL_SCALE_SIZE * 2, img); - assertBitmapMinSize(LOCAL_SCALE_SIZE/2, LOCAL_SCALE_SIZE/2, img); + assertBitmapMinSize(LOCAL_SCALE_SIZE / 2, LOCAL_SCALE_SIZE / 2, img); } - private final int NET_SCALE_SIZE = 100; + @LargeTest + public void testTrim() throws IOException, ImageCacheException { + testClear(); + + final Uri localFile = loadLocalFile(); + + final int maxSize = 150; + final int minSize = 50; + final int entryCount = maxSize - minSize + 1 /* includes max size */; + + for (int i = minSize; i <= maxSize; i++) { + final Drawable img = imc.getImage(localFile, i, i); + + assertNotNull(img); + } - private void testNetworkLoad(Uri uri) throws IOException, ImageCacheException{ + assertEquals(entryCount, imc.getCacheEntryCount()); + + final long diskUsage = imc.getCacheDiskUsage(); + + assertTrue("Disk usage isn't reasonable", diskUsage > 1000 && diskUsage < 10 * 1024 * 1024); + + // actual disk usage should be around 479100 + + final long cacheSize = 300 * 1024 /* kilo */; + imc.setCacheMaxSize(cacheSize); + + final long trimmed = imc.trim(); + + assertTrue("no bytes were trimmed", trimmed > 0); + + assertTrue("disk usage hasn't changed", diskUsage != imc.getCacheDiskUsage()); + + assertTrue("disk usage is larger than desired max size", + imc.getCacheDiskUsage() < cacheSize); + + assertTrue("entry count wasn't reduced", imc.getCacheEntryCount() < entryCount); + + // this should have the earliest creation date, so it should be trimmed first + assertFalse("first entry wasn't trimmed", + imc.contains(imc.getKey(localFile, minSize, minSize))); + + // this has the most recent creation date, so it should be trimmed last + assertTrue("last entry was trimmed", imc.contains(imc.getKey(localFile, maxSize, maxSize))); + + } + + private final int NET_SCALE_SIZE = 100; + private void testNetworkLoad(Uri uri) throws IOException, ImageCacheException { // ensure we don't have it in the cache final String origKey = imc.getKey(uri); @@ -177,9 +238,9 @@ private void testNetworkLoad(Uri uri) throws IOException, ImageCacheException{ assertNotNull(img); - assertBitmapMaxSize(NET_SCALE_SIZE*2, NET_SCALE_SIZE*2, img); + assertBitmapMaxSize(NET_SCALE_SIZE * 2, NET_SCALE_SIZE * 2, img); - assertBitmapMinSize(NET_SCALE_SIZE/2, NET_SCALE_SIZE/2, img); + assertBitmapMinSize(NET_SCALE_SIZE / 2, NET_SCALE_SIZE / 2, img); // ensure that it's stored in the disk cache assertNotNull(imc.get(origKey)); @@ -193,7 +254,8 @@ public void testNetworkLoad() throws ClientProtocolException, IOException, Image testNetworkLoad(Uri.parse("http://mobile-server.mit.edu/~stevep/logo_start_locast1.png")); } - public void testNetworkLoadLarge() throws ClientProtocolException, IOException, ImageCacheException { + public void testNetworkLoadLarge() throws ClientProtocolException, IOException, + ImageCacheException { testClear(); testNetworkLoad(Uri.parse("http://mobile-server.mit.edu/~stevep/large_logo.png")); From c71e425aeaee97ba3fbeca4ef858904e324f6ca9 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Fri, 1 Feb 2013 17:04:54 -0500 Subject: [PATCH 08/16] Replaces last-modified time with a simple queue --- .../mobile/android/imagecache/DiskCache.java | 66 +++++++++++-------- .../imagecache/test/ImageCacheJunitTest.java | 10 ++- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/DiskCache.java b/src/edu/mit/mobile/android/imagecache/DiskCache.java index a30c5b5..ad7a966 100644 --- a/src/edu/mit/mobile/android/imagecache/DiskCache.java +++ b/src/edu/mit/mobile/android/imagecache/DiskCache.java @@ -28,10 +28,7 @@ import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; import android.util.Log; @@ -54,6 +51,8 @@ public abstract class DiskCache { private final File mCacheBase; private final String mCachePrefix, mCacheSuffix; + private final ConcurrentLinkedQueue mQueue = new ConcurrentLinkedQueue(); + private long mCacheSize; /** @@ -95,7 +94,10 @@ public DiskCache(File cacheBase, String cachePrefix, String cacheSuffix) { } /** - * @param maxSize maximum size of the cache, in bytes. + * Sets the maximum size of the cache, in bytes. + * + * @param maxSize + * maximum size of the cache, in bytes. */ public void setCacheMaxSize(long maxSize) { mCacheSize = maxSize; @@ -127,6 +129,8 @@ public synchronized void put(K key, V value) throws IOException, FileNotFoundExc final OutputStream os = new FileOutputStream(saveHere); toDisk(key, value, os); os.close(); + + touchKey(key); } /** @@ -165,8 +169,23 @@ public void putRaw(K key, InputStream value) throws IOException, FileNotFoundExc tempFile.delete(); } } + touchKey(key); + } + + /** + * Puts the key at the end of the queue, removing it if it's already present. This will cause it + * to be removed last when {@link #trim()} is called. + * + * @param key + */ + private synchronized void touchKey(K key) { + if (mQueue.contains(key)) { + mQueue.remove(key); + } + mQueue.add(key); } + /** * Reads from an inputstream, dumps to an outputstream * @@ -201,6 +220,9 @@ public synchronized V get(K key) throws IOException { final InputStream is = new FileInputStream(readFrom); final V out = fromDisk(key, is); is.close(); + + touchKey(key); + return out; } @@ -292,20 +314,12 @@ public boolean accept(File pathname) { } }; - final Comparator mLastModifiedOldestFirstComparator = new Comparator() { - - @Override - public int compare(File lhs, File rhs) { - return Long.valueOf(lhs.lastModified()).compareTo(rhs.lastModified()); - } - }; - - /** * Clears out cache entries in order to reduce the on-disk size to the desired max size. This is * a somewhat expensive operation, so it should be done on a background thread. * * @return the number of bytes worth of files that were trimmed. + * @see #setCacheMaxSize(long) */ public synchronized long trim() { @@ -324,12 +338,17 @@ public synchronized long trim() { long trimmed = 0; - final List sorted = Arrays.asList(mCacheBase.listFiles(mCacheFileFilter)); - Collections.sort(sorted, mLastModifiedOldestFirstComparator); + while (trimmed < sizeToTrim && !mQueue.isEmpty()) { + final K key = mQueue.poll(); + + // shouldn't happen due to the check above, but just in case... + if (key == null) { + break; + } + + final File cacheFile = getFile(key); - long size; - for (final File cacheFile : sorted) { - size = cacheFile.length(); + final long size = cacheFile.length(); if (cacheFile.delete()) { trimmed += size; @@ -339,21 +358,14 @@ public synchronized long trim() { } else { Log.e(TAG, "error deleting " + cacheFile); } - if (trimmed >= sizeToTrim) { - break; - } } + if (BuildConfig.DEBUG) { Log.d(TAG, "trimmed a total of " + trimmed + " bytes from cache."); } return trimmed; } - protected boolean touch(K key) { - final File f = getFile(key); - return f.setLastModified(System.currentTimeMillis()); - } - /** * Implement this to do the actual disk writing. Do not close the OutputStream; it will be * closed for you. diff --git a/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java b/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java index 1d59c54..ac00702 100644 --- a/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java +++ b/test/src/edu/mit/mobile/android/imagecache/test/ImageCacheJunitTest.java @@ -194,6 +194,9 @@ public void testTrim() throws IOException, ImageCacheException { assertEquals(entryCount, imc.getCacheEntryCount()); + // cause a cache hit on the first item. + imc.get(imc.getKey(localFile, minSize, minSize)); + final long diskUsage = imc.getCacheDiskUsage(); assertTrue("Disk usage isn't reasonable", diskUsage > 1000 && diskUsage < 10 * 1024 * 1024); @@ -214,13 +217,14 @@ public void testTrim() throws IOException, ImageCacheException { assertTrue("entry count wasn't reduced", imc.getCacheEntryCount() < entryCount); - // this should have the earliest creation date, so it should be trimmed first - assertFalse("first entry wasn't trimmed", - imc.contains(imc.getKey(localFile, minSize, minSize))); + // this should have the earliest access time, so it should be trimmed first + assertFalse("second entry wasn't trimmed", + imc.contains(imc.getKey(localFile, minSize + 1, minSize + 1))); // this has the most recent creation date, so it should be trimmed last assertTrue("last entry was trimmed", imc.contains(imc.getKey(localFile, maxSize, maxSize))); + } private final int NET_SCALE_SIZE = 100; From 7c41162d08e8d056b611179cd5d3831fad8582b7 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Mon, 4 Feb 2013 16:24:40 -0500 Subject: [PATCH 09/16] Marks core methods final --- src/edu/mit/mobile/android/imagecache/DiskCache.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/DiskCache.java b/src/edu/mit/mobile/android/imagecache/DiskCache.java index ad7a966..067df9a 100644 --- a/src/edu/mit/mobile/android/imagecache/DiskCache.java +++ b/src/edu/mit/mobile/android/imagecache/DiskCache.java @@ -123,7 +123,7 @@ protected File getFile(K key) { * @param value * the data to be written to disk. */ - public synchronized void put(K key, V value) throws IOException, FileNotFoundException { + public final synchronized void put(K key, V value) throws IOException, FileNotFoundException { final File saveHere = getFile(key); final OutputStream os = new FileOutputStream(saveHere); @@ -143,7 +143,7 @@ public synchronized void put(K key, V value) throws IOException, FileNotFoundExc * @throws IOException * @throws FileNotFoundException */ - public void putRaw(K key, InputStream value) throws IOException, FileNotFoundException { + public final void putRaw(K key, InputStream value) throws IOException, FileNotFoundException { final File saveHere = getFile(key); @@ -210,7 +210,7 @@ static public void inputStreamToOutputStream(InputStream is, OutputStream os) * @param key * @return The value for key or null if the key doesn't map to any existing entries. */ - public synchronized V get(K key) throws IOException { + public final synchronized V get(K key) throws IOException { final File readFrom = getFile(key); if (!readFrom.exists()) { @@ -232,7 +232,7 @@ public synchronized V get(K key) throws IOException { * @param key * @return true if the disk cache contains the given key */ - public synchronized boolean contains(K key) { + public final synchronized boolean contains(K key) { final File readFrom = getFile(key); return readFrom.exists(); From b5ee6d33ce7270673da28da831ec7f5b3f485d4c Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Mon, 4 Feb 2013 16:55:25 -0500 Subject: [PATCH 10/16] Adds autotrim feature In order to make ImageCache as easy to use as possible, this adds a feature to automatically trim the disk cache in order to satisfy a given storage size. By default, this size is determined automatically as a fraction of the available free space. Cached items that haven't been accessed by the given ImageCache instance will be purged first, followed by items that haven't been accessed recently. All the parameters of autotrimming can be configured, including the use of autotrim at all. --- .../mobile/android/imagecache/DiskCache.java | 271 ++++++++++++++++-- .../mobile/android/imagecache/ImageCache.java | 3 +- 2 files changed, 247 insertions(+), 27 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/DiskCache.java b/src/edu/mit/mobile/android/imagecache/DiskCache.java index 067df9a..f159de2 100644 --- a/src/edu/mit/mobile/android/imagecache/DiskCache.java +++ b/src/edu/mit/mobile/android/imagecache/DiskCache.java @@ -28,12 +28,35 @@ import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import android.os.Build; +import android.os.StatFs; import android.util.Log; /** + *

* A simple disk cache. + *

+ * + *

+ * By default, the maximum size of the cache is automatically set based on the amount of free space + * available to the cache. Alternatively, a fixed size can be specified using + * {@link #setCacheMaxSize(long)}. + *

+ * + *

+ * By default, the cache will automatically maintain its size by periodically checking to see if it + * estimates that a trim is needed and if it is, proceeding to running {@link #trim()} on a worker + * thread. This feature can be controlled by {@link #setAutoTrimFrequency(int)}. + *

* * @author Steve Pomeroy * @@ -42,18 +65,52 @@ * @param * the value that will be stored to disk */ -// TODO add automatic cache cleanup so low disk conditions can be met public abstract class DiskCache { private static final String TAG = "DiskCache"; + /** + * Automatically determines the maximum size of the cache based on available free space. + */ + public static final int AUTO_MAX_CACHE_SIZE = 0; + + /** + * The default number of cache hits before {@link #trim()} is automatically triggered. See + * {@link #setAutoTrimFrequency(int)}. + */ + public static final int DEFAULT_AUTO_TRIM_FREQUENCY = 10; + + /** + * Pass to {@link #setAutoTrimFrequency(int)} to disable automatic trimming. See {@link #trim()} + * . + */ + public static final int AUTO_TRIM_DISABLED = 0; + + // ///////////////////////////////////////////// + + private long mMaxDiskUsage = AUTO_MAX_CACHE_SIZE; + private MessageDigest hash; private final File mCacheBase; private final String mCachePrefix, mCacheSuffix; - private final ConcurrentLinkedQueue mQueue = new ConcurrentLinkedQueue(); + private final ConcurrentLinkedQueue mQueue = new ConcurrentLinkedQueue(); - private long mCacheSize; + /** + * In auto max cache mode, the maximum is set to the total free space divided by this amount. + */ + private static final int AUTO_MAX_CACHE_SIZE_DIVISOR = 10; + + private int mAutoTrimFrequency = DEFAULT_AUTO_TRIM_FREQUENCY; + + private final ThreadPoolExecutor mExecutor = new ThreadPoolExecutor(1, 5, 60, TimeUnit.SECONDS, + new LinkedBlockingQueue()); + + private int mAutoTrimHitCount = 1; + + private long mEstimatedDiskUsage; + + private long mEstimatedFreeSpace; /** * Creates a new disk cache with no cachePrefix or cacheSuffix @@ -91,16 +148,71 @@ public DiskCache(File cacheBase, String cachePrefix, String cacheSuffix) { throw re; } } + + updateDiskUsageInBg(); } /** - * Sets the maximum size of the cache, in bytes. + * Sets the maximum size of the cache, in bytes. The default is to automatically manage the max + * size based on the available disk space. This can be explicitly set by passing this + * {@link #AUTO_MAX_CACHE_SIZE}. * * @param maxSize * maximum size of the cache, in bytes. */ public void setCacheMaxSize(long maxSize) { - mCacheSize = maxSize; + mMaxDiskUsage = maxSize; + } + + /** + * After this many puts, if it looks like there's a low space condition, {@link #trim()} will + * automatically be called. + * + * @param autoTrimFrequency + * Set to {@link #AUTO_TRIM_DISABLED} to turn off auto trim. The default is + * {@link #DEFAULT_AUTO_TRIM_FREQUENCY}. + */ + public void setAutoTrimFrequency(int autoTrimFrequency) { + mAutoTrimFrequency = autoTrimFrequency; + } + + /** + * Updates cached estimates on the + */ + private void updateDiskUsageEstimates() { + final long diskUsage = getCacheDiskUsage(); + + final long availableSpace = getFreeSpace(); + + synchronized (this) { + mEstimatedDiskUsage = diskUsage; + mEstimatedFreeSpace = availableSpace; + } + } + + private void updateDiskUsageInBg() { + mExecutor.execute(new Runnable() { + + @Override + public void run() { + updateDiskUsageEstimates(); + } + }); + } + + /** + * Gets the amount of space free on the cache volume. + * + * @return free space in bytes. + */ + private long getFreeSpace() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { + return mCacheBase.getUsableSpace(); + } else { + // maybe make singleton + final StatFs stat = new StatFs(mCacheBase.getAbsolutePath()); + return (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); + } } /** @@ -130,7 +242,11 @@ public final synchronized void put(K key, V value) throws IOException, FileNotFo toDisk(key, value, os); os.close(); - touchKey(key); + mEstimatedDiskUsage += saveHere.length(); + + touchEntry(saveHere); + + autotrim(); } /** @@ -169,22 +285,62 @@ public final void putRaw(K key, InputStream value) throws IOException, FileNotFo tempFile.delete(); } } - touchKey(key); + if (allGood) { + mEstimatedDiskUsage += saveHere.length(); + + touchEntry(saveHere); + + autotrim(); + } } /** * Puts the key at the end of the queue, removing it if it's already present. This will cause it * to be removed last when {@link #trim()} is called. * - * @param key + * @param cacheFile */ - private synchronized void touchKey(K key) { - if (mQueue.contains(key)) { - mQueue.remove(key); + private void touchEntry(File cacheFile) { + if (mQueue.contains(cacheFile)) { + mQueue.remove(cacheFile); } - mQueue.add(key); + mQueue.add(cacheFile); + } + + /** + * Marks the given key as accessed recently. This will deprioritize it from automatically being + * purged upon {@link #trim()}. + * + * @param key + */ + protected void touchKey(K key) { + touchEntry(getFile(key)); } + /** + * Call this every time you may be able to start a trim in the background. This implicitly runs + * {@link #updateDiskUsageInBg()} each time it's called. + */ + private void autotrim() { + if (mAutoTrimFrequency == 0) { + return; + } + + mAutoTrimHitCount = (mAutoTrimHitCount + 1) % mAutoTrimFrequency; + + if (mAutoTrimHitCount == 0 + && mEstimatedDiskUsage > Math.min(mEstimatedFreeSpace, mMaxDiskUsage)) { + + mExecutor.execute(new Runnable() { + @Override + public void run() { + trim(); + } + }); + } + + updateDiskUsageInBg(); + } /** * Reads from an inputstream, dumps to an outputstream @@ -221,7 +377,7 @@ public final synchronized V get(K key) throws IOException { final V out = fromDisk(key, is); is.close(); - touchKey(key); + touchEntry(readFrom); return out; } @@ -251,8 +407,38 @@ public synchronized boolean clear(K key) { if (!readFrom.exists()) { return true; } + final long size = readFrom.length(); + + final boolean success = readFrom.delete(); + + if (success) { + mEstimatedDiskUsage -= size; + } + + return success; + } + + /** + * Removes the item from the disk cache. + * + * @param cacheFile + * @return true if the cached item has been removed or was already removed, false if it was not + * able to be removed. + */ + private synchronized boolean clear(File cacheFile) { + + if (!cacheFile.exists()) { + return true; + } + final long size = cacheFile.length(); + + final boolean success = cacheFile.delete(); + + if (success) { + mEstimatedDiskUsage -= size; + } - return readFrom.delete(); + return success; } /** @@ -268,7 +454,6 @@ public synchronized boolean clear() { for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)) { if (!cacheFile.delete()) { - // throw new IOException("cannot delete cache file"); Log.e(TAG, "error deleting " + cacheFile); success = false; } @@ -295,7 +480,7 @@ public int getCacheEntryCount() { /** * @return the size of the cache in bytes, as it is on disk. */ - public synchronized long getCacheDiskUsage() { + public long getCacheDiskUsage() { long usage = 0; for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)) { usage += cacheFile.length(); @@ -314,9 +499,17 @@ public boolean accept(File pathname) { } }; + private final Comparator mLastModifiedOldestFirstComparator = new Comparator() { + + @Override + public int compare(File lhs, File rhs) { + return Long.valueOf(lhs.lastModified()).compareTo(rhs.lastModified()); + } + }; + /** - * Clears out cache entries in order to reduce the on-disk size to the desired max size. This is - * a somewhat expensive operation, so it should be done on a background thread. + * Clears out cache entries in order to reduce the on-disk usage to the desired maximum size. + * This is a somewhat expensive operation, so it should be done on a background thread. * * @return the number of bytes worth of files that were trimmed. * @see #setCacheMaxSize(long) @@ -324,12 +517,16 @@ public boolean accept(File pathname) { public synchronized long trim() { long desiredSize; - if (mCacheSize > 0) { - desiredSize = mCacheSize; + final long freeSpace = getFreeSpace(); + + if (mMaxDiskUsage > 0) { + desiredSize = mMaxDiskUsage; } else { - desiredSize = mCacheBase.getUsableSpace() / 10; // 1/10th of the free space. + desiredSize = getFreeSpace() / AUTO_MAX_CACHE_SIZE_DIVISOR; } + desiredSize = Math.min(freeSpace, desiredSize); + final long sizeToTrim = Math.max(0, getCacheDiskUsage() - desiredSize); if (sizeToTrim == 0) { @@ -338,19 +535,39 @@ public synchronized long trim() { long trimmed = 0; + final List sorted = Arrays.asList(mCacheBase.listFiles(mCacheFileFilter)); + Collections.sort(sorted, mLastModifiedOldestFirstComparator); + + // first clear out any files that aren't in the queue + for (final File cacheFile : sorted) { + if (mQueue.contains(cacheFile)) { + continue; + } + + final long size = cacheFile.length(); + if (clear(cacheFile)) { + trimmed += size; + if (BuildConfig.DEBUG) { + Log.d(TAG, "trimmed unqueued " + cacheFile.getName() + " from cache."); + } + } + + if (trimmed >= sizeToTrim) { + break; + } + } + while (trimmed < sizeToTrim && !mQueue.isEmpty()) { - final K key = mQueue.poll(); + final File cacheFile = mQueue.poll(); // shouldn't happen due to the check above, but just in case... - if (key == null) { + if (cacheFile == null) { break; } - final File cacheFile = getFile(key); - final long size = cacheFile.length(); - if (cacheFile.delete()) { + if (clear(cacheFile)) { trimmed += size; if (BuildConfig.DEBUG) { Log.d(TAG, "trimmed " + cacheFile.getName() + " from cache."); @@ -394,6 +611,8 @@ public synchronized long trim() { */ public String hash(K key) { final byte[] ba; + + // MessageDigest isn't threadsafe, so we need to ensure it doesn't tread on itself. synchronized (hash) { hash.update(key.toString().getBytes()); ba = hash.digest(); diff --git a/src/edu/mit/mobile/android/imagecache/ImageCache.java b/src/edu/mit/mobile/android/imagecache/ImageCache.java index 33105eb..83d4255 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageCache.java +++ b/src/edu/mit/mobile/android/imagecache/ImageCache.java @@ -381,6 +381,7 @@ public Drawable getDrawable(String key) { if (DEBUG) { Log.d(TAG, "mem cache hit for key " + key); } + touchKey(key); return img; } @@ -679,7 +680,7 @@ public void cancel(int id) { /** * Deprecated in favour of {@link #cancel(int)}. - * + * * @param id */ @Deprecated From b8316dfae5b8cfc2a73d0ef65b1ec2418ce1c5a9 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Mon, 4 Feb 2013 17:07:30 -0500 Subject: [PATCH 11/16] Adds tests for trim --- test/res/menu/main_menu.xml | 5 ++++ .../imagecache/test/ConcurrencyTest.java | 20 ++++++++++--- .../imagecache/test/ImageCacheJunitTest.java | 2 ++ .../imagecache/test/InteractiveDemo.java | 29 +++++++++++++++---- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/test/res/menu/main_menu.xml b/test/res/menu/main_menu.xml index ac8b64e..6c62d53 100644 --- a/test/res/menu/main_menu.xml +++ b/test/res/menu/main_menu.xml @@ -6,6 +6,11 @@ android:icon="@android:drawable/ic_menu_delete" android:showAsAction="ifRoom" android:title="Clear Cache"/> + Date: Mon, 4 Feb 2013 17:07:42 -0500 Subject: [PATCH 12/16] Sets HTTP connection timeout to 20s --- src/edu/mit/mobile/android/imagecache/ImageCache.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageCache.java b/src/edu/mit/mobile/android/imagecache/ImageCache.java index 83d4255..4c4d7a5 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageCache.java +++ b/src/edu/mit/mobile/android/imagecache/ImageCache.java @@ -48,6 +48,7 @@ import org.apache.http.conn.ssl.SSLSocketFactory; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; +import org.apache.http.params.CoreConnectionPNames; import org.apache.http.params.HttpParams; import android.annotation.SuppressLint; @@ -171,7 +172,11 @@ public static ImageCache getInstance(Context context) { */ public ImageCache(Context context, CompressFormat format, int quality) { super(context.getCacheDir(), null, getExtension(format)); - hc = getHttpClient(); + if (USE_APACHE_NC) { + hc = getHttpClient(); + } else { + hc = null; + } mRes = context.getResources(); @@ -287,6 +292,8 @@ private HttpClient getHttpClient() { final HttpParams params = dhc.getParams(); dhc = null; + params.setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, 20 * 1000); + final SchemeRegistry registry = new SchemeRegistry(); registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); registry.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443)); From a2321ab8988a9c525b4c63870d274b1ebef3f922 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Mon, 4 Feb 2013 17:16:57 -0500 Subject: [PATCH 13/16] Updates README --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8fd3979..6bd8216 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Image Cache =========== An image download-and-cacher that also knows how to efficiently generate -and retrieve thumbnails of various sizes. +and retrieve thumbnails of various sizes. Features -------- @@ -12,6 +12,8 @@ Features * automatic generation and caching of multiple sizes of images based on one downloaded asset * provides a disk cache as well as a memory cache +* automatic disk cache management; no setup necessary, but parameters can be + fine-tuned if desired * designed to work with your existing setup: no extending a custom application or activity needed * cursor adapter supports multiple image fields for each ImageView; skips @@ -21,8 +23,8 @@ Features Using ----- -Please see the test/ directory for both a simple example of using it as well as -some unit tests. When running the application in test/ make sure to run it as +Please see the `test/` directory for both a simple example of using it as well as +some unit tests. When running the application in `test/` make sure to run it as an Android activity if you want to see the demo. Both the unit tests and the interactive test load some images from our lab's servers. @@ -31,7 +33,7 @@ License ======= MEL Android Image Cache -Copyright (C) 2011-2012 [MIT Mobile Experience Lab][mel] +Copyright (C) 2011-2013 [MIT Mobile Experience Lab][mel] This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public From 6c86ed5a25e1740d41785377dbc1745673cf06a9 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Mon, 4 Feb 2013 18:42:35 -0500 Subject: [PATCH 14/16] Removes deprecated callback; API-breaking change --- .../mit/mobile/android/imagecache/ImageCache.java | 15 --------------- .../android/imagecache/ImageLoaderAdapter.java | 6 ------ 2 files changed, 21 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageCache.java b/src/edu/mit/mobile/android/imagecache/ImageCache.java index 4c4d7a5..bdceb13 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageCache.java +++ b/src/edu/mit/mobile/android/imagecache/ImageCache.java @@ -814,7 +814,6 @@ protected void downloadImage(String key, Uri uri) throws ClientProtocolException private void notifyListeners(LoadResult result) { for (final OnImageLoadListener listener : mImageLoadListeners) { listener.onImageLoaded(result.id, result.image, result.drawable); - listener.onImageLoaded((long) result.id, result.image, result.drawable); } } @@ -839,19 +838,5 @@ public interface OnImageLoadListener { * the loaded and scaled image */ public void onImageLoaded(int id, Uri imageUri, Drawable image); - - /** - * Deprecated to change id from {@code long} to {@code int} in order to integrate better - * into Android. This will still be called as long as you read this message, however it will - * go away soon. Please change your signature to {@link #onImageLoaded(int, Uri, Drawable)}. - * This can be a no-op: both callbacks will be called. - * - * @param id - * @param imageUri - * @param image - * @see #onImageLoaded(int, Uri, Drawable) - */ - @Deprecated - public void onImageLoaded(long id, Uri imageUri, Drawable image); } } diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index 4d67bd7..61e2531 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -270,10 +270,4 @@ private static class ViewDimensionCache { int width; int height; } - - @Override - @Deprecated - public void onImageLoaded(long id, Uri imageUri, Drawable image) { - // XXX - } } From f35af3bf31a4d8a3389b24abf4c1859585c74773 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Tue, 12 Feb 2013 12:53:18 -0500 Subject: [PATCH 15/16] Fixes documentation bug; improves ImageLoaderAdapter docs Closes #8 --- res/values/ids.xml | 2 + .../imagecache/ImageLoaderAdapter.java | 53 ++++++++++++++----- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/res/values/ids.xml b/res/values/ids.xml index 5c8f24f..3a09717 100644 --- a/res/values/ids.xml +++ b/res/values/ids.xml @@ -1,7 +1,9 @@ + + \ No newline at end of file diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index 61e2531..4c411ed 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -1,7 +1,7 @@ package edu.mit.mobile.android.imagecache; /* - * Copyright (C) 2011-2012 MIT Mobile Experience Lab + * Copyright (C) 2011-2013 MIT Mobile Experience Lab * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -20,6 +20,7 @@ import java.io.IOException; import java.lang.ref.SoftReference; +import android.app.Activity; import android.content.Context; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -38,11 +39,11 @@ *

* *

- * To use, pass in a ListAdapter that generates ImageViews in the layout hierarchy of getView(). - * ImageViews are searched for using the IDs specified in imageViewIDs. When found, - * {@link ImageView#getTag(R.id.ic__load_id)} is called and should return a {@link Uri} referencing - * a local or remote image. See {@link ImageCache#loadImage(long, Uri, int, int)} for details on the - * types of URIs and images supported. + * To use, pass in a {@link ListAdapter} that generates {@link ImageView}s in the layout hierarchy + * of getView(). ImageViews are searched for using the IDs specified in {@code imageViewIDs}. When + * found, {@link ImageView#getTag(R.id.ic__uri)} is called and should return a {@link Uri} + * referencing a local or remote image. See {@link ImageCache#loadImage(int, Uri, int, int)} for + * details on the types of URIs and images supported. *

* * @author Steve Pomeroy @@ -51,6 +52,20 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnImageLoadListener { private static final String TAG = ImageLoaderAdapter.class.getSimpleName(); + /** + * The unit specified is in pixels + */ + public static final int UNIT_PX = 0; + + /** + * The unit specified is in density-independent pixels (DIP) + */ + public static final int UNIT_DIP = 1; + + // ////////////////////////////////////////////// + // / private + // ////////////////////////////////////////////// + private final SparseArray> mImageViewsToLoad = new SparseArray>(); private final int[] mImageViewIDs; @@ -62,12 +77,21 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnI private final SparseArray mViewDimensionCache; - public static final int UNIT_PX = 0, UNIT_DIP = 1; + // /////////////////////////////////////////////// /** + * Like the + * {@link #ImageLoaderAdapter(Context, ListAdapter, ImageCache, int[], int, int, int, boolean)} + * constructor with a default of {@code true} for autosize. + * * @param context + * a context for getting the display density. You don't need to worry about this + * class holding on to a reference to this: it's only used in the constructor. * @param wrapped + * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of + * using this adapter wrapper. * @param cache + * an instance of your image cache. This can be shared with the process. * @param imageViewIDs * a list of resource IDs matching the ImageViews that should be scanned and loaded. * @param defaultWidth @@ -77,7 +101,7 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnI * the default maximum height, in the specified unit. This size will be used if the * size cannot be obtained from the view. * @param unit - * one of UNIT_PX or UNIT_DIP + * one of {@link #UNIT_PX} or {@link #UNIT_DIP} */ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache, int[] imageViewIDs, int defaultWidth, int defaultHeight, int unit) { @@ -92,6 +116,7 @@ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of * using this adapter wrapper. * @param cache + * an instance of your image cache. This can be shared with the process. * @param imageViewIDs * a list of resource IDs matching the ImageViews that should be scanned and loaded. * @param defaultWidth @@ -101,7 +126,7 @@ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache * the default maximum height, in the specified unit. This size will be used if the * size cannot be obtained from the view. * @param unit - * one of UNIT_PX or UNIT_DIP + * one of {@link #UNIT_PX} or {@link #UNIT_DIP} * @param autosize * if true, the view's dimensions will be cached the first time it's loaded and an * image of the appropriate size will be requested the next time an image is loaded. @@ -143,8 +168,13 @@ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache } /** + * Constructs a new adapter with a default unit of pixels. + * * @param wrapped + * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of + * using this adapter wrapper. * @param cache + * an instance of your image cache. This can be shared with the process. * @param imageViewIDs * a list of resource IDs matching the ImageViews that should be scan * @param width @@ -164,14 +194,14 @@ protected void finalize() throws Throwable { } /** - * This can be called from your onResume() method. + * This can be called from your {@link Activity#onResume()} method. */ public void registerOnImageLoadListener() { mCache.registerOnImageLoadListener(this); } /** - * This can be called from your onPause() method. + * This can be called from your {@link Activity#onPause()} method. */ public void unregisterOnImageLoadListener() { mCache.unregisterOnImageLoadListener(this); @@ -181,7 +211,6 @@ public void unregisterOnImageLoadListener() { public View getView(int position, View convertView, ViewGroup parent) { final View v = super.getView(position, convertView, parent); - for (final int id : mImageViewIDs) { if (convertView != null) { final ImageView iv = (ImageView) convertView.findViewById(id); From b3b093d945b7ddfa3f68078fe553d6f634d29676 Mon Sep 17 00:00:00 2001 From: Steve Pomeroy Date: Wed, 13 Feb 2013 16:45:11 -0500 Subject: [PATCH 16/16] Moves short-circuit to a better location in Adapter --- .../android/imagecache/ImageLoaderAdapter.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java index 4c411ed..d098311 100644 --- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java +++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java @@ -226,6 +226,13 @@ public View getView(int position, View convertView, ViewGroup parent) { if (iv == null) { continue; } + + final Uri tag = (Uri) iv.getTag(R.id.ic__uri); + // short circuit if there's no tag + if (tag == null) { + continue; + } + ViewDimensionCache viewDimension = null; if (mAutosize) { @@ -242,13 +249,11 @@ public View getView(int position, View convertView, ViewGroup parent) { } } - final Uri tag = (Uri) iv.getTag(R.id.ic__uri); - // short circuit if there's no tag - if (tag == null) { - return v; - } - final int imageID = mCache.getNewID(); + + // ic__load_id is used to keep track of what load ID is associated with what + // particular ImageView + iv.setTag(R.id.ic__load_id, imageID); // attempt to bypass all the loading machinery to get the image loaded as quickly // as possible