第2章 缓存类型与适用场景:内存缓存、磁盘缓存、网络缓存、二级缓存、三级缓存的选择原则
主线程缓存读写防 ANR 的核心挑战在于:在微秒级响应窗口内,选对数据源。不同缓存类型在延迟、容量、持久性和一致性上差异巨大,选错类型会导致主线程卡顿甚至 ANR。本章逐一拆解 5 种缓存类型的内部原理、适用边界,并给出可量化的选择原则。
2.1 缓存类型全景
从主线程视角看,缓存本质是用空间换时间的数据副本。按存储介质与访问路径,分为三个基础类型和两个组合类型:
| 类型 | 存储介质 | 典型延迟 | 容量上限 | 持久性 | ANR 敏感度 |
|---|---|---|---|---|---|
| 内存缓存 | 堆内存 / Native 内存 | < 1 ms | 应用堆的 1/8 ~ 1/4 | 进程结束即丢失 | 极高(阻塞直接卡死) |
| 磁盘缓存 | 闪存 / SD 卡 | 10 ~ 100 ms | 数 MB ~ 数百 MB | 跨进程/重启保留 | 中(异步写可缓解) |
| 网络缓存 | 远程服务器 | 100 ms ~ 数秒 | 理论上无限 | 服务端控制 | 极高(网络抖动直接 ANR) |
| 二级缓存 | 内存 + 磁盘 | 组合延迟 | 两者之和 | 部分持久 | 中(命中率决定) |
| 三级缓存 | 内存 + 磁盘 + 网络 | 组合延迟 | 理论无限 | 完全持久可选 | 低(逐级降级) |
2.2 内存缓存(Memory Cache)
原理:数据以对象引用形式驻留在应用进程堆或 Native 堆中,主线程通过指针直接读取,无 I/O 调度开销。
实现流程:
- 确定缓存容量(通常取 Runtime.maxMemory() 的 1/8)
- 实现淘汰策略(LRU、LFU、FIFO)
- 绑定生命周期(随 Activity/Fragment 或 Application)
- 读写时加轻量锁或使用并发容器
核心 API 示例(LruCache):
// 计算可用内存并分配缓存
int maxMem = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMem / 8;
LruCache<String, Bitmap> memCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount() / 1024;
}
};
// 主线程读取(直接返回,无锁竞争)
Bitmap result = memCache.get("avatar_001");
if (result != null) {
imageView.setImageBitmap(result);
}
适用场景:高频小数据、UI 资源(图标、缩略图)、配置对象。注意:不要在内存缓存中存放超大 Bitmap 或集合,否则触发 GC 导致主线程卡顿。
2.3 磁盘缓存(Disk Cache)
原理:数据序列化后写入闪存文件系统,依赖 File I/O 和系统 Page Cache。主线程直接读磁盘可能因 I/O 调度产生 10~100ms 毛刺。
实现流程:
- 选择缓存目录(context.getCacheDir() 或自定义路径)
- 确定最大空间(通常 5~50 MB)
- 使用 DiskLruCache 或自研文件索引
- 写操作切到子线程,读操作允许主线程但需超时保护
核心 API 示例(DiskLruCache):
// 打开磁盘缓存
DiskLruCache diskCache = DiskLruCache.open(
cacheDir, // 缓存目录
1, // app 版本号
1, // 每个 key 对应 value 个数
50 * 1024 * 1024 // 最大 50MB
);
// 主线程读取(需 try-catch 防止 FileNotFoundException)
String key = hashKeyForDisk("avatar_001");
DiskLruCache.Snapshot snapshot = diskCache.get(key);
if (snapshot != null) {
InputStream in = snapshot.getInputStream(0);
Bitmap bitmap = BitmapFactory.decodeStream(in);
// 直接使用 bitmap
snapshot.close();
}
适用场景:大图片、离线资源、持久化配置。警告:主线程读磁盘必须设超时(如 20ms),超时后降级到异步回调或默认值。
Looper.getMainLooper().getThread() 监控耗时,超过阈值切换异步。
2.4 网络缓存(Network Cache)
原理:依赖 HTTP 缓存头(Cache-Control、ETag)或客户端本地副本,避免重复请求。主线程直接发起网络请求是 ANR 重灾区。
实现流程:
- 配置 OkHttp / Retrofit 内置缓存
- 设置缓存目录与最大大小
- 服务端配合返回 Cache-Control 头
- 主线程只读缓存副本,网络请求强制切子线程
配置示例(OkHttp 缓存):
// 构建 OkHttp 客户端时配置缓存
File cacheDir = new File(context.getCacheDir(), "http_cache");
Cache networkCache = new Cache(cacheDir, 10 * 1024 * 1024); // 10MB
OkHttpClient client = new OkHttpClient.Builder()
.cache(networkCache)
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build();
// 主线程获取缓存(仅读本地磁盘缓存副本)
Request request = new Request.Builder()
.url("https://example.com/avatar_001.png")
.build();
Response response = client.newCall(request).execute(); // 切勿在主线程调用!
适用场景:API 响应、远程图片、配置文件。硬规则:主线程永远不执行网络请求,只尝试读取磁盘缓存副本;无缓存时立即返回占位并通过异步加载。
2.5 二级缓存与三级缓存组合策略
单一缓存类型无法同时满足速度、容量和持久性,实际工程中采用分层组合:
| 组合模式 | 层级 | 读取路径 | 写入策略 | 典型命中率 |
|---|---|---|---|---|
| 二级缓存 | L1 内存 + L2 磁盘 | 读:L1 → L2 → 异步网络 | 写:L1 同步 + L2 异步 | L1 60% + L2 30% |
| 三级缓存 | L1 内存 + L2 磁盘 + L3 网络 | 读:L1 → L2 → L3 | 写:L1 同步 + L2 异步 + L3 按需 | L1 45% + L2 35% + L3 20% |
二级缓存实现骨架:
public class L2Cache<K, V> {
private final LruCache<K, V> memCache; // L1
private final DiskLruCache diskCache; // L2
public V get(K key) {
// 1. 查内存缓存(0.1ms)
V value = memCache.get(key);
if (value != null) return value;
// 2. 查磁盘缓存(10~50ms),回填内存
String diskKey = key.toString();
DiskLruCache.Snapshot snap = diskCache.get(diskKey);
if (snap != null) {
value = deserialize(snap.getInputStream(0));
memCache.put(key, value);
return value;
}
// 3. 未命中,返回 null 由调用方决定是否异步加载
return null;
}
}
三级缓存读取决策:主线程优先命中 L1,L1 miss 后查 L2(磁盘),L2 miss 后通过异步任务查 L3(网络),网络回写 L2 和 L1。三级缓存是 ANR 防护的最终防线:即使磁盘 I/O 抖动,主线程仍可快速返回 L1 旧数据。
2.6 缓存选择原则与决策矩阵
选择缓存类型时,使用3W1H 决策矩阵量化评估:
| 决策维度 | 权重 | 内存缓存 | 磁盘缓存 | 网络缓存 |
|---|---|---|---|---|
| 访问频次 | 高 (0.4) | ★★★★★ | ★★★ | ★ |
| 数据体积 | <