Android中載入一個Bitmap(位元影像)到你的UI介面是非常簡單的,但是如果你要一次載入一大批,事情就變得複雜多了。在大多數的情況下(如ListView、GridView或者ViewPager這樣的組件),螢幕上的圖片以及馬上要在滾動到螢幕上顯示的圖片的總量,在本質上是不受限制的。
像這樣的組件在子視圖移出螢幕後會進行視圖回收,記憶體使用量仍被保留。但假設你不保留任何長期存活的引用,記憶體回收行程也會釋放你所載入的Bitmap。這自然再好不過了,但是為了保持流暢且快速載入的UI,你要避免繼續在圖片回到螢幕上的時候重新處理。使用記憶體和硬碟緩衝通常能解決這個問題,使用緩衝允許組件快速載入並處理圖片。
這節課將帶你使用記憶體和硬碟緩衝Bitmap,以在載入多個Bitmap的時候提升UI的響應性和流暢性。
使用記憶體緩衝
以犧牲寶貴的應用記憶體為代價,記憶體緩衝提供了快速的Bitmap訪問方式。LruCache類(可以在Support Library中擷取並支援到API Level 4以上,即1.6版本以上)是非常適合用作緩衝Bitmap任務的,它將最近被引用到的Object Storage Service在一個強引用的LinkedHashMap中,並且在緩衝超過了指定大小之後將最近不常使用的對象釋放掉。
注意:以前有一個非常流行的記憶體緩衝實現是SoftReference(軟引用)或者WeakReference(弱引用)的Bitmap緩衝方案,然而現在已經不推薦使用了。自Android2.3版本(API Level 9)開始,記憶體回收行程更著重於對軟/弱引用的回收,這使得上述的方案相當無效。此外,Android
3.0(API Level 11)之前的版本中,Bitmap的備份資料直接儲存在本地記憶體中並以一種不可預測的方式從記憶體中釋放,很可能短暫性的引起程式超出記憶體限制而崩潰。
為了給LruCache選擇一個合適的大小,要考慮到很多原因,例如:
• 其他的Activity(活動)和(或)程式都是很耗費記憶體的嗎?
• 螢幕上一次會顯示多少圖片?有多少圖片將在螢幕上顯示?
• 裝置的螢幕大小和密度是多少?一個超高清螢幕(xhdpi)的裝置如Galaxy Nexus,相比Nexus S(hdpi)來說,緩衝同樣數量的圖片需要更大的緩衝空間。
• Bitmap的尺寸、配置以及每張圖片需要佔用多少記憶體?
• 圖片的訪問是否頻繁?有些會比其他的更加被頻繁的訪問到嗎?如果是這樣,也許你需要將某些圖片一直保留在記憶體中,甚至需要多個LruCache對象分配給不同組的Bitmap。
• 你能平衡圖片的品質和數量嗎?有的時候儲存大量低品質的圖片更加有用,然後可以在背景工作中載入另一個高品質版本的圖片。
對於設定緩衝大小,並沒有適用於所有應用的規範,它取決於你在記憶體使用量分析後給出的合適的解決方案。緩衝空間太小並無益處,反而會引起額外的開銷,而太大了又可能再次引起java.lang.OutOfMemory異常或只留下很小的空間給應用的其他程式運行。
這裡有一個設定Bitmap的LruCache樣本:
private LruCache<String, Bitmap> mMemoryCache;
@Override
protected void onCreate(Bundle savedInstanceState) {
...
// Get memory class of this device, exceeding this amount will throw an
// OutOfMemory exception.
final int memClass = ((ActivityManager) context.getSystemService(
Context.ACTIVITY_SERVICE)).getMemoryClass();
// Use 1/8th of the available memory for this memory cache.
final int cacheSize = 1024 * 1024 * memClass / 8;
mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// The cache size will be measured in bytes rather than number of items.
return bitmap.getByteCount();
}
};
...
}
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的應用記憶體被分配給緩衝。在一個普通的/hdpi裝置上最低也在4M左右(32/8)。一個解析度為800*480的裝置上,全屏的填滿圖片的GridView佔用的記憶體約1.5M(800*480*4位元組),因此這個大小的記憶體可以緩衝2.5頁左右的圖片。
當載入一個Bitmap到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;
}
...
}
使用硬碟緩衝
一個記憶體緩衝對加速訪問最近瀏覽過的Bitmap非常有協助,但是你不能局限於記憶體中的可用圖片。GridView這樣有著更大的資料集的組件可以很輕易消耗掉記憶體緩衝。你的應用有可能在執行其他任務(如打電話)的時候被打斷,並且在背景任務有可能被殺死或者緩衝被釋放。一旦使用者重新聚焦(resume)到你的應用,你得再次處理每一張圖片。
在這種情況下,硬碟緩衝可以用來儲存Bitmap並在圖片被記憶體緩衝釋放後減小圖片載入的時間(次數)。當然,從硬碟載入圖片比記憶體要慢,並且應該在後台線程進行,因為硬碟讀取的時間是不可預知的。
注意:如果訪問圖片的次數非常頻繁,那麼ContentProvider可能更適合用來儲存緩衝圖片,例如Image Gallery這樣的應用程式。
這個類中的範例程式碼使用DiskLruCache(來自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);
}
注意:即便是硬碟緩衝初始化也需要硬碟操作,因此不應該在主線程執行。但是,這意味著硬碟緩衝在初始化前就能被訪問到。為瞭解決這個問題,在上面的實現中添加了一個鎖對象(lock object),以確保在緩衝被初始化之前應用無法訪問硬碟緩衝。
在UI線程中檢查記憶體緩衝,相應的硬碟緩衝檢查應在後台線程中進行。硬碟操作永遠不要在UI線程中發生。當圖片處理完成後,最終的Bitmap要被添加到記憶體緩衝和硬碟緩衝中,以便後續的使用。
處理配置更改
運行時的配置會發生變化,例如螢幕方向的改變,會導致Android銷毀並以新的配置重新啟動Activity(關於此問題的更多資訊,請參閱Handling Runtime Changes)。為了讓使用者有著流暢而快速的體驗,你需要在配置發生改變的時候避免再次處理所有的圖片。
幸運的是,你在“使用記憶體緩衝”一節中為Bitmap構造了很好的記憶體緩衝。這些記憶體可以通過使用Fragment傳遞到新的Activity(活動)執行個體,這個Fragment可以調用setRetainInstance(true)方法保留下來。在Activity(活動)被重新建立後,你可以在上面的Fragment中訪問到已經存在的緩衝對象,使得圖片能快載入並重新填充到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的情況下旋轉裝置螢幕。在保留緩衝的情況下,你應該能發現填充圖片到Activity中幾乎是瞬間從記憶體中取出而沒有任何延遲的感覺。任何圖片優先從記憶體緩衝擷取,沒有的話再到硬碟緩衝中找,如果都沒有,那就以普通方式載入圖片。