快取點陣圖

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

注意:在大多數情況下,我們都建議您使用 Glide 程式庫在應用程式內擷取、解碼和顯示點陣圖。Glide 可以抽象化處理這些工作,以及 Android 點陣圖和其他圖像相關工作的繁瑣之處。如要瞭解如何使用和下載 Glide,請造訪 GitHub 的 Glide 存放區

在使用者介面 (UI) 中載入單一點陣圖的程序相當簡單,但如果需要一次載入更多圖片,情況就會更加複雜。在許多情況下 (例如使用 ListViewGridViewViewPager 等元件),畫面中的圖片與近期將捲動至畫面中的圖片總數,基本上是沒有限制的。

使用此類元件,以子項檢視畫面在螢幕中關閉時進行回收的方式減少記憶體用量。如果您沒有保留任何長期參照,垃圾收集器也會釋出已載入的點陣圖。這樣雖然不會有問題,但為了保持 UI 載入流暢快速,建議您避免在這些圖片每次重新顯示時持續進行處理。記憶體和磁碟快取通常能在這個部分提供協助,讓元件可以快速重新載入已處理的圖片。

本課程將逐步引導您使用記憶體和磁碟點陣圖快取,讓 UI 在載入點陣圖時可以有更快的反應,運作也更流暢。

使用記憶體快取

記憶體快取可讓您快速存取點陣圖,但是可能會耗用大量的應用程式記憶體。LruCache 類別 (也在支援資料庫提供,可於就版 API 級別 4 使用) 特別適合用於快取點陣圖,以便將近期參照過的物件保留在嚴格參照的 LinkedHashMap 中,並在快取超過其指定大小前,移除最近期使用過的成員。

注意:以往常見的記憶體快取實作為 SoftReferenceWeakReference 點陣圖快取,但我們不建議這麼做。從 Android 2.3 (API 級別 9) 開始,垃圾收集器會更主動地收集導致此類作業效率不彰的背景/弱式參照。此外,在 Android 3.0 (API 級別 11) 之前,點陣圖的備份資料會儲存在並非以預期方式釋出的原生記憶體中,而這可能會導致應用程式短暫超出其記憶體限制並當機。

為了選擇適合 LruCache 的大小,建議您將幾個因素納入考量,例如:

  • 其他活動和/或應用程式的記憶體用量有多大?
  • 畫面中會出現多少張圖片?有多少內容可在畫面中使用?
  • 裝置的螢幕大小和密度為何?與 Nexus S (hdpi) 等裝置相較之下,超高密度螢幕 (xhdpi) 裝置 (例如 Galaxy Nexus) 必須有更大的快取,才可以在記憶體中保留相同數量的圖片。
  • 點陣圖的維度與設定是什麼?這些記憶體又會佔用多少記憶體?
  • 存取圖片的頻率為何?是否有存取頻率不同的圖片?如果有,您可能會希望特定項目能總是保留在記憶體中,或甚至讓不同的點陣圖群組有多個 LruCache 物件。
  • 您是否可以在品質與數量之間取得平衡?有時候,儲存大量的低品質點陣圖,然後保留在另一個背景工作中載入較高品質版本的點陣圖可能會比較實用。

目前沒有適用於各種應用程式的特定大小或公式,因此您可以自行決定使用情況,並找出合適的解決方案。快取如果太小,不但會造成額外的負擔,而且沒有益處;快取如果太大,可能會導致再次發生 java.lang.OutOfMemory例外情況,使應用程式的其餘記憶體無法執行。

以下是點陣圖的 LruCache 設定範例:

Kotlin

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
        }
    }
    ...
}

Java

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);
}

注意事項:在這個範例中,系統會將八分之一的應用程式記憶體分配給我們的快取。在 normal/hdpi 裝置中,最低約 4MB (32/8)。在螢幕解析度為 800x480 的裝置中,全螢幕 GridView 會使用約 1.5MB (800*480*4 位元組),因此可以在記憶體中快取至少 2.5 頁的記憶體。

將點陣圖載入至 ImageView 時,系統會先檢查 LruCache。如果找到項目,系統會立即使用該項目更新 ImageView,或者產生背景執行緒以處理圖片:

Kotlin

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
    }
}

Java

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,才能在記憶體快取中加入項目:

Kotlin

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)
            }
        }
    }
    ...
}

Java

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 實作。以下是更新的程式碼範例,除了現有記憶體快取之外,還加入了磁碟快取:

Kotlin

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)
}

Java

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 物件的範例:

Kotlin

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
    }
}

Java

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 的情況下旋轉裝置。如果保留快取,您應該會發現圖片幾乎是立即從記憶體填入活動,而且幾乎沒有延遲。如果記憶體快取中找不到任何圖片,則可在磁碟快取中找到;如果沒有,就會照常處理。