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 三层缓存架构图

下面这张图,是我在项目中实际使用的缓存架构。你可以看到数据是怎么流动的:

三层缓存架构 内存缓存 LruCache / 弱引用 OkHttp磁盘缓存 HTTP响应体缓存 Room持久化缓存 结构化数据 / SQLite 回读 回读 缓存策略说明 • 内存缓存:最快,App重启丢失 • OkHttp缓存:较快,可跨进程 • Room缓存:最慢,但最可靠 读取顺序: 内存 → OkHttp → Room 写入顺序: Room → OkHttp → 内存 数据流方向:写入时从上到下,读取时从下到上 每一层都可以独立配置过期时间和清理策略

11.5 缓存过期策略

缓存最怕什么?怕「脏数据」。用户看到的是旧信息,体验极差。

我常用的过期策略有三种:

  1. TTL过期:设置固定有效期,到期自动清除。适合新闻列表、商品信息。
  2. 版本号过期:服务端返回数据版本号,本地版本号不一致时强制刷新。适合配置项、协议数据。
  3. 手动清除:用户主动下拉刷新时,清掉缓存重新请求。

举个例子,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