1/* 2 * Copyright (C) 2012 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.example.android.bitmapfun.util; 18 19import com.example.android.bitmapfun.BuildConfig; 20 21import android.annotation.TargetApi; 22import android.content.Context; 23import android.graphics.Bitmap; 24import android.graphics.Bitmap.CompressFormat; 25import android.graphics.BitmapFactory; 26import android.graphics.drawable.BitmapDrawable; 27import android.os.Bundle; 28import android.os.Environment; 29import android.os.StatFs; 30import android.support.v4.app.Fragment; 31import android.support.v4.app.FragmentManager; 32import android.support.v4.util.LruCache; 33import android.util.Log; 34 35import java.io.File; 36import java.io.FileDescriptor; 37import java.io.FileInputStream; 38import java.io.IOException; 39import java.io.InputStream; 40import java.io.OutputStream; 41import java.lang.ref.SoftReference; 42import java.security.MessageDigest; 43import java.security.NoSuchAlgorithmException; 44import java.util.HashSet; 45import java.util.Iterator; 46 47/** 48 * This class handles disk and memory caching of bitmaps in conjunction with the 49 * {@link ImageWorker} class and its subclasses. Use 50 * {@link ImageCache#getInstance(FragmentManager, ImageCacheParams)} to get an instance of this 51 * class, although usually a cache should be added directly to an {@link ImageWorker} by calling 52 * {@link ImageWorker#addImageCache(FragmentManager, ImageCacheParams)}. 53 */ 54public class ImageCache { 55 private static final String TAG = "ImageCache"; 56 57 // Default memory cache size in kilobytes 58 private static final int DEFAULT_MEM_CACHE_SIZE = 1024 * 5; // 5MB 59 60 // Default disk cache size in bytes 61 private static final int DEFAULT_DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB 62 63 // Compression settings when writing images to disk cache 64 private static final CompressFormat DEFAULT_COMPRESS_FORMAT = CompressFormat.JPEG; 65 private static final int DEFAULT_COMPRESS_QUALITY = 70; 66 private static final int DISK_CACHE_INDEX = 0; 67 68 // Constants to easily toggle various caches 69 private static final boolean DEFAULT_MEM_CACHE_ENABLED = true; 70 private static final boolean DEFAULT_DISK_CACHE_ENABLED = true; 71 private static final boolean DEFAULT_INIT_DISK_CACHE_ON_CREATE = false; 72 73 private DiskLruCache mDiskLruCache; 74 private LruCache<String, BitmapDrawable> mMemoryCache; 75 private ImageCacheParams mCacheParams; 76 private final Object mDiskCacheLock = new Object(); 77 private boolean mDiskCacheStarting = true; 78 79 private HashSet<SoftReference<Bitmap>> mReusableBitmaps; 80 81 /** 82 * Create a new ImageCache object using the specified parameters. This should not be 83 * called directly by other classes, instead use 84 * {@link ImageCache#getInstance(FragmentManager, ImageCacheParams)} to fetch an ImageCache 85 * instance. 86 * 87 * @param cacheParams The cache parameters to use to initialize the cache 88 */ 89 private ImageCache(ImageCacheParams cacheParams) { 90 init(cacheParams); 91 } 92 93 /** 94 * Return an {@link ImageCache} instance. A {@link RetainFragment} is used to retain the 95 * ImageCache object across configuration changes such as a change in device orientation. 96 * 97 * @param fragmentManager The fragment manager to use when dealing with the retained fragment. 98 * @param cacheParams The cache parameters to use if the ImageCache needs instantiation. 99 * @return An existing retained ImageCache object or a new one if one did not exist 100 */ 101 public static ImageCache getInstance( 102 FragmentManager fragmentManager, ImageCacheParams cacheParams) { 103 104 // Search for, or create an instance of the non-UI RetainFragment 105 final RetainFragment mRetainFragment = findOrCreateRetainFragment(fragmentManager); 106 107 // See if we already have an ImageCache stored in RetainFragment 108 ImageCache imageCache = (ImageCache) mRetainFragment.getObject(); 109 110 // No existing ImageCache, create one and store it in RetainFragment 111 if (imageCache == null) { 112 imageCache = new ImageCache(cacheParams); 113 mRetainFragment.setObject(imageCache); 114 } 115 116 return imageCache; 117 } 118 119 /** 120 * Initialize the cache, providing all parameters. 121 * 122 * @param cacheParams The cache parameters to initialize the cache 123 */ 124 private void init(ImageCacheParams cacheParams) { 125 mCacheParams = cacheParams; 126 127 // Set up memory cache 128 if (mCacheParams.memoryCacheEnabled) { 129 if (BuildConfig.DEBUG) { 130 Log.d(TAG, "Memory cache created (size = " + mCacheParams.memCacheSize + ")"); 131 } 132 133 // If we're running on Honeycomb or newer, then 134 if (Utils.hasHoneycomb()) { 135 mReusableBitmaps = new HashSet<SoftReference<Bitmap>>(); 136 } 137 138 mMemoryCache = new LruCache<String, BitmapDrawable>(mCacheParams.memCacheSize) { 139 140 /** 141 * Notify the removed entry that is no longer being cached 142 */ 143 @Override 144 protected void entryRemoved(boolean evicted, String key, 145 BitmapDrawable oldValue, BitmapDrawable newValue) { 146 if (RecyclingBitmapDrawable.class.isInstance(oldValue)) { 147 // The removed entry is a recycling drawable, so notify it 148 // that it has been removed from the memory cache 149 ((RecyclingBitmapDrawable) oldValue).setIsCached(false); 150 } else { 151 // The removed entry is a standard BitmapDrawable 152 153 if (Utils.hasHoneycomb()) { 154 // We're running on Honeycomb or later, so add the bitmap 155 // to a SoftRefrence set for possible use with inBitmap later 156 mReusableBitmaps.add(new SoftReference<Bitmap>(oldValue.getBitmap())); 157 } 158 } 159 } 160 161 /** 162 * Measure item size in kilobytes rather than units which is more practical 163 * for a bitmap cache 164 */ 165 @Override 166 protected int sizeOf(String key, BitmapDrawable value) { 167 final int bitmapSize = getBitmapSize(value) / 1024; 168 return bitmapSize == 0 ? 1 : bitmapSize; 169 } 170 }; 171 } 172 173 // By default the disk cache is not initialized here as it should be initialized 174 // on a separate thread due to disk access. 175 if (cacheParams.initDiskCacheOnCreate) { 176 // Set up disk cache 177 initDiskCache(); 178 } 179 } 180 181 /** 182 * Initializes the disk cache. Note that this includes disk access so this should not be 183 * executed on the main/UI thread. By default an ImageCache does not initialize the disk 184 * cache when it is created, instead you should call initDiskCache() to initialize it on a 185 * background thread. 186 */ 187 public void initDiskCache() { 188 // Set up disk cache 189 synchronized (mDiskCacheLock) { 190 if (mDiskLruCache == null || mDiskLruCache.isClosed()) { 191 File diskCacheDir = mCacheParams.diskCacheDir; 192 if (mCacheParams.diskCacheEnabled && diskCacheDir != null) { 193 if (!diskCacheDir.exists()) { 194 diskCacheDir.mkdirs(); 195 } 196 if (getUsableSpace(diskCacheDir) > mCacheParams.diskCacheSize) { 197 try { 198 mDiskLruCache = DiskLruCache.open( 199 diskCacheDir, 1, 1, mCacheParams.diskCacheSize); 200 if (BuildConfig.DEBUG) { 201 Log.d(TAG, "Disk cache initialized"); 202 } 203 } catch (final IOException e) { 204 mCacheParams.diskCacheDir = null; 205 Log.e(TAG, "initDiskCache - " + e); 206 } 207 } 208 } 209 } 210 mDiskCacheStarting = false; 211 mDiskCacheLock.notifyAll(); 212 } 213 } 214 215 /** 216 * Adds a bitmap to both memory and disk cache. 217 * @param data Unique identifier for the bitmap to store 218 * @param value The bitmap drawable to store 219 */ 220 public void addBitmapToCache(String data, BitmapDrawable value) { 221 if (data == null || value == null) { 222 return; 223 } 224 225 // Add to memory cache 226 if (mMemoryCache != null) { 227 if (RecyclingBitmapDrawable.class.isInstance(value)) { 228 // The removed entry is a recycling drawable, so notify it 229 // that it has been added into the memory cache 230 ((RecyclingBitmapDrawable) value).setIsCached(true); 231 } 232 mMemoryCache.put(data, value); 233 } 234 235 synchronized (mDiskCacheLock) { 236 // Add to disk cache 237 if (mDiskLruCache != null) { 238 final String key = hashKeyForDisk(data); 239 OutputStream out = null; 240 try { 241 DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); 242 if (snapshot == null) { 243 final DiskLruCache.Editor editor = mDiskLruCache.edit(key); 244 if (editor != null) { 245 out = editor.newOutputStream(DISK_CACHE_INDEX); 246 value.getBitmap().compress( 247 mCacheParams.compressFormat, mCacheParams.compressQuality, out); 248 editor.commit(); 249 out.close(); 250 } 251 } else { 252 snapshot.getInputStream(DISK_CACHE_INDEX).close(); 253 } 254 } catch (final IOException e) { 255 Log.e(TAG, "addBitmapToCache - " + e); 256 } catch (Exception e) { 257 Log.e(TAG, "addBitmapToCache - " + e); 258 } finally { 259 try { 260 if (out != null) { 261 out.close(); 262 } 263 } catch (IOException e) {} 264 } 265 } 266 } 267 } 268 269 /** 270 * Get from memory cache. 271 * 272 * @param data Unique identifier for which item to get 273 * @return The bitmap drawable if found in cache, null otherwise 274 */ 275 public BitmapDrawable getBitmapFromMemCache(String data) { 276 BitmapDrawable memValue = null; 277 278 if (mMemoryCache != null) { 279 memValue = mMemoryCache.get(data); 280 } 281 282 if (BuildConfig.DEBUG && memValue != null) { 283 Log.d(TAG, "Memory cache hit"); 284 } 285 286 return memValue; 287 } 288 289 /** 290 * Get from disk cache. 291 * 292 * @param data Unique identifier for which item to get 293 * @return The bitmap if found in cache, null otherwise 294 */ 295 public Bitmap getBitmapFromDiskCache(String data) { 296 final String key = hashKeyForDisk(data); 297 Bitmap bitmap = null; 298 299 synchronized (mDiskCacheLock) { 300 while (mDiskCacheStarting) { 301 try { 302 mDiskCacheLock.wait(); 303 } catch (InterruptedException e) {} 304 } 305 if (mDiskLruCache != null) { 306 InputStream inputStream = null; 307 try { 308 final DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); 309 if (snapshot != null) { 310 if (BuildConfig.DEBUG) { 311 Log.d(TAG, "Disk cache hit"); 312 } 313 inputStream = snapshot.getInputStream(DISK_CACHE_INDEX); 314 if (inputStream != null) { 315 FileDescriptor fd = ((FileInputStream) inputStream).getFD(); 316 317 // Decode bitmap, but we don't want to sample so give 318 // MAX_VALUE as the target dimensions 319 bitmap = ImageResizer.decodeSampledBitmapFromDescriptor( 320 fd, Integer.MAX_VALUE, Integer.MAX_VALUE, this); 321 } 322 } 323 } catch (final IOException e) { 324 Log.e(TAG, "getBitmapFromDiskCache - " + e); 325 } finally { 326 try { 327 if (inputStream != null) { 328 inputStream.close(); 329 } 330 } catch (IOException e) {} 331 } 332 } 333 return bitmap; 334 } 335 } 336 337 /** 338 * @param options - BitmapFactory.Options with out* options populated 339 * @return Bitmap that case be used for inBitmap 340 */ 341 protected Bitmap getBitmapFromReusableSet(BitmapFactory.Options options) { 342 Bitmap bitmap = null; 343 344 if (mReusableBitmaps != null && !mReusableBitmaps.isEmpty()) { 345 final Iterator<SoftReference<Bitmap>> iterator = mReusableBitmaps.iterator(); 346 Bitmap item; 347 348 while (iterator.hasNext()) { 349 item = iterator.next().get(); 350 351 if (null != item && item.isMutable()) { 352 // Check to see it the item can be used for inBitmap 353 if (canUseForInBitmap(item, options)) { 354 bitmap = item; 355 356 // Remove from reusable set so it can't be used again 357 iterator.remove(); 358 break; 359 } 360 } else { 361 // Remove from the set if the reference has been cleared. 362 iterator.remove(); 363 } 364 } 365 } 366 367 return bitmap; 368 } 369 370 /** 371 * Clears both the memory and disk cache associated with this ImageCache object. Note that 372 * this includes disk access so this should not be executed on the main/UI thread. 373 */ 374 public void clearCache() { 375 if (mMemoryCache != null) { 376 mMemoryCache.evictAll(); 377 if (BuildConfig.DEBUG) { 378 Log.d(TAG, "Memory cache cleared"); 379 } 380 } 381 382 synchronized (mDiskCacheLock) { 383 mDiskCacheStarting = true; 384 if (mDiskLruCache != null && !mDiskLruCache.isClosed()) { 385 try { 386 mDiskLruCache.delete(); 387 if (BuildConfig.DEBUG) { 388 Log.d(TAG, "Disk cache cleared"); 389 } 390 } catch (IOException e) { 391 Log.e(TAG, "clearCache - " + e); 392 } 393 mDiskLruCache = null; 394 initDiskCache(); 395 } 396 } 397 } 398 399 /** 400 * Flushes the disk cache associated with this ImageCache object. Note that this includes 401 * disk access so this should not be executed on the main/UI thread. 402 */ 403 public void flush() { 404 synchronized (mDiskCacheLock) { 405 if (mDiskLruCache != null) { 406 try { 407 mDiskLruCache.flush(); 408 if (BuildConfig.DEBUG) { 409 Log.d(TAG, "Disk cache flushed"); 410 } 411 } catch (IOException e) { 412 Log.e(TAG, "flush - " + e); 413 } 414 } 415 } 416 } 417 418 /** 419 * Closes the disk cache associated with this ImageCache object. Note that this includes 420 * disk access so this should not be executed on the main/UI thread. 421 */ 422 public void close() { 423 synchronized (mDiskCacheLock) { 424 if (mDiskLruCache != null) { 425 try { 426 if (!mDiskLruCache.isClosed()) { 427 mDiskLruCache.close(); 428 mDiskLruCache = null; 429 if (BuildConfig.DEBUG) { 430 Log.d(TAG, "Disk cache closed"); 431 } 432 } 433 } catch (IOException e) { 434 Log.e(TAG, "close - " + e); 435 } 436 } 437 } 438 } 439 440 /** 441 * A holder class that contains cache parameters. 442 */ 443 public static class ImageCacheParams { 444 public int memCacheSize = DEFAULT_MEM_CACHE_SIZE; 445 public int diskCacheSize = DEFAULT_DISK_CACHE_SIZE; 446 public File diskCacheDir; 447 public CompressFormat compressFormat = DEFAULT_COMPRESS_FORMAT; 448 public int compressQuality = DEFAULT_COMPRESS_QUALITY; 449 public boolean memoryCacheEnabled = DEFAULT_MEM_CACHE_ENABLED; 450 public boolean diskCacheEnabled = DEFAULT_DISK_CACHE_ENABLED; 451 public boolean initDiskCacheOnCreate = DEFAULT_INIT_DISK_CACHE_ON_CREATE; 452 453 /** 454 * Create a set of image cache parameters that can be provided to 455 * {@link ImageCache#getInstance(FragmentManager, ImageCacheParams)} or 456 * {@link ImageWorker#addImageCache(FragmentManager, ImageCacheParams)}. 457 * @param context A context to use. 458 * @param diskCacheDirectoryName A unique subdirectory name that will be appended to the 459 * application cache directory. Usually "cache" or "images" 460 * is sufficient. 461 */ 462 public ImageCacheParams(Context context, String diskCacheDirectoryName) { 463 diskCacheDir = getDiskCacheDir(context, diskCacheDirectoryName); 464 } 465 466 /** 467 * Sets the memory cache size based on a percentage of the max available VM memory. 468 * Eg. setting percent to 0.2 would set the memory cache to one fifth of the available 469 * memory. Throws {@link IllegalArgumentException} if percent is < 0.05 or > .8. 470 * memCacheSize is stored in kilobytes instead of bytes as this will eventually be passed 471 * to construct a LruCache which takes an int in its constructor. 472 * 473 * This value should be chosen carefully based on a number of factors 474 * Refer to the corresponding Android Training class for more discussion: 475 * http://developer.android.com/training/displaying-bitmaps/ 476 * 477 * @param percent Percent of available app memory to use to size memory cache 478 */ 479 public void setMemCacheSizePercent(float percent) { 480 if (percent < 0.05f || percent > 0.8f) { 481 throw new IllegalArgumentException("setMemCacheSizePercent - percent must be " 482 + "between 0.05 and 0.8 (inclusive)"); 483 } 484 memCacheSize = Math.round(percent * Runtime.getRuntime().maxMemory() / 1024); 485 } 486 } 487 488 /** 489 * @param candidate - Bitmap to check 490 * @param targetOptions - Options that have the out* value populated 491 * @return true if <code>candidate</code> can be used for inBitmap re-use with 492 * <code>targetOptions</code> 493 */ 494 private static boolean canUseForInBitmap( 495 Bitmap candidate, BitmapFactory.Options targetOptions) { 496 int width = targetOptions.outWidth / targetOptions.inSampleSize; 497 int height = targetOptions.outHeight / targetOptions.inSampleSize; 498 499 return candidate.getWidth() == width && candidate.getHeight() == height; 500 } 501 502 /** 503 * Get a usable cache directory (external if available, internal otherwise). 504 * 505 * @param context The context to use 506 * @param uniqueName A unique directory name to append to the cache dir 507 * @return The cache dir 508 */ 509 public static File getDiskCacheDir(Context context, String uniqueName) { 510 // Check if media is mounted or storage is built-in, if so, try and use external cache dir 511 // otherwise use internal cache dir 512 final String cachePath = 513 Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || 514 !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() : 515 context.getCacheDir().getPath(); 516 517 return new File(cachePath + File.separator + uniqueName); 518 } 519 520 /** 521 * A hashing method that changes a string (like a URL) into a hash suitable for using as a 522 * disk filename. 523 */ 524 public static String hashKeyForDisk(String key) { 525 String cacheKey; 526 try { 527 final MessageDigest mDigest = MessageDigest.getInstance("MD5"); 528 mDigest.update(key.getBytes()); 529 cacheKey = bytesToHexString(mDigest.digest()); 530 } catch (NoSuchAlgorithmException e) { 531 cacheKey = String.valueOf(key.hashCode()); 532 } 533 return cacheKey; 534 } 535 536 private static String bytesToHexString(byte[] bytes) { 537 // http://stackoverflow.com/questions/332079 538 StringBuilder sb = new StringBuilder(); 539 for (int i = 0; i < bytes.length; i++) { 540 String hex = Integer.toHexString(0xFF & bytes[i]); 541 if (hex.length() == 1) { 542 sb.append('0'); 543 } 544 sb.append(hex); 545 } 546 return sb.toString(); 547 } 548 549 /** 550 * Get the size in bytes of a bitmap in a BitmapDrawable. 551 * @param value 552 * @return size in bytes 553 */ 554 @TargetApi(12) 555 public static int getBitmapSize(BitmapDrawable value) { 556 Bitmap bitmap = value.getBitmap(); 557 558 if (Utils.hasHoneycombMR1()) { 559 return bitmap.getByteCount(); 560 } 561 // Pre HC-MR1 562 return bitmap.getRowBytes() * bitmap.getHeight(); 563 } 564 565 /** 566 * Check if external storage is built-in or removable. 567 * 568 * @return True if external storage is removable (like an SD card), false 569 * otherwise. 570 */ 571 @TargetApi(9) 572 public static boolean isExternalStorageRemovable() { 573 if (Utils.hasGingerbread()) { 574 return Environment.isExternalStorageRemovable(); 575 } 576 return true; 577 } 578 579 /** 580 * Get the external app cache directory. 581 * 582 * @param context The context to use 583 * @return The external cache dir 584 */ 585 @TargetApi(8) 586 public static File getExternalCacheDir(Context context) { 587 if (Utils.hasFroyo()) { 588 return context.getExternalCacheDir(); 589 } 590 591 // Before Froyo we need to construct the external cache dir ourselves 592 final String cacheDir = "/Android/data/" + context.getPackageName() + "/cache/"; 593 return new File(Environment.getExternalStorageDirectory().getPath() + cacheDir); 594 } 595 596 /** 597 * Check how much usable space is available at a given path. 598 * 599 * @param path The path to check 600 * @return The space available in bytes 601 */ 602 @TargetApi(9) 603 public static long getUsableSpace(File path) { 604 if (Utils.hasGingerbread()) { 605 return path.getUsableSpace(); 606 } 607 final StatFs stats = new StatFs(path.getPath()); 608 return (long) stats.getBlockSize() * (long) stats.getAvailableBlocks(); 609 } 610 611 /** 612 * Locate an existing instance of this Fragment or if not found, create and 613 * add it using FragmentManager. 614 * 615 * @param fm The FragmentManager manager to use. 616 * @return The existing instance of the Fragment or the new instance if just 617 * created. 618 */ 619 private static RetainFragment findOrCreateRetainFragment(FragmentManager fm) { 620 // Check to see if we have retained the worker fragment. 621 RetainFragment mRetainFragment = (RetainFragment) fm.findFragmentByTag(TAG); 622 623 // If not retained (or first time running), we need to create and add it. 624 if (mRetainFragment == null) { 625 mRetainFragment = new RetainFragment(); 626 fm.beginTransaction().add(mRetainFragment, TAG).commitAllowingStateLoss(); 627 } 628 629 return mRetainFragment; 630 } 631 632 /** 633 * A simple non-UI Fragment that stores a single Object and is retained over configuration 634 * changes. It will be used to retain the ImageCache object. 635 */ 636 public static class RetainFragment extends Fragment { 637 private Object mObject; 638 639 /** 640 * Empty constructor as per the Fragment documentation 641 */ 642 public RetainFragment() {} 643 644 @Override 645 public void onCreate(Bundle savedInstanceState) { 646 super.onCreate(savedInstanceState); 647 648 // Make sure this Fragment is retained over a configuration change 649 setRetainInstance(true); 650 } 651 652 /** 653 * Store a single object in this Fragment. 654 * 655 * @param object The object to store 656 */ 657 public void setObject(Object object) { 658 mObject = object; 659 } 660 661 /** 662 * Get the stored object. 663 * 664 * @return The stored object 665 */ 666 public Object getObject() { 667 return mObject; 668 } 669 } 670 671} 672