通过前面两篇文章的学习,加载单张图片对我们来说已经是很容易的事了,但是面对同时加载多张图片的情况又会有新的问题。例如使用ListView、GridView或者ViewPager这类组件展示图片,快速滑动的特性决定了它们在屏幕上展示的图片的数量是无限制的。
这类组件通过复用滑动到屏幕区域以外的子view来减少内存消耗。如果我们没有用持久的引用保存已经加载的图片,那么垃圾回收器也会释放掉那些不在屏幕上显示的图片所占用的内存空间。这些特性是很好的,会减少内存的使用,但是为了快速流畅的展示UI界面,我们又避免每次显示图片时都重新加载图片。在这种场景下我们就需要使用到内存缓存和存储器缓存。
(本文出处:http://www.jianshu.com/p/ea590c658dc4 )
内存缓存(Memory Cache)
内存缓存使我们可以快速的获取图片,但是它也消耗了我们宝贵的内存资源。内存缓存特别适合用来做类似缓存图片的工作,它会将最近使用到的对象保存到一个LinkedHashMap中,并且在缓存的数据大小即将超过设置的上限时释放掉那些不常使用的对象。
过去我们常常使用SoftReference或者WeakReference来实现图片缓存,现在不再推荐这种这方式了。从Android 2.3 (API Level 9)开始器垃圾回收器回收软/弱引用概率提高了,那么使用这种方式实现的图片缓存工具的效率就会变低。
为了给LruCache分配一个合理缓存内存空间,我们需要考虑以下一些因素。
- 应用还剩多少可用空间?
- 需要同时显示多少张图片?需要为显示准备多少张图片?
- 设备屏幕尺寸和密度。如显示相同数量的图片Galaxy Nexus(xhdpi)就比Nexus S(hdpi)需要更多的内存空间。
- 图片本身的尺寸和属性。
- 图片使用的频率,有的会频繁使用,有只是偶尔使用。你也许应该直接将频繁使用的图片保存起来或者使用多个LruCache来分组管理这些图片。
- 平衡缓存图片的质量和数量。有时我们可以缓存很多低质量的图片,当图片被使用到时再用后台任务去加载一个高质量的图片。
如果缓存空间太小,就会频繁释放/加载图片,导致缓存效率较低;如果太大又会有内存溢出的风险,从而降低应用其他功能的执行效率。不幸的是没有一个适用于所有应用的确切值或者公式,我们只能根据具体使用场景来分析出一个合适的方案。
这里有一个图片缓存的例子可供参考。
private LruCache<String, Bitmap> mMemoryCache;
@Overrideprotected void onCreate(Bundle savedInstanceState) {
...
// Get max available VM memory, exceeding this amount will throw anOutOfMemory exception.
// Stored in kilobytes as LruCache takes anint 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设备上缓存空间大约会有4M(32M/8)。在分辨率为800*480的设备上一张全屏的图片所占用的空间大概是1.5M(800x480x4),所以上面的例子在内存分配最小的hdpi设备上可以缓存约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);
}
}
Android中高效的显示图片 - 非UI线程加载中我们实现的这个后台任务类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;
}
...
}
存储器缓存(Disk Cache)
内存缓存对提升再次访问最近使用图片的速度是有帮助的,但是它还有一些不足之处。如GridView这类组件显示大量图片时,这些图片会很快用光缓存空间。缓存空间满了之后再加入缓存图片时,LruCache就会释放掉一些老的缓存图片,当我们再次使用到这些老的图片时就有需要重新加载了。还有当我们的应用被电话之类的应用中断退到后台时有可能会被系统销毁来回收内存,这时再次进入应用时,所有的缓存数据都需要重新加载了。
这种场景就需要使用到我们的存储器缓存了。存储器缓存可以持久化这些加载后的图片,缩短当内存缓存中的数据不再可用时再次加载图片的时间。当然,从存储器中加载图片会比从内存中加载慢。因为读取存储器的时间是不可预测的,所以应该使用后台线程来加载。
备注:如果应用会频繁的使用这些图片(如相册),那么使用ContentProvider存储这些图片是一个更好的选择。
我们知道在显示网络图片时,存储器缓存有存在的必要性。如果显示的是本地图片,就是说图片原本就存储在存储器上了,存储器缓存还有没有存在的意义?答案是肯定的。在文章Android中高效的显示图片 - 加载大图中有介绍,加载本地图片时很有可能会对图片进行裁剪或者压缩操作,调整成更适合当前场景显示的图片,存储器缓存就可以将这些调整后的图片缓存起来,以便后续使用。
下面是一个在内存缓存的基础上实现了存储器缓存的例子。
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线程中。这就是会有线程同步的问题,所以我们使用了锁来保证在DiskLruCache初始化完成后才可访问。
因为存储器操作不应该在UI线程中执行,所以从内存缓存中获取数据可以在UI线程执行,但从存储器缓存中获取数据要在后台线程中执行。图片加载完成后需要同时缓存到内存缓存和存储器缓存,以便后续使用。
Configuration改变时缓存图片的处理方法
应用运行时Configuration改变(如屏幕方向改变)会导致Activity销毁重启。为了提供更顺畅的用户体验,这种情况下我们需要避免再次加载我们已经加载过的图片。
如果我们使用了内存缓存保存了我们的图片,那么在Configuration改变时我们可以通过Fragment将内存缓存数据传递到新Activity中。通过调用Fragment的setRetainInstance(true)来保存起来,在新的Activity重建以后这个保留起来的Fragment实例会重新关联到Activity上,然后就可以从Fragment中获取我们之前缓存的图片了。示例如下:
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);
}
}
你可以分别把setRetainInstance设置成true和false,然后旋转屏幕感受下效果。
总结
好了,到这里我们又给我们的图片加载框架加上了缓存的功能。当我们显示图片时,会先到检查LruCache,再检查DiskLruCache,都没有再执行常规的图片加载任务。在有大量图片展示的应用中,图片缓存的工作都是必须做的,它对图片展示的效率和应用性能的提升都有很大的帮助。
本文是《Android中高效的显示图片》专题中的第三篇
- Android中高效的显示图片 - 加载大图
- Android中高效的显示图片 - 非UI线程加载
- Android中高效的显示图片 - 图片缓存
- Android中高效的显示图片 - Bitmap的内存模型
网友评论