通過前面兩篇文章的學習,加載單張圖片對我們來說已經是很容易的事了,但是面對同時加載多張圖片的情況又會有新的問題。例如使用ListView、GridView或者ViewPager這類組件展示圖片,快速滑動的特性決定了它們在屏幕上展示的圖片的數量是無限制的。
這類組件通過復用滑動到屏幕區域以外的子view來減少內存消耗。如果我們沒有用持久的引用保存已經加載的圖片,那么垃圾回收器也會釋放掉那些不在屏幕上顯示的圖片所占用的內存空間。這些特性是很好的,會減少內存的使用,但是為了快速流暢的展示UI界面,我們又避免每次顯示圖片時都重新加載圖片。在這種場景下我們就需要使用到內存緩存和存儲器緩存。
(本文出處:http://www.lxweimin.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的內存模型