欢迎参加我们将于 6 月 3 日举行的 #Android11:Beta 版发布会

缓存位图

注意:在大多数情况下,我们建议您使用 Glide 库来获取、解码和显示应用中的位图。在 Android 中处理上述及其他与位图和其他图片相关的任务时,Glide 可以替您完成大部分复杂工作。如需了解如何使用和下载 Glide,请访问 GitHub 上的 Glilt 代码库

将单个位图加载到界面中非常简单,但如果需要同时加载较多的图片,情况就会变得复杂。实际上,在许多情况下(比如使用 ListViewGridViewViewPager 等组件时),屏幕上的图片与可能很快会滚动到屏幕上的图片加起来,数量是无限的。

系统通过循环利用移出屏幕的子视图来限制此类组件对内存的占用。垃圾回收器假设您不会保留任何长期的引用,因此也会释放已加载的位图。这些都没有问题,但是为了确保能够快速、流畅地加载界面,您必须避免每次这些图片返回到屏幕上时都要处理这些图片。通常采用内存和磁盘缓存会有所帮助,因为这可以让组件快速重新加载经过处理的图片。

本节课将介绍如何利用内存和磁盘位图缓存来改善界面在加载多个位图时的响应性和流畅性。

使用内存缓存

内存缓存可以提供对位图的快速访问,但代价是会占用宝贵的应用内存。LruCache 类(支持库中也提供了该类,最低可支持 API 级别 4)非常适合用于以下任务:缓存位图,将最近引用的对象保持在强引用的 LinkedHashMap 中,并且在缓存超出其指定大小之前移除上次使用时间最早的成员。

注意:过去,最常用的内存缓存实现是 SoftReferenceWeakReference 位图缓存,但现在已不建议使用。从 Android 2.3(API 级别 9)开始,垃圾回收器会更积极地回收软引用/弱引用,导致它们效用不佳。此外,在 Android 3.0(API 级别 11)之前,位图的后备数据存储在原生内存中,该内存不会以可预测的方式释放,因此可能会导致应用短暂超出其内存限制并崩溃。

要为 LruCache 选择合适的大小,需要考虑多种因素,例如:

  • Activity 和/或应用的其余部分对内存的占用情况如何?
  • 一次会在屏幕上显示多少张图片?有多少张图片需要准备好随时可以显示在屏幕上?
  • 设备的屏幕尺寸和密度是多少?相比于 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);
    }
    

注意:在本示例中,将八分之一的应用内存分配给了缓存。在普通/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);
    }
    

注意:即使是初始化磁盘缓存也需要执行磁盘操作,因此不应在主线程上执行。不过,这也意味着可能会在初始化之前访问该缓存。为了解决此问题,上述实现利用了一个 lock 对象来确保应用在磁盘缓存初始化之前不会从该缓存中读取数据。

虽然内存缓存是在界面线程中检查,但磁盘缓存会在后台线程中检查。界面线程上不应执行磁盘操作。图片处理完毕后,系统会将最终的位图同时添加到内存缓存和磁盘缓存中以供将来使用。

处理配置更改

运行时配置更改(例如屏幕方向更改)会导致 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 中。在内存缓存中找不到的图片有可能会在磁盘缓存中,如果不在,系统会照常处理它们。