如何对位图进行缓存?
若只加载一张位图到 UI 中是很简单的事情,不过,如果要一次性加载多张大图片的话,事情就会变得很复杂。例如,使用 ListView, GridView 或 ViewPager 等显示多张图片,由于使用这些组件时,可以滚动屏幕,从而理论上可以加载无数张图片。
当使用上述组件显示图片时,若显示的内容随机用户滚动屏幕而不在屏幕上显示的时候,组件本身会重复利用子视图,这里内存的使用量便会降低。若没有长时间存在的引用时,垃圾回收器就会释放已经加载过的图片所占的内存。这当然是非常理想的情况。但是,程序为了让用户能快速而流畅的查看图片,并不希望当已经加载过的图片再次显示在屏幕上时,依然对这些图片再次进行处理。所以,对于再次显示已经加载过的图片,进行内存缓存或磁盘缓存就显得很有必要。
本文将引导你如何通过创建内存缓存和磁盘缓存来流畅的显示多张位图。
使用内存缓存
内存缓存提供了一种快速访问位图的方法,不过其代价是需要占用一些宝贵的应用内存。LruCache 类特别适合用于对位图进行缓存,将最近使用过的对象保存在强引用 LinkedHashMap 中,并且在缓存超过指定的大小时,清除掉最近不使用的位图。
注意:以前比较流行的内存缓存实现方法是使用 SoftReference 或 WeakReference 对位图进行缓存,但是现在已经不建议这么做了。从 Android 2.3(API Level 9)开始,垃圾回收器会更加主动的回收无效的 soft/weak references。此外,在 Android 3.0 (API Level 11)之前,返回的位图数据虽然保存在内存中,但是所占用的内存并不会按照可预知的方式将其释放掉,从而导致的潜在问题就是应用程序很容易就超过内存限制并导致程序崩溃。
为了给 LruCache 选取一个合适的大小,我们需要考虑如下一些因素:
余下的页面或应用需要占用多少内存?
屏幕上一次最多可以显示几张图片?需要几屏才能显示余下的图片?
手机的屏幕大小及分辨率是多少?与 Nexus S (hdpi)相比,像 Galaxy Nexus 这样的超清屏(xhdpi)需要更大的缓存才能显示下相同数量的图片。
位图的尺寸和配置是什么?并且需要占用多少内存?
是否经常需要访问图片?某些图片的访问频率是否会比其它图片的访问频率高?若是的话,可能需要专门针对这些图片进行内存缓存。或者使用多个 LruCache 对象来缓存不同的位图。
如何在质量和数量上保持平衡?例如,有时需要缓存大量低品质的图片,有时却需要在后台缓存一张高品质的图片。
并没有特定缓存的大小或公式适用于所有的应用,这完全取决于你对程序内存使用情况的分析。若缓存太小反而会导致额外的内存开销,起不到缓存的作用。若缓存太大可能会导致发生 java.lang.OutOfMemory 异常,让程序可用的内存变得很小。
下面的例子为你演示如何为位图建立缓存:
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); }
注意:在上面这个例子中,我们设置的缓存大小是应用所占内存的 1/8。对于普通分辨率或高分辨率的屏幕来说,这个缓存值大约是 4 MB (32/8)。对于 800x480 分辨率的屏幕来说,一屏幕的图片大约需要占用 1.5 MB (800*480*4 bytes) 内存。也就是说,整个缓存大约能缓存 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 这样的组件,很容易就将内存缓存占满。又如,应用程序可以被其它任务中断,例如,来了一个电话,此时后台任务会被中止,内存缓存自然也就无效了。若之后用户再次回到应用中,程序还得重新处理图片。
然而磁盘缓存却可以解决这个问题,它可以持久的缓存图片。当内存缓存无效的时候,磁盘缓存可以有效的帮忙你降低图片加载时间。当然,从磁盘获取图片要比从内存获取图片要慢,所以应用在后台线程加载图片,因为磁盘读写时间是不可预知的。
注意:若频繁的访问图片,那么 ContentProvider 可能是更加合适的地方用于缓存图片。例如,在制作图像画廊这样的应用程序中。
在下面的例子中,我们使用了 DiskLruCache 实现了从磁盘缓存读取图片。下面这个例子是前面内存缓存例子的升级版,除了可以从内存缓存读取图片外,还添加了从磁盘缓存读取图片的功能:
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); }
注意:即使初始化磁盘缓存需要磁盘操作权限,但是这也不应该在主线程中进行。这就意味着程序有可能在初始化操作之前就对缓存进行访问。为了解决这个问题,我们在上面的实现中,使用了一个同步锁对象来保证直到缓存被初始化之后,才可以访问磁盘缓存。
虽然检查内存缓存是在 UI 线程中进行的,但是对磁盘缓存的检查却应该放到后台线程去做。要记住,对磁盘的操作永远不要发生在主 UI 线程中。当图片处理完成后,最终的位图应该同时被添加到内存缓存和磁盘缓存中以备后用。
处理配置变化
在程序运行的过程中,配置是会发生变化的,例如,屏幕方向的变化会导致系统销毁当前的页面并重新应用新的配置后重启原页面(关于类似行为的详细说明,请参阅如下文章:Handling Runtime Changes http://developer.android.com/guide/topics/resources/runtime-changes.html)。为了让用户有一个更快更平滑的使用体验,我们并不希望当配置发生变化时,就得重新处理所有的图片。
幸运的是,我们前面已经讲了如何使用内存缓存,使用它就可以完美的解决这个问题。当使用 Fragment 时,我们可以调用 setRetainInstance(true) 方法,将内存缓存传入新的 activity 实例中。当新的 activity 被重新创建时,被保持的 Fragment 会被重新添加到页面中,然后我们就可以重新访问缓存对象了,从而可以快速的在页面上显示图片。
下面这个例子是关于使用 Fragment,当配置发生变化时,如何保持一个 LruCache 对象:
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... RetainFragment mRetainFragment = RetainFragment.findOrCreateRetainFragment(getFragmentManager()); mMemoryCache = RetainFragment.mRetainedCache; if (mMemoryCache == null) { mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { ... // Initialize cache here as usual } mRetainFragment.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(); } return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setRetainInstance(true); } }
为了验证上面这个例子,你可以改变手机的方向来查看是否使用了被保持的 Fragment。你可能注意到了,当手机方向发生变化时,图片几乎是瞬间就从内存缓存中被重新加载了。任何没有被内存缓存的图片,都可能被磁盘进行缓存,若也没有被磁盘缓存,那么图片就会从非缓存对象中被加载。