缓存位图

注意:在大多数情况下,我们建议您使用 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)等设备相比,像 Galaxy Nexus 这样的超高密度屏幕 (xhdpi) 设备需要更大的缓存才能在内存中保存相同数量的图像。
  • 位图的尺寸和配置是什么,因此每个位图将占用多少内存?
  • 图像访问频率如何?某些图像的访问频率是否高于其他图像?如果是这样,您可能希望始终将某些项目保存在内存中,甚至为不同组的位图创建多个 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);
}

注意:在此示例中,应用程序内存的八分之一分配给我们的缓存。在普通/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 销毁并使用新配置重新启动正在运行的活动(有关此行为的更多信息,请参阅 处理运行时更改)。您希望避免不得不再次处理所有图像,以便用户在发生配置更改时获得流畅且快速的体验。

幸运的是,您在 使用内存缓存 部分构建了一个不错的位图内存缓存。可以通过使用 Fragment 将此缓存传递到新的活动实例,该实例通过调用 setRetainInstance(true) 来保留。活动重新创建后,此保留的 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。您应该注意到几乎没有或根本没有延迟,因为当您保留缓存时,图像几乎会立即从内存中填充活动。在内存缓存中找不到的任何图像都希望在磁盘缓存中可用,如果不是,则按常规处理。