注:本文翻译自Google官方的Android Developers Training文档,译者技术一般,由于喜爱安卓而产生了翻译的念头,纯属个人兴趣爱好。
原文链接:http://developer.android.com/training/displaying-bitmaps/cache-bitmap.html
向你的应用中加载一个单一的位图是很直接的行为,然而当你需要一次性加载一组图像的大集合时,事情会变得更加复杂。在很多情况下(比如对于ListView,GridView或者ViewPager),屏幕上显示的图片以及会因加载动作而进入屏幕的图片,这两者的总数加起来是无法限制的。
通过对移除屏幕区域的子View进行回收,可以让这类组件内存使用降低下来。垃圾回收器也会对那些假定你将不再需要的引用对象进行回收和释放。这些措施都很好,但是为了保持流畅地和快速地加载UI,你会希望避免多次连续地处理这些图片,当它们回到屏幕区域中来时。一个存储或磁盘缓存可以在这方面提供帮助,它可以让组件迅速的重新加载处理过的图片。
这节课将会教你使用一个存储和磁盘缓存,来提升你的UI加载多个图片时的响应和流畅性。
一). 使用一个内存缓存
一个内存缓存提供了快速访问位图的方法,但它的代价是需要消耗掉珍贵的应用内存。LruCache类(在Support Library也有,可以支持到API Level 4及以上的平台)对于缓存图片来说尤其适合,它能将最近引用的对象存储在一个基于强引用的LinkedHashMap中,并且在缓存超出它的特定大小后,将最近最迟被引用的对象去除。
Note:
在过去,一个流行的内存缓存实现是SoftReference或者WeakReference的位图缓存,然而,这并不是推荐的实现方法。从Android 2.3(API Level 9)开始,垃圾回收器对于软引用和弱引用的回收变得更加地激进,从而使得它们的效用正在下降。从Android 3.0(API Level 11)开始,存储于本机内存的位图数据并不是以一个可预测的形式释放的,这就有潜在的可能性导致一个应用超出它的内存限制进而崩溃。
为了为一个LruCache选择合适的大小,一些因素需要考量,例如:
- 你的activity或应用剩余的存储压力是如何的?
- 同一时间有多少应用显示在屏幕上?有多少需要准备就绪显示到屏幕上?
- 设备的屏幕的尺寸和密度的大小是多少?一个极高密度的屏幕(xhdpi)的设备(比如Galaxy Nexus)可能相对于其他比如hdpi的设备(比如Nexus S)需要更大的缓存来容纳同样数量的照片。
- 位图文件的尺寸和属性是怎样的,需要消耗多少大的内存空间?
- 图片被访问的频率高不高?有没有一些图片被访问你的频率比其它的要高?如果有,也许你会期望让这些项目一直保留在内存或者为不同被访问频率的图片设置多组LruCache对象。
- 能否做到数量和质量间的平衡?有些时候存储大量低质量的图片时很有用的,而将更高质量的图片加载任务放在后台执行。
没有什么特定的大小或者公式能够适合所有的应用,你应该自己分析并决定你的用法和解决方案。一个过小的缓存会导致大量无益处的执行操作,而太大的缓存会导致java.lang.OutOfMemory异常,或者让你剩下的应用只有有限的存储来工作。
下面是一个LruCache配置的样例代码:
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... // Get max available VM memory, exceeding this amount will throw an // OutOfMemory exception. Stored in kilobytes as LruCache takes an // int in its constructor. final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // Use 1/8th of the available memory for this memory cache. final int cacheSize = maxMemory / 8; mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { // The cache size will be measured in kilobytes rather than // number of items. return bitmap.getByteCount() / 1024; } }; ... } public void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } } public Bitmap getBitmapFromMemCache(String key) { return mMemoryCache.get(key); }
Note:
在这个例子中,八分之一的应用内存被分配给了我们的缓存。在一个标准或hdpi的设备上,这大约为4MB左右(32/8)。一个全屏的GridView,在一个分辨率为800x480的设备上,充满图片之后,会使用掉大约1.5MB(800*480*4字节),所以这个缓存至少大约能放下2.5个页面数量的图片在内存中。
当把一个图片加载到ImageView时,LruCache会先进行检查。如果找到了一个对应的条目,那么它将会立即用来更新ImageView,否则的话一个后台线程会启动并处理该图像:
public void loadBitmap(int resId, ImageView imageView) { final String imageKey = String.valueOf(resId); final Bitmap bitmap = getBitmapFromMemCache(imageKey); if (bitmap != null) { mImageView.setImageBitmap(bitmap); } else { mImageView.setImageResource(R.drawable.image_placeholder); BitmapWorkerTask task = new BitmapWorkerTask(mImageView); task.execute(resId); } }
BitmapWorkerTask也需要更新,并将相应字段添加到内存缓存中:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { ... // Decode image in background. @Override protected Bitmap doInBackground(Integer... params) { final Bitmap bitmap = decodeSampledBitmapFromResource( getResources(), params[0], 100, 100)); addBitmapToMemoryCache(String.valueOf(params[0]), bitmap); return bitmap; } ... }
二). 使用磁盘缓存
一个内存缓存对于加速访问最近查看的位图是很有效果的,然而你不能依赖于它,因为无法做到所有图片都放置在该缓存中。如GridView这样的组件其较大的数据集可以迅速填充内存缓存。同时,你的应用可能会被另一个事务打断,如一个来电,此时在后台中,它可能会被杀掉,这样的话内存缓存就被销毁了。一旦这个用户恢复了,你的应用不得不重新处理这些图片。
一个磁盘缓存可以在这种情况下发挥效用,它能保持处理过的位图文件,并减少在内存缓存中不再可以获得的加载时间。当然,从磁盘获取图片比从内存获取图片要慢,由于磁盘读写的速度有很多不确定性,故应该在后台线程中执行。
Note:
一个ContentProvider是一个比较合适的存储缓存图片的地方,对于那些访问频率较高的图片来说,例如在图库的应用中。
下面的代码使用了DiskLruCache的实现,它来自于Android source。并且添加到内存缓存的代码中,更新其功能:
private DiskLruCache mDiskLruCache; private final Object mDiskCacheLock = new Object(); private boolean mDiskCacheStarting = true; private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB private static final String DISK_CACHE_SUBDIR = "thumbnails"; @Override protected void onCreate(Bundle savedInstanceState) { ... // Initialize memory cache ... // Initialize disk cache on background thread File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR); new InitDiskCacheTask().execute(cacheDir); ... } class InitDiskCacheTask extends AsyncTask<File, Void, Void> { @Override protected Void doInBackground(File... params) { synchronized (mDiskCacheLock) { File cacheDir = params[0]; mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE); mDiskCacheStarting = false; // Finished initialization mDiskCacheLock.notifyAll(); // Wake any waiting threads } return null; } } class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { ... // Decode image in background. @Override protected Bitmap doInBackground(Integer... params) { final String imageKey = String.valueOf(params[0]); // Check disk cache in background thread Bitmap bitmap = getBitmapFromDiskCache(imageKey); if (bitmap == null) { // Not found in disk cache // Process as normal final Bitmap bitmap = decodeSampledBitmapFromResource( getResources(), params[0], 100, 100)); } // Add final bitmap to caches addBitmapToCache(imageKey, bitmap); return bitmap; } ... } public void addBitmapToCache(String key, Bitmap bitmap) { // Add to memory cache as before if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } // Also add to disk cache synchronized (mDiskCacheLock) { if (mDiskLruCache != null && mDiskLruCache.get(key) == null) { mDiskLruCache.put(key, bitmap); } } } public Bitmap getBitmapFromDiskCache(String key) { synchronized (mDiskCacheLock) { // Wait while disk cache is started from background thread while (mDiskCacheStarting) { try { mDiskCacheLock.wait(); } catch (InterruptedException e) {} } if (mDiskLruCache != null) { return mDiskLruCache.get(key); } } return null; } // Creates a unique subdirectory of the designated app cache directory. Tries to use external // but if not mounted, falls back on internal storage. public static File getDiskCacheDir(Context context, String uniqueName) { // Check if media is mounted or storage is built-in, if so, try and use external cache dir // otherwise use internal cache dir final String cachePath = Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() : context.getCacheDir().getPath(); return new File(cachePath + File.separator + uniqueName); }
Note:
因为初始化磁盘缓存也需要磁盘操作所以它也不能再主线程中执行。然而,这其实意味着缓存有可能在还未初始化的时候就被访问了。为了解决这个问题,在上面的代码实现中,一个信号量(lock)保证了应用会在初始化完成之后才去读取缓存。
虽然内存缓存在UI线程中检查,磁盘缓存是在后台线程中检查。磁盘操作不应该发生在UI线程中执行。当图片处理完成了,最后位图将会同时添加到内存和磁盘缓存中,以备将来使用。
三). 处理配置变更
运行时的配置变更,如屏幕方向变化,会导致Android销毁当前activity,并以新的配置重启activity(可以阅读:Handling Runtime Changes)。你一定希望避免重复处理图像,这样的话用户就能在配置改变时,拥有平滑快速地使用体验。
幸运的是,你在之前的章节中,已经拥有了一个很出色的图片内存缓存了。这个缓存可以通过使用一个Fragment(该Fragment通过调用setRetainInstance(true)将其自身保留),传递给新的activity实例。在activity重新创建之后,这个保留的Fragment就完成了重新依附(reattach),同时你获得了现有缓存对象的访问,允许图片快速提取并填充到ImageView对象中。
下面是一个使用Fragment,在配置变更发生时保留LruCache对象的例子:
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... RetainFragment retainFragment = RetainFragment.findOrCreateRetainFragment(getFragmentManager()); mMemoryCache = retainFragment.mRetainedCache; if (mMemoryCache == null) { mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { ... // Initialize cache here as usual } retainFragment.mRetainedCache = mMemoryCache; } ... } class RetainFragment extends Fragment { private static final String TAG = "RetainFragment"; public LruCache<String, Bitmap> mRetainedCache; public RetainFragment() {} public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) { RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG); if (fragment == null) { fragment = new RetainFragment(); fm.beginTransaction().add(fragment, TAG).commit(); } return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setRetainInstance(true); } }
要测试这段代码,尝试分别在保留Fragment和不保留Fragment的情况下旋转设备。你应该能注意到当保留了缓存时,图片填充到activity时几乎没有延迟。那些在内存缓存中找不到的图片一般都会在磁盘缓存中找到,如果找不到,这些图片就会像平常一样处理。