缓存位图

注意:在大多数情况下,我们建议您使用 Glide 库来获取、解码和显示应用中的位图。Glide 抽象了处理这些以及与 Android 上的位图和其他图像相关的其他任务的大部分复杂性。有关使用和下载 Glide 的信息,请访问 GitHub 上的 Glide 仓库

将单个位图加载到用户界面 (UI) 中很简单,但是如果您需要一次加载大量图像,事情就会变得复杂。在许多情况下(例如对于 ListViewGridViewViewPager 等组件),屏幕上显示的图像总数以及可能很快会滚动到屏幕上的图像基本上是无限的。

通过回收移出屏幕的子视图,可以降低此类组件的内存使用量。垃圾回收器也会释放已加载的位图,前提是您不保留任何长期有效的引用。这都没问题,但为了保持流畅、快速加载的 UI,您希望避免每次图像重新出现在屏幕上时都重复处理它们。内存缓存和磁盘缓存通常可以在这方面提供帮助,让组件能够快速重新加载已处理的图像。

本课程将指导您使用内存和磁盘位图缓存来提高加载多个位图时界面的响应速度和流畅性。

使用内存缓存

内存缓存以占用宝贵的应用内存为代价,提供了对位图的快速访问。LruCache 类(也在 支持库 中提供,可用于 API Level 4 及更高版本)特别适合缓存位图,它将最近引用的对象保存在强引用的 LinkedHashMap 中,并在缓存超出其指定大小之前逐出最近最少使用的成员。

注意:过去,一种流行的内存缓存实现是 SoftReferenceWeakReference 位图缓存,但不建议使用这种方法。从 Android 2.3(API Level 9)开始,垃圾回收器对软/弱引用回收得更频繁,这使得它们效果不佳。此外,在 Android 3.0(API Level 11)之前,位图的底层数据存储在原生内存中,其释放方式不可预测,可能导致应用短暂超出内存限制并崩溃。

为了选择适合 LruCache 的大小,应考虑以下几个因素,例如

  • 您的 Activity 和/或应用的其他部分对内存的要求有多高?
  • 一次会在屏幕上显示多少张图片?有多少图片需要准备好随时出现在屏幕上?
  • 设备的屏幕尺寸和密度是多少?与 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 使用新配置销毁并重新启动正在运行的 Activity(有关此行为的更多信息,请参阅处理运行时变更)。您希望避免再次处理所有图像,以便在发生配置变更时用户获得流畅快速的体验。

幸运的是,您在使用内存缓存部分构建了一个很好的位图内存缓存。可以使用通过调用 setRetainInstance(true) 保留的 Fragment 将此缓存传递给新的 Activity 实例。Activity 重建后,此保留的 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 的情况下旋转设备。保留缓存时,您应该会注意到几乎没有延迟,因为图像会立即从内存填充到 Activity 中。内存缓存中找不到的任何图像如果存在,则应位于磁盘缓存中;否则,它们将照常处理。