缓存图片
——Android官网原文翻译
加载一张图片到UI现在看来很简单,但是一旦当我们需要加载大量图片时,事情就会变得很复杂。在很多情况下(诸如使用像ListView,GridView,ViewPager等UI组件时),所有在屏幕显示的以及将要通过滚动在屏幕中显示的图片可能是无限的。
内存使用率会被子控件回收机制所限制住,假设您不想持久的保存图片的引用的话,GC也会释放您已经加载的图片。这些看起来都还不错,但是为了保持UI的流动性和快速加载特性,您会想要避免当这些view重新滚动回屏幕时重新加载图片。通常使用内存和外存作为缓存会对此有所帮助。
这一课将会向您介绍怎样使用内存和外存缓存图片,在加载大量图片时提升UI的响应能力和流动性。
使用内存缓存
内存缓存提供了对图片的快速读写并消耗应用程序的可用内存空间。LruCache类(在SupportLibrary中,可以支持到API Level4)是非常适合缓存图片这项任务的,它使用LinkedHashMap保存最近使用过的对象的强引用,并且在缓存超出预设的大小之前删除最近最少使用的成员。
注意:在过去,常用的内存缓存的实现方式是使用SoftReference或者WeakReference,但是我们并不推荐这样做。从Android2.3(API Level 9)开始,GC清理弱引用更加的具有侵略性,以至于它们几乎是不可用的。而且,Android3.0(API Level 11)以前,底层的Bitmap数据被存放在本地内存中,这些内存并不会以某种可以预期的方式释放,这会潜在的导致程序超出内存限制而崩溃。
为LruCache选择一个合适的大小,有些因素应该被考虑到,例如:
1.应用程序或者activity的剩余部分是怎样集中的?
2.屏幕中同时会显示多少图片?有多少图片待显示?
3.设备的屏幕尺寸和分辨率是多少?对于Galgxy Nexus这种非常高分辨率的设备,同样数量的图片会需要更大的缓存。
4.图片的大小和色彩模式是什么,以及每张图片会占用多少的内存?
5.图片的访问会有多么频繁?会不会有些图片的访问频率要高于其他图片?如果是的话,或许i您需要在内存中保留确定数目的项,后者您需要多个LruCache对象分组保存图片缓存。
6.您是否能保证质量与数量的平衡?有些时候,保存数量多单质量略低的图片会更有用,而在后台任务中加载更高质量的图片。
这里并不存在特定的大小或者公式适用于所有的应用程序,这由您自己的使用情况决定,您需要提出适合自己的解决方案。缓存太小会造成很多附加的操作,这样并没有好处;缓存太大将直接导致java.lang.OutOfMemory异常,或者您的应用程序只剩很少的可用内存。
这有一个设置Bitmap LruCached的例子:
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);
}
注意:在这个例子中,应用程序的八分之一的内存被分配用来做图片缓存。在一个普通或者hdpi的设备上,这大概会有最少4MB(32/8)的大小。一个全屏的充满了图片的GridView,在一个800x400分辨率的谁呗上会占用1.5MB(800*400*4 bytes)的空间,所以这样大小的缓存大概会在内存中保存着两页半的图片。
当加载图片到一个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;
}
...
}
2.使用磁盘缓存
内存缓存对于提升最近的View的图片读取速度是非常有用的,但是您不能依靠这个缓存来保证图片是可用的。像GridView这种拥有大数据集的组件很容易吧内存缓存填满。您的应用程序会被电话这种事务所中断,当应用程序在后台运行时,它可能会被强制停止内存缓存也会被摧毁。一旦用户回复了应用程序,您的应用程序将不得不重新处理每一张图片。
在这种情况下,磁盘缓存可以持续操作图片并帮助减少那些已经从内存缓存中移除的图片再次加载的次数。当然,磁盘读写速度要慢于内存,并且因为磁盘读取时间不可预期,读取图片的操作也要在后台线程中进行。
注意:如果图片的访问频率很高的话,ContentProvider会更适合保存图片缓存,就像gallery应用一样。
使用了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线程。当图片操作完成时,图片应该同时添加到内存和磁盘缓存中备用。
3.处理设置的变更
运行时的设置会发生变化,像是屏幕方向的变化会导致Android摧毁并使用新的设置重新启动正在运行的Activity(详情请参阅处理运行时变化)。为了用户能够得到平滑顺畅的操作体验,您应该避免在设置发生变化时重新处理您的所有图片。
幸运的是您拥有一个在第一节创建的漂亮的内存缓存。该缓存可以使用Fragment(通过调用setRetainInstance(true)保存的)传递给新的Activity。当Activity被重新创建之后,保留的Fragment会被重新附加,并且您获得了已经存在的缓存对象,这可以使图片可以快速地被取出然后填充进ImageView对象。
这里有个经过设置变化使用Fragment保存的LruCacche的例子:
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();
}
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
}
要彻底测试,请分别在保存和不保存Fragment的情况下旋转您的设备。您会注意到,当您保留了缓存时,图片会立刻但稍有滞后地从内存中填充进Activity。任何内存缓存中没有的图片会在磁盘缓存中寻找,如果找不到,它们会被正常的加载。