/******************************************************************************* * Copyright (c) 2013 Chris Banes. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. ******************************************************************************/ package uk.co.senab.bitmapcache; import com.jakewharton.DiskLruCache; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.AsyncTask; import android.os.Build; import android.os.Looper; import android.os.Process; import android.util.Log; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; /** * A cache which can be set to use multiple layers of caching for Bitmap objects in an Android app. * Instances are created via a {@link Builder} instance, which can be used to alter the settings of * the resulting cache. * * <p> Instances of this class should ideally be kept globally with the application, for example in * the {@link android.app.Application Application} object. You should also use the bundled {@link * CacheableImageView} wherever possible, as the memory cache has a close relationship with it. * </p> * * <p> Clients can call {@link #get(String)} to retrieve a cached value from the given Url. This * will check all available caches for the value. There are also the {@link * #getFromDiskCache(String, android.graphics.BitmapFactory.Options)} and {@link * #getFromMemoryCache(String)} which allow more granular access. </p> * * <p> There are a number of update methods. {@link #put(String, InputStream)} and {@link * #put(String, InputStream)} are the preferred versions of the method, as they allow 1:1 caching to * disk of the original content. <br /> {@link #put(String, Bitmap)}} should only be used if you * can't get access to the original InputStream. </p> * * @author Chris Banes */ public class BitmapLruCache { /** * The recycle policy controls if the {@link android.graphics.Bitmap#recycle()} is automatically * called, when it is no longer being used. To set this, use the {@link * Builder#setRecyclePolicy(uk.co.senab.bitmapcache.BitmapLruCache.RecyclePolicy) * Builder.setRecyclePolicy()} method. */ public static enum RecyclePolicy { /** * The Bitmap is never recycled automatically. */ DISABLED, /** * The Bitmap is only automatically recycled if running on a device API v10 or earlier. */ PRE_HONEYCOMB_ONLY, /** * The Bitmap is always recycled when no longer being used. This is the default. */ ALWAYS; boolean canRecycle() { switch (this) { case DISABLED: return false; case PRE_HONEYCOMB_ONLY: return Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB; case ALWAYS: return true; } return false; } } // The number of seconds after the last edit that the Disk Cache should be // flushed static final int DISK_CACHE_FLUSH_DELAY_SECS = 5; /** * @throws IllegalStateException if the calling thread is the main/UI thread. */ private static void checkNotOnMainThread() { if (Looper.myLooper() == Looper.getMainLooper()) { throw new IllegalStateException( "This method should not be called from the main/UI thread."); } } /** * The disk cache only accepts a reduced range of characters for the key values. This method * transforms the {@code url} into something accepted from {@link DiskLruCache}. Currently we * simply return a MD5 hash of the url. * * @param url - Key to be transformed * @return key which can be used for the disk cache */ private static String transformUrlForDiskCacheKey(String url) { return Md5.encode(url); } private File mTempDir; private Resources mResources; /** * Memory Cache Variables */ private BitmapMemoryLruCache mMemoryCache; private RecyclePolicy mRecyclePolicy; /** * Disk Cache Variables */ private DiskLruCache mDiskCache; // Variables which are only used when the Disk Cache is enabled private HashMap<String, ReentrantLock> mDiskCacheEditLocks; private ScheduledThreadPoolExecutor mDiskCacheFlusherExecutor; private DiskCacheFlushRunnable mDiskCacheFlusherRunnable; // Transient private ScheduledFuture<?> mDiskCacheFuture; BitmapLruCache(Context context) { if (null != context) { // Make sure we have the application context context = context.getApplicationContext(); mTempDir = context.getCacheDir(); mResources = context.getResources(); } } /** * Returns whether any of the enabled caches contain the specified URL. <p/> If you have the * disk cache enabled, you should not call this method from main/UI thread. * * @param url the URL to search for. * @return {@code true} if any of the caches contain the specified URL, {@code false} * otherwise. */ public boolean contains(String url) { return containsInMemoryCache(url) || containsInDiskCache(url); } /** * Returns whether the Disk Cache contains the specified URL. You should not call this method * from main/UI thread. * * @param url the URL to search for. * @return {@code true} if the Disk Cache is enabled and contains the specified URL, {@code * false} otherwise. */ public boolean containsInDiskCache(String url) { if (null != mDiskCache) { checkNotOnMainThread(); try { return null != mDiskCache.get(transformUrlForDiskCacheKey(url)); } catch (IOException e) { e.printStackTrace(); } } return false; } /** * Returns whether the Memory Cache contains the specified URL. This method is safe to be called * from the main thread. * * @param url the URL to search for. * @return {@code true} if the Memory Cache is enabled and contains the specified URL, {@code * false} otherwise. */ public boolean containsInMemoryCache(String url) { return null != mMemoryCache && null != mMemoryCache.get(url); } /** * Returns the value for {@code url}. This will check all caches currently enabled. <p/> If you * have the disk cache enabled, you should not call this method from main/UI thread. * * @param url - String representing the URL of the image */ public CacheableBitmapDrawable get(String url) { return get(url, null); } /** * Returns the value for {@code url}. This will check all caches currently enabled. <p/> If you * have the disk cache enabled, you should not call this method from main/UI thread. * * @param url - String representing the URL of the image * @param decodeOpts - Options used for decoding the contents from the disk cache only. */ public CacheableBitmapDrawable get(String url, BitmapFactory.Options decodeOpts) { CacheableBitmapDrawable result; // First try Memory Cache result = getFromMemoryCache(url); if (null == result) { // Memory Cache failed, so try Disk Cache result = getFromDiskCache(url, decodeOpts); } return result; } /** * Returns the value for {@code url} in the disk cache only. You should not call this method * from main/UI thread. <p/> If enabled, the result of this method will be cached in the memory * cache. <p /> Unless you have a specific requirement to only query the disk cache, you should * call {@link #get(String)} instead. * * @param url - String representing the URL of the image * @param decodeOpts - Options used for decoding the contents from the disk cache. * @return Value for {@code url} from disk cache, or {@code null} if the disk cache is not * enabled. */ public CacheableBitmapDrawable getFromDiskCache(final String url, final BitmapFactory.Options decodeOpts) { CacheableBitmapDrawable result = null; if (null != mDiskCache) { checkNotOnMainThread(); try { final String key = transformUrlForDiskCacheKey(url); DiskLruCache.Snapshot snapshot = mDiskCache.get(key); if (null != snapshot) { // Try and decode bitmap Bitmap bitmap = BitmapFactory .decodeStream(snapshot.getInputStream(0), null, decodeOpts); if (null != bitmap) { result = new CacheableBitmapDrawable(url, mResources, bitmap, mRecyclePolicy); if (null != mMemoryCache) { mMemoryCache.put(result); } } else { // If we get here, the file in the cache can't be // decoded. Remove it and schedule a flush. mDiskCache.remove(key); scheduleDiskCacheFlush(); } } } catch (IOException e) { e.printStackTrace(); } } return result; } /** * Returns the value for {@code url} in the memory cache only. This method is safe to be called * from the main thread. <p /> You should check the result of this method before starting a * threaded call. * * @param url - String representing the URL of the image * @return Value for {@code url} from memory cache, or {@code null} if the disk cache is not * enabled. */ public CacheableBitmapDrawable getFromMemoryCache(final String url) { CacheableBitmapDrawable result = null; if (null != mMemoryCache) { synchronized (mMemoryCache) { result = mMemoryCache.get(url); // If we get a value, but it has a invalid bitmap, remove it if (null != result && !result.hasValidBitmap()) { mMemoryCache.remove(url); result = null; } } } return result; } /** * @return true if the Disk Cache is enabled. */ public boolean isDiskCacheEnabled() { return null != mDiskCache; } /** * @return true if the Memory Cache is enabled. */ public boolean isMemoryCacheEnabled() { return null != mMemoryCache; } /** * Caches {@code bitmap} for {@code url} into all enabled caches. If the disk cache is enabled, * the bitmap will be compressed losslessly. <p/> If you have the disk cache enabled, you should * not call this method from main/UI thread. * * @param url - String representing the URL of the image. * @param bitmap - Bitmap which has been decoded from {@code url}. * @return CacheableBitmapDrawable which can be used to display the bitmap. */ public CacheableBitmapDrawable put(final String url, final Bitmap bitmap) { CacheableBitmapDrawable d = new CacheableBitmapDrawable(url, mResources, bitmap, mRecyclePolicy); if (null != mMemoryCache) { mMemoryCache.put(d); } if (null != mDiskCache) { checkNotOnMainThread(); final String key = transformUrlForDiskCacheKey(url); final ReentrantLock lock = getLockForDiskCacheEdit(key); lock.lock(); try { DiskLruCache.Editor editor = mDiskCache.edit(key); Util.saveBitmap(bitmap, editor.newOutputStream(0)); editor.commit(); } catch (IOException e) { e.printStackTrace(); } finally { lock.unlock(); scheduleDiskCacheFlush(); } } return d; } /** * Caches resulting bitmap from {@code inputStream} for {@code url} into all enabled caches. * This version of the method should be preferred as it allows the original image contents to be * cached, rather than a re-compressed version. <p /> The contents of the InputStream will be * copied to a temporary file, then the file will be decoded into a Bitmap. Providing the decode * worked: <ul> <li>If the memory cache is enabled, the decoded Bitmap will be cached to * memory.</li> <li>If the disk cache is enabled, the contents of the original stream will be * cached to disk.</li> </ul> <p/> You should not call this method from the main/UI thread. * * @param url - String representing the URL of the image * @param inputStream - InputStream opened from {@code url} * @return CacheableBitmapDrawable which can be used to display the bitmap. */ public CacheableBitmapDrawable put(final String url, final InputStream inputStream) { return put(url, inputStream, null); } /** * Caches resulting bitmap from {@code inputStream} for {@code url} into all enabled caches. * This version of the method should be preferred as it allows the original image contents to be * cached, rather than a re-compressed version. <p /> The contents of the InputStream will be * copied to a temporary file, then the file will be decoded into a Bitmap, using the optional * <code>decodeOpts</code>. Providing the decode worked: <ul> <li>If the memory cache is * enabled, the decoded Bitmap will be cached to memory.</li> <li>If the disk cache is enabled, * the contents of the original stream will be cached to disk.</li> </ul> <p/> You should not * call this method from the main/UI thread. * * @param url - String representing the URL of the image * @param inputStream - InputStream opened from {@code url} * @param decodeOpts - Options used for decoding. This does not affect what is cached in the * disk cache (if enabled). * @return CacheableBitmapDrawable which can be used to display the bitmap. */ public CacheableBitmapDrawable put(final String url, final InputStream inputStream, final BitmapFactory.Options decodeOpts) { checkNotOnMainThread(); // First we need to save the stream contents to a temporary file, so it // can be read multiple times File tmpFile = null; try { tmpFile = File.createTempFile("bitmapcache_", null, mTempDir); // Pipe InputStream to file Util.copy(inputStream, tmpFile); try { // Close the original InputStream inputStream.close(); } catch (IOException e) { // NO-OP - Ignore } } catch (IOException e) { e.printStackTrace(); } CacheableBitmapDrawable d = null; if (null != tmpFile) { // Try and decode File Bitmap bitmap = BitmapFactory.decodeFile(tmpFile.getAbsolutePath(), decodeOpts); if (null != bitmap) { d = new CacheableBitmapDrawable(url, mResources, bitmap, mRecyclePolicy); if (null != mMemoryCache) { d.setCached(true); mMemoryCache.put(d.getUrl(), d); } if (null != mDiskCache) { final ReentrantLock lock = getLockForDiskCacheEdit(url); lock.lock(); try { DiskLruCache.Editor editor = mDiskCache .edit(transformUrlForDiskCacheKey(url)); Util.copy(tmpFile, editor.newOutputStream(0)); editor.commit(); } catch (IOException e) { e.printStackTrace(); } finally { lock.unlock(); scheduleDiskCacheFlush(); } } } // Finally, delete the temporary file tmpFile.delete(); } return d; } /** * Removes the entry for {@code url} from all enabled caches, if it exists. <p/> If you have the * disk cache enabled, you should not call this method from main/UI thread. */ public void remove(String url) { if (null != mMemoryCache) { mMemoryCache.remove(url); } if (null != mDiskCache) { checkNotOnMainThread(); try { mDiskCache.remove(transformUrlForDiskCacheKey(url)); scheduleDiskCacheFlush(); } catch (IOException e) { e.printStackTrace(); } } } /** * This method iterates through the memory cache (if enabled) and removes any entries which are * not currently being displayed. A good place to call this would be from {@link * android.app.Application#onLowMemory() Application.onLowMemory()}. */ public void trimMemory() { if (null != mMemoryCache) { mMemoryCache.trimMemory(); } } synchronized void setDiskCache(DiskLruCache diskCache) { mDiskCache = diskCache; if (null != diskCache) { mDiskCacheEditLocks = new HashMap<String, ReentrantLock>(); mDiskCacheFlusherExecutor = new ScheduledThreadPoolExecutor(1); mDiskCacheFlusherRunnable = new DiskCacheFlushRunnable(diskCache); } } void setMemoryCache(BitmapMemoryLruCache memoryCache, RecyclePolicy recyclePolicy) { mMemoryCache = memoryCache; mRecyclePolicy = recyclePolicy; } private ReentrantLock getLockForDiskCacheEdit(String url) { synchronized (mDiskCacheEditLocks) { ReentrantLock lock = mDiskCacheEditLocks.get(url); if (null == lock) { lock = new ReentrantLock(); mDiskCacheEditLocks.put(url, lock); } return lock; } } private void scheduleDiskCacheFlush() { // If we already have a flush scheduled, cancel it if (null != mDiskCacheFuture) { mDiskCacheFuture.cancel(false); } // Schedule a flush mDiskCacheFuture = mDiskCacheFlusherExecutor .schedule(mDiskCacheFlusherRunnable, DISK_CACHE_FLUSH_DELAY_SECS, TimeUnit.SECONDS); } /** * Builder class for {link {@link BitmapLruCache}. An example call: * * <pre> * BitmapLruCache.Builder builder = new BitmapLruCache.Builder(); * builder.setMemoryCacheEnabled(true).setMemoryCacheMaxSizeUsingHeapSize(this); * builder.setDiskCacheEnabled(true).setDiskCacheLocation(...); * * BitmapLruCache cache = builder.build(); * </pre> * * @author Chris Banes */ public final static class Builder { static final int MEGABYTE = 1024 * 1024; static final float DEFAULT_MEMORY_CACHE_HEAP_RATIO = 1f / 8f; static final float MAX_MEMORY_CACHE_HEAP_RATIO = 0.75f; static final int DEFAULT_DISK_CACHE_MAX_SIZE_MB = 10; static final int DEFAULT_MEM_CACHE_MAX_SIZE_MB = 3; static final RecyclePolicy DEFAULT_RECYCLE_POLICY = RecyclePolicy.ALWAYS; // Only used for Javadoc static final float DEFAULT_MEMORY_CACHE_HEAP_PERCENTAGE = DEFAULT_MEMORY_CACHE_HEAP_RATIO * 100; static final float MAX_MEMORY_CACHE_HEAP_PERCENTAGE = MAX_MEMORY_CACHE_HEAP_RATIO * 100; private static long getHeapSize() { return Runtime.getRuntime().maxMemory(); } private Context mContext; private boolean mDiskCacheEnabled; private File mDiskCacheLocation; private long mDiskCacheMaxSize; private boolean mMemoryCacheEnabled; private int mMemoryCacheMaxSize; private RecyclePolicy mRecyclePolicy; /** * @deprecated You should now use {@link Builder(Context)}. This is so that we can reliably * set up correctly. */ public Builder() { this(null); } public Builder(Context context) { mContext = context; // Disk Cache is disabled by default, but it's default size is set mDiskCacheMaxSize = DEFAULT_DISK_CACHE_MAX_SIZE_MB * MEGABYTE; // Memory Cache is enabled by default, with a small maximum size mMemoryCacheEnabled = true; mMemoryCacheMaxSize = DEFAULT_MEM_CACHE_MAX_SIZE_MB * MEGABYTE; mRecyclePolicy = DEFAULT_RECYCLE_POLICY; } /** * @return A new {@link BitmapLruCache} created with the arguments supplied to this * builder. */ public BitmapLruCache build() { final BitmapLruCache cache = new BitmapLruCache(mContext); if (isValidOptionsForMemoryCache()) { if (Constants.DEBUG) { Log.d("BitmapLruCache.Builder", "Creating Memory Cache"); } cache.setMemoryCache(new BitmapMemoryLruCache(mMemoryCacheMaxSize), mRecyclePolicy); } if (isValidOptionsForDiskCache()) { new AsyncTask<Void, Void, DiskLruCache>() { @Override protected DiskLruCache doInBackground(Void... params) { try { return DiskLruCache.open(mDiskCacheLocation, 0, 1, mDiskCacheMaxSize); } catch (IOException e) { e.printStackTrace(); return null; } } @Override protected void onPostExecute(DiskLruCache result) { cache.setDiskCache(result); } }.execute(); } return cache; } /** * Set whether the Disk Cache should be enabled. Defaults to {@code false}. * * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setDiskCacheEnabled(boolean enabled) { mDiskCacheEnabled = enabled; return this; } /** * Set the Disk Cache location. This location should be read-writeable. * * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setDiskCacheLocation(File location) { mDiskCacheLocation = location; return this; } /** * Set the maximum number of bytes the Disk Cache should use to store values. Defaults to * {@value #DEFAULT_DISK_CACHE_MAX_SIZE_MB}MB. * * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setDiskCacheMaxSize(long maxSize) { mDiskCacheMaxSize = maxSize; return this; } /** * Set whether the Memory Cache should be enabled. Defaults to {@code true}. * * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setMemoryCacheEnabled(boolean enabled) { mMemoryCacheEnabled = enabled; return this; } /** * Set the maximum number of bytes the Memory Cache should use to store values. Defaults to * {@value #DEFAULT_MEM_CACHE_MAX_SIZE_MB}MB. * * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setMemoryCacheMaxSize(int size) { mMemoryCacheMaxSize = size; return this; } /** * Sets the Memory Cache maximum size to be the default value of {@value * #DEFAULT_MEMORY_CACHE_HEAP_PERCENTAGE}% of heap size. * * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setMemoryCacheMaxSizeUsingHeapSize() { return setMemoryCacheMaxSizeUsingHeapSize(DEFAULT_MEMORY_CACHE_HEAP_RATIO); } /** * Sets the Memory Cache maximum size to be the given percentage of heap size. This is * capped at {@value #MAX_MEMORY_CACHE_HEAP_PERCENTAGE}% of the app heap size. * * @param percentageOfHeap - percentage of heap size. Valid values are 0.0 <= x <= {@value * #MAX_MEMORY_CACHE_HEAP_RATIO}. * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setMemoryCacheMaxSizeUsingHeapSize(float percentageOfHeap) { int size = Math .round(getHeapSize() * Math.min(percentageOfHeap, MAX_MEMORY_CACHE_HEAP_RATIO)); return setMemoryCacheMaxSize(size); } /** * Sets the recycle policy. This controls if {@link android.graphics.Bitmap#recycle()} is * called. * * @param recyclePolicy - New recycle policy, can not be null. * @return This Builder object to allow for chaining of calls to set methods. */ public Builder setRecyclePolicy(RecyclePolicy recyclePolicy) { if (null == recyclePolicy) { throw new IllegalArgumentException("The recycle policy can not be null"); } mRecyclePolicy = recyclePolicy; return this; } private boolean isValidOptionsForDiskCache() { if (mDiskCacheEnabled) { if (null == mDiskCacheLocation) { Log.i(Constants.LOG_TAG, "Disk Cache has been enabled, but no location given. Please call setDiskCacheLocation(...)"); return false; } else if (!mDiskCacheLocation.canWrite()) { throw new IllegalArgumentException("Disk Cache Location is not write-able"); } return true; } return false; } private boolean isValidOptionsForMemoryCache() { return mMemoryCacheEnabled && mMemoryCacheMaxSize > 0; } } static final class DiskCacheFlushRunnable implements Runnable { private final DiskLruCache mDiskCache; public DiskCacheFlushRunnable(DiskLruCache cache) { mDiskCache = cache; } public void run() { // Make sure we're running with a background priority Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); if (Constants.DEBUG) { Log.d(Constants.LOG_TAG, "Flushing Disk Cache"); } try { mDiskCache.flush(); } catch (IOException e) { e.printStackTrace(); } } } }