载入一张位图到UI界面是很简单直接的事情,但是如果要一次性载入多张图片,那么就会变得很复杂了。在很多情况下,如在ListView
, GridView
or ViewPager里面,页面上展示的图片,是由快速滚动的很多个图片组成的,数量是几乎没有限制的。
像上面说的几种视图控件,在他们的离开屏幕使子视图被回收利用的情况下,内存的使用一直是保持比较低的。假定你不在使用载入的位图,内存垃圾回收机制同样会释放你载入的位图。这样做理论上来说很好,但是,为了保持你的UI的流畅性和快速载入,你要避免当UI视图重新显示到屏幕的时候,重新去进行图片的载入,处理工作。那么内存或者磁盘的缓存区在这里就是解决这个问题的,可以让UI可以快速的再次载入之前的位图。
这节课里面就引导你如何利用内存,磁盘缓存位图,来提高响应速度,当载入多张位图的时候,让你的UI如流水一样顺滑。
使用内存缓存
内存缓存在应用程序所使用的珍贵的内存情况下,提供了快速访问位图的途径。
LruCache类非常适合缓存位图的任务,把最近的引用保存在一个强的引用对象
LinkedHashMap里面,当缓存到达它被设计的最大值,会把最近几乎不怎么使用的资源释放掉。
注意:在上一节里面,使用到的一个缓存实现是:
SoftReference
or WeakReference位图缓存,然而这样做并不推荐。从android 2.3开始,内存回收机制对soft/weak的垃圾回收更加严格,这种情况下,几乎会导致他们无效。另外,前面的版本到android3.0,备份的位图数据时存在本地内存里面的,并无法按可预测的方式自动释放掉,可能会潜在的导致APP暂时内存溢出而导致崩溃。
为了选择一个合适大小的
LruCache,以下几个因素要考虑:
你的activity或者app要使用的内存密度
一次性显示到屏幕的位图数量,要准备多少位图来显示到屏幕
设备的分辨率。一个高分辨率的设备,如Nexus,就需要更大的缓存来存储位图。
位图的分辨率和配置,以及要使用多大的内存
对位图的使用的频繁程度。如果频繁使用,就需要让这些位图长期存在于内存,甚至可以有多个LruCache来针对多组位图。
你可以平衡质量和数量吗?有时候这一点很重要,如要存放许多低质量的位图,但是同时要在另外一个任务中载入高质量的位图。
没有标准的大小或者公式来适合每一个应用。主要取决于你来分析你的APP要使用的内存大小来采取合适的解决方案。缓存的数据太小,会导致额外的浪费,缓存的数据太大,可能会导致内存溢出的异常,使得你的APP可用的内存会很有限。
下面的例子就是针对位图而设置的一个
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);
}
注意:在这个例子中,程序内存的八分之一被用来作为缓存。一个正常的高分辨率设备使用的缓存大小大约在4MB左右。一个填充位图,占据整个屏幕的GridView,在分辨率为800*480的设备上,大小约为1.5MB。所以我们创建的这个缓存大概可以存放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也许是更加适合存放缓存图像的地方,如果是需要频繁的访问这些图像,例如画廊应用。
下面的的代码示例,使用在Android source里面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);
}
注意:对磁盘缓存操作需要有对磁盘操作的权限,而且也不应该在主线程里面进行。而且,也并不意味着在初始化之前就有机会去访问缓存。要处理这些,在上面的实现中,一个上锁的对象可以保证APP在缓存未初始化之前,无法对该缓存进行读取操作。
在UI线程里面对内存缓存进行检查,对磁盘缓存是在后台线程里面检查的。磁盘的操作永远不应该放在UI线程里面。当图像处理完成,最终产生的位图会被同时存在内存和磁盘缓存,以便将来使用。
处理配置发送改变的情况
运行时的配置发送改变,例如屏幕方向发生变化,会导致andorid系统会销毁并重新启动当前运行的activity.你要避免重复去初始化全部的位图,这样当屏幕发送改变是,用户有一个平滑,快速的用户体验。
幸运的是,你有很好位图内存缓存,如我们在上面讲述到的,这个缓存可以被传递到一个带有Fragment的activity里面,通过调研setRetainInstance(true)方法。当这个anctivity重新被创建的时候,保存的
Fragment重新被添加到activity,获得缓存对象,允许位图快速的取出填充到ImageView里面。
下面就一个例子,在配变化的时候,一个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情况下尝试这个旋转屏幕。你会注意到,在你保存有缓存的时候,这个图像的重新显示机会不耗时,是立即填充好的。任何在内存缓存里面没有的位图,都要尝试在磁盘缓存里面去查找,如果没有,就要重新初始化这个图像了。