11、缓存策略:OkHttp缓存配置、Retrofit离线缓存、结合Room实现本地持久化缓存
缓存这东西,说白了就是「用空间换时间」。
我在早期做项目时,总觉得缓存是锦上添花。直到有一次用户反馈:「你们的App在电梯里根本刷不出数据」。嗯,那次之后,我把缓存策略当成了网络层的核心模块来设计。
今天我们就来聊聊,Android网络层怎么把缓存玩明白。从OkHttp的磁盘缓存,到Retrofit的离线兜底,再到Room做持久化——这三层组合起来,基本能覆盖90%的缓存场景。
核心思路:网络层缓存不是「存不存」的问题,而是「什么时候存、什么时候读、什么时候清」的问题。
11.1 OkHttp缓存配置:最基础的磁盘缓存
OkHttp自带了缓存能力。你不需要额外引入库,只需要配置一个Cache对象。
我个人习惯这样配:
val cacheDir = File(context.cacheDir, "http_cache")
val cache = Cache(cacheDir, 10 * 1024 * 1024) // 10MB
val okHttpClient = OkHttpClient.Builder()
.cache(cache)
.addInterceptor { chain ->
val request = chain.request()
// 这里可以按需添加缓存控制头
chain.proceed(request)
}
.build()
这里有个坑——缓存目录要选对。我曾经见过有人把缓存放在外部存储,结果用户一清理SD卡,缓存全没了。建议用context.cacheDir,系统会自动管理。
OkHttp的缓存策略依赖HTTP响应头。服务端返回的Cache-Control字段决定了缓存行为:
| 响应头 | 含义 | 示例 |
|---|---|---|
max-age=3600 |
缓存1小时 | 适合不常变的数据 |
no-cache |
每次都要验证 | 适合需要实时性的数据 |
no-store |
完全不缓存 | 敏感信息 |
public |
允许中间代理缓存 | CDN场景 |
小技巧:如果服务端没返回Cache-Control,你可以在拦截器里手动添加。比如:
chain.proceed(request).newBuilder()
.header("Cache-Control", "max-age=60")
.build()
11.2 Retrofit离线缓存:没有网络也能看
OkHttp的缓存有个问题——它只在有网时生效。如果完全离线,OkHttp会直接抛异常。
那怎么办?我们需要一个「离线兜底」策略。
我的做法是:在拦截器里判断网络状态。没网时,强制走缓存。
class OfflineCacheInterceptor(
private val isOnline: () -> Boolean
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!isOnline()) {
// 离线时,强制使用缓存
val cacheRequest = request.newBuilder()
.header("Cache-Control", "only-if-cached")
.build()
val response = chain.proceed(cacheRequest)
// 如果缓存命中,直接返回
if (response.code == 504) {
// 缓存未命中,返回一个自定义的离线响应
return Response.Builder()
.code(200)
.message("Offline")
.body(ResponseBody.create(null, "{}"))
.request(request)
.protocol(Protocol.HTTP_1_1)
.build()
}
return response
}
return chain.proceed(request)
}
}
这段代码的核心是only-if-cached。它告诉OkHttp:「我只用缓存,别去请求网络」。如果缓存没命中,OkHttp会返回504。这时候我们可以返回一个兜底数据。
注意:离线缓存只适合「读」操作。写操作(POST/PUT/DELETE)在离线时应该提示用户,或者存入本地队列。
11.3 结合Room:真正的持久化缓存
OkHttp的缓存有个硬伤——它存的是HTTP响应体,不是结构化数据。你没法按条件查询,也没法做增量更新。
所以,对于核心数据,我建议用Room做持久化缓存。
先定义实体:
@Entity(tableName = "cache_entries")
data class CacheEntry(
@PrimaryKey val key: String, // 缓存键,比如 "user_123"
val data: String, // JSON字符串
val timestamp: Long, // 缓存时间
val expiresAt: Long // 过期时间
)
然后封装一个缓存管理器:
class CacheManager(private val dao: CacheDao) {
suspend fun get(key: String): String? {
val entry = dao.getByKey(key) ?: return null
if (System.currentTimeMillis() > entry.expiresAt) {
dao.delete(entry)
return null
}
return entry.data
}
suspend fun put(key: String, data: String, ttl: Long = 3600_000L) {
val entry = CacheEntry(
key = key,
data = data,
timestamp = System.currentTimeMillis(),
expiresAt = System.currentTimeMillis() + ttl
)
dao.insert(entry)
}
suspend fun clear() {
dao.deleteAll()
}
}
在Repository层,我们可以这样用:
class UserRepository(
private val api: UserApi,
private val cacheManager: CacheManager
) {
suspend fun getUser(id: String): User {
// 先读缓存
val cached = cacheManager.get("user_$id")
if (cached != null) {
return Json.decodeFromString(cached)
}
// 缓存没命中,请求网络
val user = api.getUser(id)
// 写入缓存,TTL设为5分钟
cacheManager.put("user_$id", Json.encodeToString(user), 300_000L)
return user
}
}
为什么不用Room做离线缓存?因为Room的查询是异步的,而OkHttp的拦截器是同步的。两者混用容易出问题。我的建议是:
- OkHttp缓存:适合「临时性」的页面缓存,比如图片、列表数据
- Room缓存:适合「结构化」的业务数据,比如用户信息、配置项
11.4 三层缓存架构图
下面这张图,是我在项目中实际使用的缓存架构。你可以看到数据是怎么流动的:
11.5 缓存过期策略
缓存最怕什么?怕「脏数据」。用户看到的是旧信息,体验极差。
我常用的过期策略有三种:
- TTL过期:设置固定有效期,到期自动清除。适合新闻列表、商品信息。
- 版本号过期:服务端返回数据版本号,本地版本号不一致时强制刷新。适合配置项、协议数据。
- 手动清除:用户主动下拉刷新时,清掉缓存重新请求。
举个例子,TTL过期在Room里怎么实现:
@Dao
interface CacheDao {
@Query("SELECT * FROM cache_entries WHERE key = :key AND expiresAt > :now")
suspend fun getValidEntry(key: String, now: Long): CacheEntry?
@Query("DELETE FROM cache_entries WHERE expiresAt < :now")
suspend fun deleteExpired(now: Long)
}
然后在后台定期清理:
fun startCleanupJob() {
CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
delay(60_000L) // 每分钟清理一次
dao.deleteExpired(System.currentTimeMillis())
}
}
}
我的习惯:对于用户敏感数据(比如登录态),我从来不做缓存。每次启动都重新请求。安全第一,性能第二。
11.6 避坑指南
做缓存这么多年,踩过的坑不少。挑几个典型的说说:
- 缓存穿透:某个key的缓存一直没命中,每次都查数据库。解决方案是「布隆过滤器」或者「空值缓存」。
- 缓存雪崩:大量缓存同时过期,导致请求全部打到后端。解决方案是「随机过期时间」。
- 缓存一致性:数据更新后,缓存没同步。解决方案是「先更新数据库,再删除缓存」。
我曾经遇到过一个问题:用户修改了头像,但App里还是旧头像。排查后发现是OkHttp缓存了图片响应,而服务端没更新ETag。最后我们在拦截器里加了强制校验,才解决。
嗯,缓存这东西,看似简单,但真要做得「又快又准」,还是得花心思的。
公众号:蓝海资料掘金营,微信deep3321