- 文档
- 教程
- 移动开发
【免费下载链接】android-training-course-in-chinese
Android官方培训课程中文版
本文基于《Android官方培训课程中文版》高效显示Bitmap 章节中的《缓存Bitmap》一课展开,并结合同章节其余课程内容进行源码级扩充。
在 ListView、GridView、ViewPager 这类需要一次性加载大量图片的控件中,屏幕内外的图片数量几乎没有上限。循环利用子视图(RecyclerView 的复用机制)与垃圾回收(GC)虽然能缓解内存压力,但每次滑动回来都重新解码一遍图片,UI 会明显卡顿。本文的核心方案是用内存缓存(LruCache)与磁盘缓存(DiskLruCache)把"处理过的 Bitmap"保存下来,让控件在滑动回来时能瞬间复用,从而在有限的设备内存下同时兼顾响应速度与 UI 流畅度。读完本文,你将掌握缓存大小的估算方法、LruCache/DiskLruCache 的完整接入代码、后台线程初始化磁盘缓存的线程安全写法,以及旋转屏幕等配置改变时如何用 Fragment 保留缓存避免重复加载。
为什么要缓存 Bitmap
将单个 Bitmap 加载到 UI 是简单直接的,但在 ListView、GridView、ViewPager 等滚动场景下,需要显示的图片和"即将滑动显示"的图片数量是不可控的。即使通过循环利用子视图与 GC 释放不再使用的 Bitmap,也仍然存在一个体验问题:每次从缓存视图滑回来时,都要重新处理(解码、缩放、甚至从网络拉取)那些图片。
内存与磁盘缓存正是为了解决这个问题:缓存允许控件快速重新加载"已经处理过的图片",避免重复解码带来的 CPU 开销与 I/O 开销。本文所在章节的课程脉络为:
- 高效加载大图:通过 inJustDecodeBounds 与 inSampleSize 加载按比例缩小的 Bitmap,避免 OutOfMemory;
- 非UI线程处理Bitmap:用 AsyncTask 在后台线程处理 Bitmap,并解决并发问题;
- 缓存Bitmap(本文主体):在内存与磁盘两个层级缓存处理结果;
- 管理Bitmap的内存使用:针对不同 Android 版本优化 Bitmap 内存(recycle() 与 inBitmap);
- 在UI上显示Bitmap:把上述技巧综合应用到 ViewPager 与 GridView。
使用内存缓存(Use a Memory Cache)
内存缓存以花费宝贵的程序内存为代价换取对 Bitmap 的快速访问。LruCache类(API Level 4 起可在 Support Library 中找到)特别适合缓存 Bitmap:它内部使用一个强引用(strong referenced)的LinkedHashMap保存最近引用的对象,并在缓存超出设定大小(maxSize)时,剔除(evict)最近最少使用到的对象——即标准的 LRU 淘汰策略。
为什么不用软引用/弱引用
过去流行用SoftReference或WeakReference缓存 Bitmap,官方明确不推荐这种做法:
- 从 Android 2.3(API Level 9)开始,垃圾回收机制变得更加频繁,软(弱)引用被释放的频率随之增高,缓存命中率大幅下降,导致引用方案效率极低;
- 在 Android 3.0(API Level 11)之前,Bitmap 的像素数据存放在 Native Memory 中,释放时机不可预测,容易导致程序超出内存限制而崩溃。
因此,从 Android 2.3 时代开始,基于 LruCache 的强引用 + 显式淘汰模型就成了 Android 官方推荐的缓存实现。这也与仓库中 管理Bitmap的内存使用 一课的版本演进描述一致:早期版本像素数据存于 Native 堆、无法预测释放时机,而自 Android 3.0 起像素数据并入 Dalvik 堆,GC 才能统一管理。
如何确定 LruCache 的合适大小
没有通用的公式,需要结合以下因素综合分析:
| 考量因素 | 说明 |
|---|---|
| 应用剩余可用内存 | 缓存占用的正是应用进程的堆内存(heap),取多少取决于预算 |
| 同时呈现的图片数量 | 屏幕上显示多少张、还需要预加载多少张准备滚动显示 |
| 屏幕大小与密度 | xhdpi 设备(如 Galaxy Nexus)缓存同样数量的图片,比 hdpi 设备(如 Nexus S)需要更大的空间 |
| Bitmap 的尺寸与配置 | 每张图占用多少字节,直接决定缓存能存多少张 |
| 图片的访问频率 | 若部分图片访问更频繁,可考虑按访问频率分组,为不同组配置多个 LruCache 对象 |
| 质量与数量的平衡 | 某些场景保存大量低质量 Bitmap 更有用,高质量版本交由后台线程另行加载 |
缓存太小会导致额外的解码开销而收益甚微;缓存太大则可能抛出java.lang.OutOfMemory,并挤占应用其余功能所需的内存。官方的经验做法是:取Runtime.getRuntime().maxMemory()(虚拟机最大可用内存)的 1/8 作为缓存预算。
建立 LruCache 的完整示例
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... // 获取虚拟机最大可用内存,超过此值将抛出 OutOfMemory 异常。 // 单位换算为 KB,因为 LruCache 构造函数的参数是 int。 final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // 将可用内存的 1/8 用作内存缓存。 final int cacheSize = maxMemory / 8; mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { // 缓存大小按 KB 计量,而不是按条目数量计量。 return bitmap.getByteCount() / 1024; } }; ... } public void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } } public Bitmap getBitmapFromMemCache(String key) { return mMemoryCache.get(key); }关键点:
sizeOf()决定了"1 个缓存单元"如何计算,这里用getByteCount() / 1024把 Bitmap 的实际内存占用折算成 KB,使缓存上限(cacheSize)与内存占用直接挂钩;addBitmapToMemoryCache()先判空再写入,避免重复覆盖;- 以资源 ID 字符串作为 key(如
String.valueOf(resId))简单且稳定。
一个直观的容量参考:上述示例把 1/8 内存用作缓存。在常见 hdpi 设备上(heap 约 32MB),最少约有 4MB 缓存空间;一个填满图片的 800x480 手机屏幕 GridView,按 800x480x4 bytes 计算约占用 1.5MB,因此该缓存大约可支撑 2.5 页的图片内容。
加载流程:先查内存缓存,未命中再后台处理
在把 Bitmap 显示到 ImageView 之前,先检查 LruCache 是否已存在该图;命中则立即显示,未命中则设置占位图并触发后台线程处理:
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在解码完成后把结果写回内存缓存:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { ... // 在后台线程解码图片。 @Override protected Bitmap doInBackground(Integer... params) { final Bitmap bitmap = decodeSampledBitmapFromResource( getResources(), params[0], 100, 100)); addBitmapToMemoryCache(String.valueOf(params[0]), bitmap); return bitmap; } ... }这里用到的decodeSampledBitmapFromResource()来自 高效加载大图 一课:先用inJustDecodeBounds = true读取图片原始宽高,再通过calculateInSampleSize()计算 2 的幂次采样率,最后真正解码出接近目标尺寸(如 100x100)的缩略图。缓存配合采样解码,才能保证单张 Bitmap 的内存占用可控。
完整背景解码与并发处理的实现(WeakReference 持有 ImageView、AsyncDrawable 记录任务、cancelPotentialWork 取消过期任务等)见 非UI线程处理Bitmap。
使用磁盘缓存(Use a Disk Cache)
内存缓存能加速"最近访问过"的 Bitmap,但无法保证所有 Bitmap 都常驻内存:GridView 这类大容量控件很容易耗尽整个内存缓存;应用被电话等行为暂停退到后台后,进程可能被系统杀死,内存缓存随之销毁、Bitmap 全部丢失,用户恢复应用时又得重新处理所有图片。
磁盘缓存用于保存已经处理过的 Bitmap,能显著减少"不在内存缓存中的 Bitmap"的重复加载次数。代价是从磁盘读取比内存慢得多,且读取时间不可预期,因此磁盘读取必须放到后台线程执行。
若图片会被非常频繁地访问(如系统图库应用),使用
ContentProvider可能是比磁盘缓存更合适的方案。
DiskLruCache 的初始化与线程安全
这一节的示例使用了从 Android 源码(libcore 中的DiskLruCache)剥离出来的实现。改进后的示例在已有内存缓存基础上叠加磁盘缓存:
private DiskLruCache mDiskLruCache; private final Object mDiskCacheLock = new Object(); private boolean mDiskCacheStarting = 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) { ... // 初始化内存缓存 ... // 在后台线程初始化磁盘缓存 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 (mDiskCacheLock) { File cacheDir = params[0]; mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE); mDiskCacheStarting = false; // 初始化完成 mDiskCacheLock.notifyAll(); // 唤醒所有等待中的线程 } return null; } }两个核心设计:
- 磁盘缓存初始化放在后台线程:因为涉及文件 I/O,绝不能在主线程执行;
- 用锁对象保证"初始化完成前不可读":初始化是异步的,期间其他线程可能尝试访问磁盘缓存。
mDiskCacheStarting标志 +wait()/notifyAll()确保读取线程会一直等待到初始化完成。
后台任务同时检查磁盘缓存
BitmapWorkerTask在解码前先查磁盘缓存,未命中才走正常解码流程,最终结果同时写入两级缓存:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { ... // 在后台线程解码图片。 @Override protected Bitmap doInBackground(Integer... params) { final String imageKey = String.valueOf(params[0]); // 在后台线程检查磁盘缓存 Bitmap bitmap = getBitmapFromDiskCache(imageKey); if (bitmap == null) { // 磁盘缓存未命中 // 按正常流程处理 final Bitmap bitmap = decodeSampledBitmapFromResource( getResources(), params[0], 100, 100)); } // 将最终 Bitmap 写入两级缓存 addBitmapToCache(imageKey, bitmap); return bitmap; } ... } public void addBitmapToCache(String key, Bitmap bitmap) { // 先写入内存缓存(与之前相同) if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } // 再写入磁盘缓存 synchronized (mDiskCacheLock) { if (mDiskLruCache != null && mDiskLruCache.get(key) == null) { mDiskLruCache.put(key, bitmap); } } } public Bitmap getBitmapFromDiskCache(String key) { synchronized (mDiskCacheLock) { // 磁盘缓存尚未初始化完成时,等待 while (mDiskCacheStarting) { try { mDiskCacheLock.wait(); } catch (InterruptedException e) {} } if (mDiskLruCache != null) { return mDiskLruCache.get(key); } } return null; }获取合适的缓存目录
// 在指定的应用缓存目录下创建一个唯一的子目录。 // 优先使用外部存储,若未挂载则回退到内部存储。 public static File getDiskCacheDir(Context context, String uniqueName) { // 检查媒体是否已挂载或存储是否为内置存储, // 是则使用外部缓存目录,否则使用内部缓存目录。 final String cachePath = Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() : context.getCacheDir().getPath(); return new File(cachePath + File.separator + uniqueName); }注意:getExternalCacheDir()与getCacheDir()返回的都是系统管理的缓存目录(应用卸载时自动清理,且无需额外存储权限),不要与用户可见的公开目录混淆。
线程模型小结:内存缓存的读取(LruCache.get())可在 UI 线程进行;磁盘缓存的读取(DiskLruCache.get())必须放到后台线程;磁盘操作任何时候都不允许发生在 UI 线程。图片处理完成后,需同时写入内存缓存与磁盘缓存,方便后续直接复用。
处理配置改变(Handle Configuration Changes)
运行时配置改变(如屏幕方向旋转)会导致当前 Activity 被系统销毁并重建。如果不做处理,所有图片都会被重新解码,用户会明显感知到卡顿。目标是:在配置改变时避免重复处理所有图片,提供平滑过渡体验。
前面建立的内存缓存可以通过一个设置了setRetainInstance(true)的 Fragment 实例被保存下来:旋转后新的 Activity 创建时,这个被保留的 Fragment 会被重新附着,从而拿到缓存对象,直接从内存中获取图片快速恢复显示。代码示例如下:
private LruCache<String, Bitmap> mMemoryCache; @Override protected void onCreate(Bundle savedInstanceState) { ... RetainFragment retainFragment = RetainFragment.findOrCreateRetainFragment(getFragmentManager()); mMemoryCache = retainFragment.mRetainedCache; if (mMemoryCache == null) { mMemoryCache = new LruCache<String, Bitmap>(cacheSize) { ... // 像往常一样初始化缓存 } retainFragment.mRetainedCache = mMemoryCache; } ... } class RetainFragment extends Fragment { private static final String TAG = "RetainFragment"; public LruCache<String, Bitmap> mRetainedCache; 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); } }findOrCreateRetainFragment()保证同一时间只存在一个"持有缓存"的 Fragment(通过 tag 查找),Activity 重建后取回的是同一个缓存实例。
验证方式:分别在有/无保留 Fragment 的情况下旋转屏幕,对比恢复速度。保留缓存时,从内存缓存重新绘制几乎没有延迟;内存缓存未命中的图片可能存在于磁盘缓存;两级缓存都未命中时,才走正常解码流程。
缓存之外的进阶:内存回收与 inBitmap 复用
缓存控制"存什么",而 管理Bitmap的内存使用 一课解决"怎么回收与复用",二者常配合使用:
- Android 2.3.3(API 10)及以下:Bitmap 像素数据位于 Native 内存,GC 无法及时回收。可用引用计数(
mDisplayRefCount、mCacheRefCount)+recycle()在确认不再显示且不在缓存时主动释放,注意 recycle 后绘制会抛"Canvas: trying to use a recycled bitmap"错误; - Android 3.0(API 11)及以上:像素数据移入 Dalvik 堆,GC 可统一管理。推荐在 LruCache 的
entryRemoved()回调中,把被淘汰的 Bitmap 软引用存入mReusableBitmaps集合,随后用BitmapFactory.Options.inBitmap在解码新图时复用旧 Bitmap 的内存,减少分配与 GC 压力。Android 4.4(API 19)之前仅允许复用尺寸完全一致的 Bitmap,4.4 起只要新图字节数不超过候选图getAllocationByteCount()即可复用。
此外,当应用整体内存紧张时,可借助onTrimMemory()回调主动清空 LruCache(如TRIM_MEMORY_UI_HIDDEN、TRIM_MEMORY_RUNNING_LOW等级别),相关内容参见 管理应用的内存。
总结
在 Android 官方培训课程的这套 Bitmap 缓存方案中,三层架构各司其职:
- 采样解码层:
inSampleSize控制单图内存占用(load-bitmap.md); - 异步处理层:AsyncTask + WeakReference + AsyncDrawable 控制并发正确性(process-bitmap.md);
- 缓存层(本文):LruCache 负责近期热图的内存快速命中,DiskLruCache 负责冷图与进程被杀后的持久化兜底,RetainFragment 负责配置改变时的不间断缓存。
将三者综合应用到 ViewPager、GridView 的完整范例见 在UI上显示Bitmap,其核心就是在getView()中调用loadBitmap(),先查内存缓存、再查磁盘缓存、最后后台解码并回写两级缓存。遵循这套模式,即可在大图、大量图片、频繁滑动与屏幕旋转的严苛场景下,获得流畅且不崩溃的图片加载体验。
- 文档
- 教程
- 移动开发
【免费下载链接】android-training-course-in-chinese
Android官方培训课程中文版
相关推荐
VirtualApp图片加载缓存策略:内存与磁盘缓存
VirtualApp图片加载缓存策略:内存与磁盘缓存 在Android应用开发中,图片加载与缓存管理直接影响用户体验和系统性能。VirtualApp作为轻量级沙
移动开发虚拟化VirtualAPK资源加载缓存:内存缓存与磁盘缓存策略
VirtualAPK资源加载缓存:内存缓存与磁盘缓存策略 你是否在开发Android插件化应用时遇到过资源加载缓慢、内存占用过高的问题?VirtualAPK作为
移动开发插件系统5分钟上手Nornir:从安装到执行第一个设备管理任务的完整教程
5分钟上手Nornir:从安装到执行第一个设备管理任务的完整教程 Nornir是一款功能强大的可插拔多线程框架,专为设备管理任务设计,提供高效的库存管理能力。本
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考