注意:在大多數情況下,建議您使用 Glide 程式庫在應用程式中擷取、解碼,並顯示點陣圖。Glide 在處理上述作業,以及其他在 Android 中使用點陣圖和其他圖片的相關任務時,大多能將複雜之處化繁為簡。如要瞭解如何使用和下載 Glide,請造訪 GitHub 的 Glide 存放區。
在使用者介面 (UI) 中載入單一點陣圖的程序相當簡單,但如果需要同時載入一組較大的圖片,操作起來會比較複雜。在許多情況下 (例如使用 ListView
、GridView
或 ViewPager
等元件時),畫面中的圖片與近期將捲動至畫面中的圖片總數,基本上沒有限制。
透過這類元件,系統能在子項檢視畫面從畫面中消失時加以回收,進而減少記憶體用量。垃圾收集器會假設您未保留任何長期參照,因此也會釋出已載入的點陣圖。這樣雖然不會有問題,但為了確保 UI 能流暢快速地載入,建議您避免在這些圖片每次重新出現在畫面上時持續處理圖片。記憶體和磁碟快取通常能在這方面派上用場,讓元件可以快速重新載入已處理的圖片。
本課程將逐步引導您使用記憶體和磁碟點陣圖快取,讓 UI 在載入多組點陣圖時能更快反應,運作起來也更流暢。
使用記憶體快取
記憶體快取可讓您快速存取點陣圖,但是可能會耗用大量的應用程式記憶體。LruCache
類別 (也可從支援資料庫取得,最低支援 API 級別 4 版本) 特別適合用於快取點陣圖,以便將近期參照過的物件保留在強式參照的 LinkedHashMap
中,並在快取超過其指定大小前,移除近期使用頻率最低的成員。
注意:在過去,記憶體快取常見的實作方式為 SoftReference
或 WeakReference
點陣圖快取,但我們不建議這麼做。從 Android 2.3 (API 級別 9) 開始,垃圾收集器會更主動地收集導致此類作業效率不彰的軟/弱式參照。此外,在 Android 3.0 (API 級別 11) 之前,點陣圖的備份資料會儲存在並非以預期方式釋出的原生記憶體中,而這可能會導致應用程式短暫超出其記憶體限制並當機。
為了選擇適合 LruCache
的大小,建議您將幾個因素納入考量,例如:
- 其他活動和/或應用程式的記憶體用量有多大?
- 畫面中一次會出現多少張圖片?需準備好多少張圖片,以便隨時顯示在畫面上?
- 裝置的螢幕大小和密度為何?與 Nexus S (HDPI) 這類裝置相較,超高密度螢幕 (XHDPI) 裝置 (例如 Galaxy Nexus) 需具備更大的快取,才可以在記憶體中保留相同數量的圖片。
- 點陣圖的維度與設定是什麼?又分別會占用多少記憶體?
- 圖片的存取頻率為何?是否有某些圖片的存取頻率較高?如果有,您可能需要將某些特定項目一律保留在記憶體中,甚至讓不同的點陣圖群組有多個
LruCache
物件。 - 您是否可以在畫質與數量之間取得平衡?有時候,儲存大量低畫質的點陣圖會比較實用,因為這樣或許能在另一個背景工作中載入較高畫質版本的點陣圖。
目前沒有適用於所有應用程式的特定大小或公式,因此您應自行分析使用情況,找出合適的解決方案。快取太小會造成額外負擔,這並沒有益處;快取太大可能會導致再次發生 java.lang.OutOfMemory
例外情況,使應用程式其他功能沒有足夠記憶體可用。
以下範例說明如何為點陣圖設定 LruCache
:
private lateinit var memoryCache: LruCache<String, Bitmap>
override fun onCreate(savedInstanceState: Bundle?) {
...
// Get max available VM memory, exceeding this amount will throw an
// OutOfMemory exception. Stored in kilobytes as LruCache takes an
// int in its constructor.
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
// Use 1/8th of the available memory for this memory cache.
val cacheSize = maxMemory / 8
memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
// The cache size will be measured in kilobytes rather than
// number of items.
return bitmap.byteCount / 1024
}
}
...
}
private LruCache<String, Bitmap> memoryCache;
@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;
memoryCache = 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) {
memoryCache.put(key, bitmap);
}
}
public Bitmap getBitmapFromMemCache(String key) {
return memoryCache.get(key);
}
注意:在這個範例中,系統會將八分之一的應用程式記憶體分配給快取,這在一般/HDPI 裝置上最少為 4 MB (32/8) 左右。而在解析度 800x480 的裝置上,全螢幕的 GridView
填滿圖片後會占用約 1.5 MB (800*480*4 位元組) 的空間,因此可以在記憶體中快取至少 2.5 頁的圖片。
將點陣圖載入至 ImageView
時,系統會先檢查 LruCache
。如果找到項目,系統會立即透過該項目更新 ImageView
;如果沒有找到,則會產生背景執行緒來處理圖片:
fun loadBitmap(resId: Int, imageView: ImageView) {
val imageKey: String = resId.toString()
val bitmap: Bitmap? = getBitmapFromMemCache(imageKey)?.also {
mImageView.setImageBitmap(it)
} ?: run {
mImageView.setImageResource(R.drawable.image_placeholder)
val task = BitmapWorkerTask()
task.execute(resId)
null
}
}
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
,才能在記憶體快取中加入項目:
private inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
...
// Decode image in background.
override fun doInBackground(vararg params: Int?): Bitmap? {
return params[0]?.let { imageId ->
decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
addBitmapToMemoryCache(imageId.toString(), bitmap)
}
}
}
...
}
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 來源提取的 DiskLruCache
實作方式。以下是更新的程式碼範例,除了現有記憶體快取之外,還加入了磁碟快取:
private const val DISK_CACHE_SIZE = 1024 * 1024 * 10 // 10MB
private const val DISK_CACHE_SUBDIR = "thumbnails"
...
private var diskLruCache: DiskLruCache? = null
private val diskCacheLock = ReentrantLock()
private val diskCacheLockCondition: Condition = diskCacheLock.newCondition()
private var diskCacheStarting = true
override fun onCreate(savedInstanceState: Bundle?) {
...
// Initialize memory cache
...
// Initialize disk cache on background thread
val cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR)
InitDiskCacheTask().execute(cacheDir)
...
}
internal inner class InitDiskCacheTask : AsyncTask<File, Void, Void>() {
override fun doInBackground(vararg params: File): Void? {
diskCacheLock.withLock {
val cacheDir = params[0]
diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE)
diskCacheStarting = false // Finished initialization
diskCacheLockCondition.signalAll() // Wake any waiting threads
}
return null
}
}
internal inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
...
// Decode image in background.
override fun doInBackground(vararg params: Int?): Bitmap? {
val imageKey = params[0].toString()
// Check disk cache in background thread
return getBitmapFromDiskCache(imageKey) ?:
// Not found in disk cache
decodeSampledBitmapFromResource(resources, params[0], 100, 100)
?.also {
// Add final bitmap to caches
addBitmapToCache(imageKey, it)
}
}
}
fun addBitmapToCache(key: String, bitmap: Bitmap) {
// Add to memory cache as before
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap)
}
// Also add to disk cache
synchronized(diskCacheLock) {
diskLruCache?.apply {
if (!containsKey(key)) {
put(key, bitmap)
}
}
}
}
fun getBitmapFromDiskCache(key: String): Bitmap? =
diskCacheLock.withLock {
// Wait while disk cache is started from background thread
while (diskCacheStarting) {
try {
diskCacheLockCondition.await()
} catch (e: InterruptedException) {
}
}
return diskLruCache?.get(key)
}
// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
fun getDiskCacheDir(context: Context, uniqueName: String): File {
// Check if media is mounted or storage is built-in, if so, try and use external cache dir
// otherwise use internal cache dir
val cachePath =
if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
|| !isExternalStorageRemovable()) {
context.externalCacheDir.path
} else {
context.cacheDir.path
}
return File(cachePath + File.separator + uniqueName)
}
private DiskLruCache diskLruCache;
private final Object diskCacheLock = new Object();
private boolean diskCacheStarting = 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 (diskCacheLock) {
File cacheDir = params[0];
diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
diskCacheStarting = false; // Finished initialization
diskCacheLock.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) {
memoryCache.put(key, bitmap);
}
// Also add to disk cache
synchronized (diskCacheLock) {
if (diskLruCache != null && diskLruCache.get(key) == null) {
diskLruCache.put(key, bitmap);
}
}
}
public Bitmap getBitmapFromDiskCache(String key) {
synchronized (diskCacheLock) {
// Wait while disk cache is started from background thread
while (diskCacheStarting) {
try {
diskCacheLock.wait();
} catch (InterruptedException e) {}
}
if (diskLruCache != null) {
return diskLruCache.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 執行緒中檢查,而磁碟快取則在背景執行緒中檢查。磁碟作業一律不應在 UI 執行緒上進行。圖片處理完畢後,系統會將最終的點陣圖同時加入記憶體和磁碟快取中,以供日後使用。
處理設定變更
執行階段設定變更 (例如螢幕方向變更) 會導致 Android 使用新的設定刪除並重新啟動執行中的活動 (如要進一步瞭解此行為,請參閱「處理執行階段變更」)。您應避免需要再次處理所有圖片的情況,如此一來,當設定變更時,使用者就能享有流暢快速的體驗。
幸運的是,您在「使用記憶體快取」一節中建立了實用的點陣圖記憶體快取。您可以利用呼叫 setRetainInstance(true)
所保留的 Fragment
,將此快取傳遞至新的活動例項。重新建立活動後,系統會重新附加這個保留的 Fragment
,然後您就可以存取現有的快取物件,從而允許快速擷取圖片,並將圖片重新填入至 ImageView
物件中。
以下範例說明如何透過 Fragment
在所有設定變更中保留 LruCache
物件:
private const val TAG = "RetainFragment"
...
private lateinit var mMemoryCache: LruCache<String, Bitmap>
override fun onCreate(savedInstanceState: Bundle?) {
...
val retainFragment = RetainFragment.findOrCreateRetainFragment(supportFragmentManager)
mMemoryCache = retainFragment.retainedCache ?: run {
LruCache<String, Bitmap>(cacheSize).also { memoryCache ->
... // Initialize cache here as usual
retainFragment.retainedCache = memoryCache
}
}
...
}
class RetainFragment : Fragment() {
var retainedCache: LruCache<String, Bitmap>? = null
companion object {
fun findOrCreateRetainFragment(fm: FragmentManager): RetainFragment {
return (fm.findFragmentByTag(TAG) as? RetainFragment) ?: run {
RetainFragment().also {
fm.beginTransaction().add(it, TAG).commit()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
}
private LruCache<String, Bitmap> memoryCache;
@Override
protected void onCreate(Bundle savedInstanceState) {
...
RetainFragment retainFragment =
RetainFragment.findOrCreateRetainFragment(getFragmentManager());
memoryCache = retainFragment.retainedCache;
if (memoryCache == null) {
memoryCache = new LruCache<String, Bitmap>(cacheSize) {
... // Initialize cache here as usual
}
retainFragment.retainedCache = memoryCache;
}
...
}
class RetainFragment extends Fragment {
private static final String TAG = "RetainFragment";
public LruCache<String, Bitmap> retainedCache;
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);
}
}
如要進行測試,請嘗試在不論是否保留 Fragment
的情況下旋轉裝置。如果保留快取,您應該會發現幾乎沒有延遲,因為圖片是以近乎即時的方式從記憶體填入活動中。而在記憶體快取中找不到的圖片則可能位於磁碟快取中;如果不是的話,系統便會照常處理。