高效地加载图片(三) 缓存图片
如果只需要加载一张图片,那么直接加载就可以.但是,如果要在类似ListView,GridView或者ViewPager的控件中加载大量的图片时,问题就会变得复杂.在使用这类控件时,在短时间内可能会显示在屏幕上的图片数量是不固定的.
这类控件会通过子View的复用来保持较低的内存占用.而Garbage Collector也会在View被复用时释放对应的Bitmap,保证这些没用用到的Bitmap不会长期存在于内存中.但是为了保证控件的流畅滑动,在一个View再次滑动出现在屏幕上时,我们需要避免图片的重复性加载.而此时,在内存和磁盘上开辟一块缓存空间往往能够保证图片的快速重复加载.
使用内存缓存
一块内存缓存在耗费一定应用内存基础上,能够让快速加载图片成为可能.而LruCache正合适用来缓存图片,对最近使用过的对象保存在
LinkedHashMap中,并且将最近未使用过的对象释放.
为了给LrcCache确定一个合适的大小,有以下一些因素需要考虑:
1.应用中其他组件占用内存的情况
2.有多少图片可能会显示在屏幕上?有多少图片将要显示在屏幕上?
3.屏幕的尺寸和屏幕密度是多少?与Nexus S这类高屏幕密度设备相比,Galaxy Nexs这类超高屏幕密度的设备,往往需要更大的缓存空间来存储相同数量的图片.
4.图片的尺寸以及其他的参数,还有每张图片将会占用多少内存.
5.图片被访问的频率有多高?是否有一些图片的访问频率会比另外一些更高?如果是这样,我们可能需要将一些图片长存于内存中,或者使用多个LrcCache来对不同的Bitmap进行分组.
6.我们还需要在图片的数量和质量之间权衡.有些时候,在缓存中存放大量的缩略图,而在后台加载高清图片会明显提高效率.
对每个应用来说,需要指定的缓存大小是不一定的,这取决于我们对应用的分析并得出相应的解决方案.如果缓存空间过小,可能会造成额外的开销,这对整个应用并无补益;而缓存空间过大,则可能会造成java.lang.OutOfMemory异常,并且留给其他组件使用的内存空间也会相应减少.
以下为初始化一个存放Bitmap的LrcCache的例子:
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... // 获取最大的可用空间,如果需要的空间超出这个大小,则会抛出OutOfMemory异常 // LrcCache构造函数中的参数是以千字节为单位的 final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // 此处缓存大小取可用内存的1/8 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; } }; ... } // 将Bitmap存入缓存 public void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemCache(key) == null) { // 当使用(getBitmapFromMemCache方法,根据传入的key获取Bitmap // 当获取到的Bitmap为空时,证明没有存储过该Bitmap // 此时将该Bitmap存储到LrcCache中 mMemoryCache.put(key, bitmap); } } // 根据key从LrcCache中获取对应的Bitmap public Bitmap getBitmapFromMemCache(String key) { return mMemoryCache.get(key); }
注意:在这个例子中,应用内存的1/8被分配用作缓存.在一台正常/高屏幕分辨率的设备上,这个缓存的大小在4MB左右(32/8 MB).而使用800×480分辨率的图片填充一个全屏的GridView的话,大概需要1.5MB的内存空间(800*480*4 bytes),所以这个缓存能够存储至少2.5页的图片.
在加载一张图片到ImageView时,LrcCache会首先检查这张图片是否存在.如果图片存在,则图片会立即被更新到ImageView中,否则会开启一个后台线程去加载这张图片.
public void loadBitmap(int resId, ImageView imageView) { // 将图片的资源id转换为String型,作为key final String imageKey = String.valueOf(resId); // 根据key从LruCache中获取Bitmap final Bitmap bitmap = getBitmapFromMemCache(imageKey); if (bitmap != null) { // 如果获取到的Bitmap不为空 // 则直接将获取到的Bitmap更新到ImageView中 mImageView.setImageBitmap(bitmap); } else { // 否则,则先在ImageView中设置一张占位图 mImageView.setImageResource(R.drawable.image_placeholder); // 再开启一个新的异步任务去加载图片 BitmapWorkerTask task = new BitmapWorkerTask(mImageView); task.execute(resId); } }
BitmapWorkerTask也需要更新,将Bitmap以键值对的形式存储到LrcCache中.
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { ... // 在后台加载图片 @Override protected Bitmap doInBackground(Integer... params) { final Bitmap bitmap = decodeSampledBitmapFromResource( getResources(), params[0], 100, 100)); // 将Bitmap对象以键值对的形式存储到LrcCache中 addBitmapToMemoryCache(String.valueOf(params[0]), bitmap); return bitmap; } ... }
使用磁盘缓存
内存缓存在访问最近使用过的图片方面能够极大地提高效率,但是我们不能指望所有需要的图片都能在内存缓存中找到.向GridView这类数据源中有大量数据的控件,会轻易的就将内存缓存占用满.而我们的应用也可能会被其他的任务打断(切换到后台),例如接听电话,而当我们的应用被切换到后台时,它极有可能会被关闭,此时内存缓存也会被销毁.当用户返回我们的应用时,应用又需要重新加载需要的图片.
而磁盘缓存会在内存缓存被销毁时继续加载图片,这样当内存缓存不可用但是又需要加载图片时就能够减少加载的时间.当然,从磁盘上读取图片要比从内存中读取图片慢,而且需要在后台线程中执行,因为图片的加载时间是不一定的.
注意:如果缓存图片需要经常访问,则将这些缓存图片存储到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) { ... // 初始化内存缓存 ... // 在后台线程初始化磁盘缓存 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; // 标识结束初始化 mDiskCacheLock.notifyAll(); // 唤醒等待中的线程 } return null; } } class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { ... // 在后台解析图片 @Override protected Bitmap doInBackground(Integer... params) { final String imageKey = String.valueOf(params[0]); // 在后台线程中判断图片是否已经存在于磁盘缓存中 Bitmap bitmap = getBitmapFromDiskCache(imageKey); if (bitmap == null) { // 不存在于磁盘缓存中 // 则正常加载图片 final Bitmap bitmap = decodeSampledBitmapFromResource( getResources(), params[0], 100, 100)); } // 将加载出的图片添加到缓存中 addBitmapToCache(imageKey, bitmap); return bitmap; } ... } public void addBitmapToCache(String key, Bitmap bitmap) { // 将图片添加到内存缓存中 if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } // 同时将图片添加到磁盘缓存中 synchronized (mDiskCacheLock) { if (mDiskLruCache != null && mDiskLruCache.get(key) == null) { mDiskLruCache.put(key, bitmap); } } } public Bitmap getBitmapFromDiskCache(String key) { synchronized (mDiskCacheLock) { // 当磁盘缓存正在初始化时,则等待 while (mDiskCacheStarting) { try { mDiskCacheLock.wait(); } catch (InterruptedException e) {} } if (mDiskLruCache != null) { return mDiskLruCache.get(key); } } return null; } // 当外部存储器可用时,则在应用指定文件夹中创建一个唯一的子文件夹作为缓存目录 // 而当外部设备不可用时,则使用内置存储器 public static File getDiskCacheDir(Context context, String uniqueName) { // 检查外部存储器是否可用,如果可用则使用外部存储器的缓存目录 // 否则使用内部存储器的缓存目录 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线程中执行,磁盘缓存的检察工作则必须在后台线程中执行.设计磁盘的操作无论如何不应该在UI线程中执行.当图片加载成功,得到的图片会添加到这两个缓存中去以待使用.
处理配置的更改
当运行时,配置发生了改变,例如屏幕方向的变化.这种变化会使Android系统摧毁并且使用新的配置重建当前正在执行的Activity(有关此方面的更多介绍,请查看Handling Runtime Changes).为了使用户有一个顺畅的体验,我们需要避免重新加载所有的图片.
幸运的时,我们有一个不错的内存缓存,这个内存缓存可以通过调用Fragment的setRetainInstance(true)方法保存并且传递到新的Activity中.当Activity被重建后,这个Fragment可以重新依附到新的Activity上,这样我们就可以使用已经存在的内存缓存,快速获取图片并展示在ImageView中.
以下是通过Fragment实现保留LruCache的代码:
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... // 得到一个用于保存LruCache的Fragment RetainFragment retainFragment = RetainFragment.findOrCreateRetainFragment(getFragmentManager()); // 取出Fragment的LruCache mMemoryCache = retainFragment.mRetainedCache; if (mMemoryCache == null) { // 如果LruCache为空,则原先没有缓存 // 需要新建并初始化一个LruCache mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { ... // Initialize cache here as usual } // 将新建的LruCache存放到Fragment中 retainFragment.mRetainedCache = mMemoryCache; } ... } class RetainFragment extends Fragment { private static final String TAG = "RetainFragment"; public LruCache<String, Bitmap> mRetainedCache; public RetainFragment() {} // 新建或者从FragmentManager中得到保存LruCache的Fragment public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) { // 根据tag从FragmentManager中获取对应的Fragment RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG); if (fragment == null) { // 如果Fragment为空,则原先没有该Fragment // 即表明原先没有LruCache // 此时需要新建一个Fragment用于存放LruCache fragment = new RetainFragment(); // 并将Fragment添加到FragmentManager中 fm.beginTransaction().add(fragment, TAG).commit(); } return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 设置当Activity被重建时,Fragment重新依附到Activity上 setRetainInstance(true); } }
为了验证一下效果(是否重新将Fragment依附到Activity上),我们可以旋转一下屏幕.你会发现当我们通过Fragment保存了内存缓存,重建了Activity后重新取出图片几乎没有延时.在内存缓存中没有的图片很可能在磁盘缓存上会有,如果磁盘缓存中也没有,则会正常加载需要的图片.