网络 - 缓存

一、概念

当某些网络访问获取的内容不是每次都变的,而是短时间不变的(每月榜单)或长时间不变的(歌曲的信息),每次访问都联网获取的话,可能响应慢,不支持离线浏览,浪费用户和公司的流量费用和带宽占用。因此将已请求的内容存储在本地(内存或磁盘)用于后续复用。

|--------|-----------------------------------------------------------------------|
| 服务端侧缓存 | 当客户端重复访问一张图片地址时,服务器会判断这个请求有没有缓存,有的话就直接返回掉这个请求,而不是到达真正的服务器地址,依次减轻运算压力。 |
| 客户端侧缓存 | 客户端将服务器返回的数据缓存在本地,再次访问同一地址的时候,客户端会检测本地是否有缓存,如果有且未过期就直接使用缓存内容。 |

1.1 客户端缓存方式

内存缓存详见:LruCache。磁盘缓存详见:DiskLruCache

|--------|-----------------------------------------------------------|---------------------------|
| 内存缓存 | 读写速度最快,但容量有限,进程销毁后失效。 | 频繁访问的小数据(首页列表、用户信息)。 |
| 磁盘缓存 | 容量大,读写速度较慢,进程销毁后仍存在。 | 不频繁变动的大数据(图片、离线数据包)。 |
| HTTP缓存 | 基于HTTP协议标准(如 Cache-Control 头),由网络库(OkHttp)自动管理。通常也是存在磁盘上。 | 符合HTTP缓存规则的接口响应(GET请求结果)。 |

二、 非 HTTP 缓存

非 HTTP 缓存适用于复杂数据结构、非GET请求结果,需设计合理的 key 和过期策略。key需唯一标识缓存数据,确保"相同请求对应相同key,不同请求对应不同key"。需根据数据更新频率和重要性设置过期规则。

2.1 key命名

key需唯一标识缓存数据,确保"相同请求对应相同key,不同请求对应不同key"。

|--------|------------------------------------------------|
| 基于请求参数 | 对于接口请求,用URL + 方法 + 参数组合生成key(避免因参数不同导致缓存冲突)。 |
| 哈希优化 | 若key过长(如URL+参数复杂),可通过MD5/SHA-1哈希缩短key(避免存储冗余)。 |

2.2 过期策略

|---------------|------------------------------------------------------------------------------|
| 时间戳过期 | 存储缓存时记录时间戳,读取时判断是否超过有效期(如1小时),适用于大多数场景。 |
| 版本号过期 | 为数据设置版本号(如服务器返回verson=1.0),缓存时存储版本号,当服务器版本更新时,本地缓存失效,适用于数据更新有明确标识的场景(如配置文件)。 |
| LRU(最近最少使用)淘汰 | 当缓存容量达到上限时,淘汰最近最少使用的缓存(内存常用)。 |

2.3 离线加载策略

无论网络状态如何,先尝试加载本地缓存展示,再在有网络时请求最新数据并更新缓存和UI。

2.4 缓存与网络数据同步策略

|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| 以网络数据为准 | 在线时请求网络,无论缓存是否存在,均用网络数据更新缓存和UI确保数据最新,适用于实时性要求高的场景(如订单状态)。 |
| 增量更新 | 网络请求只返回变化的数据(如新增/修改的条目),本地缓存合并增量数据(减少传输量),例如: 服务器返回{ "added": [...], "updated": [...], "deletedIds": [...] } ,客户端合并到本地缓存列表:新增条目添加、更新条目替换、删除条目移除。 |
| 冲突处理 | 若缓存数据被本地修改(如用户编辑未提交),网络请求返回新数据时,需提示用户选择保留本地修改或覆盖(如"有新数据,是否更新?")。 |
| 后台同步 | 对于非紧急数据(如用户历史记录),可在App启动或网络恢复时,后台静默请求网络并更新缓存,不干扰用户操作。 |

2.5 大数据缓存优化

2.5.1 分页加载

将大列表按分页参数(如page=1&size=20)拆分缓存,避免一次性缓存整个列表导致的内存/磁盘占用过高。 缓存key包含分页参数(如news_list_page1_size20),只缓存用户已浏览的页(如当前页、上一页、下一页),淘汰更早的页(LRU策略)。

Kotlin 复制代码
// 缓存第1页数据  newsCache.saveCache("news_page1", page1Data)
// 缓存第2页数据  newsCache.saveCache("news_page2", page2Data)
// 加载第3页时,若缓存满,淘汰第1页(LRU)  

2.5.2 增量更新

只缓存变化的数据,而非全量数据,减少重复存储。

|----|------------------------------------------------------------------------------------|
| 列表 | 服务器返回数据时携带"版本号"或"修改时间",客户端仅缓存版本号大于本地数据的。 |
| 图片 | 缓存不同分辨率的图片(缩略图、中图、原图),根据展示场景加载(如列表用缩略图,详情用原图),对于渐进式图片(如WebP),可先缓存低质量版本,再异步缓存高质量版本。 |

Kotlin 复制代码
//服务器返回增量数据,客户端合并增量到本地缓存,无需重新缓存整个列表。
{
    "version": 5,
    "added": [{"id": 101, "content": "..."}], // 新增条目
    "updated": [{"id": 10, "content": "..."}], // 更新条目
    "deletedIds": [5, 8] // 删除条目ID
}

2.5.3 压缩存储

|----|--------------------------------------------------------------------------------|
| 列表 | 用高效序列化方式(如Protocol Buffers替代JSON),减少磁盘占用(通常比JSON小30%-50%)。 |
| 图片 | 存储压缩格式(如WebP,比JPEG小25%-35%),按显示尺寸压缩(如ImageView大小为200x200,缓存200x200的图片,而非原图尺寸)。 |

2.5.4 预热与预加载

|-----|-----------------------------------------------|
| 预热 | App启动时,提前缓存高频访问的基础数据(如首页Banner、分类列表)。 |
| 预加载 | 用户浏览当前页时,后台预加载下一页数据(如滑动列表到80%时,预加载下一页),提升流畅度。 |

2.5.5 内存缓存限制

对大体积数据(如图片),内存缓存设置严格的容量上限(如最多缓存20张图片),避免OOM。优先缓存最近访问的图片(LRU策略),后台自动释放内存(如onTrimMemory时清理部分缓存)。

2.5.6 异步写入

大体积数据的磁盘写入(如图片)放在后台线程,避免阻塞UI线程。

三、HTTP缓存

OkHttp 基于 HTTP 协议规范(RFC 7234)通过 Cache 类实现磁盘缓存。当发起GET请求时,会将服务器返回的响应按规则存储到磁盘(默认路径:/data/data/包名/cache/okhttp),后续发起相同请求时会先检查本地缓存。

  1. 缓存未过期:直接使用缓存,不发起网络请求。
  2. 缓存已过期:发起"条件请求",携带 If-Modified-Since 或 If-None-Match 头,服务器判断缓存是否有效:
    1. 有效(返回 304 Not Modified):使用本地缓存。
    2. 无效(返回 200 OK):更新缓存并使用新响应。

3.1 配置缓存目录和大小

设置好Cache后,同一个地址访问两次打印Log:

  1. 第一次访问的response.networkResponse( )有内容(来自网络)而response.cacheResponse( ) = null;
  2. 第二次访问相反,response.cacheResponse( )有内容(来自缓存)而response.networkResponse( ) = null。
Kotlin 复制代码
//缓存目录
private val cacheFile = File(APP.context.externalCacheDir, "HttpCache")
//缓存大小100mb
private val cacheSize = (100 * 1024 * 1024).toLong()
//创建Cache类
private val cache = Cache(cacheFile, cacheSize)
//配置到OkHttpClient
private val okHttpClient = OkHttpClient.Builder()
    .cache(cache)    //关联缓存
    .build()

3.2 配置缓存策略

缓存策略主要通过HTTP请求头/响应头的Cache-Control字段控制。

|------|-------------------------------------------------|
| 固定配置 | Retrofit 可以对接口方法使用 @Headers 注解配置 Cache-Control。 |
| 灵活配置 | 可根据网络状态、URL类型等进行动态配置。 |

3.2.1 通过请求头全局控制(自行配置Request)

CacheControl 类中提供了两个已经定义好的,通过伴生对象调用,可根据有无网络访问来返回其一。也可以根据需求通过 CacheControl.Builder() 自定义,或是直接配置 header。

|----------------------------|----------|
| CacheControl.FORCE_NETWORK | 只获取网络数据。 |
| CacheControl.FORCE_CACHE | 只读取本地缓存。 |

|-----------------------------------|------------------|
| noStore() | 不使用缓存,也不存储缓存。 |
| onlyIfCached() | 只使用缓存。 |
| noTransform() | 禁止转码。 |
| maxAge(10, TimeUnit.MILLISECONDS) | 超时时间为10ms。 |
| maxStale(10, TimeUnit.SECONDS) | 超时之外的超时时间为10s。 |
| minFresh(10, TimeUnit.SECONDS) | 超时时间为当前时间加上10秒钟。 |

Kotlin 复制代码
private val cacheRequestIntercept = object : Interceptor{
    val cacheControl = CacheControl.Builder()
        .maxAge(60, TimeUnit.SECONDS)    //缓存缓存有效期60秒
        .build()
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().newBuilder()
//              .header("Cache-Control", "public, max-age=1800")    //也可通过header配置
//              .cacheControl(CacheControl.FORCE_CACHE)    //使用定义好的CacheControl
            .cacheControl(cacheControl)    //使用自定义的CacheControl
            .build()
        val response = chain.proceed(request)
        return response
    }
}

//配置到OkHttpClient
private val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(cacheRequestIntercept)   //添加应用拦截器(走缓存不会调用网络拦截器)
    .build()

简写

Kotlin 复制代码
private val cacheRequestIntercept = Interceptor { chain ->
    val cacheControl = CacheControl.Builder()
        .maxAge(60, TimeUnit.SECONDS)
        .build()
    val request = chain.request().newBuilder()
        .cacheControl(cacheControl)
        .build()
    chain.proceed(request)
}

3.2.2 通过响应头全局控制(自行配置Response)

服务器通过响应头的Cache-Control指定缓存过期时间,客户端在过期前直接使用缓存,不向服务器发起请求。也可以通过拦截器自定义来覆盖。

|---------------------|----------------------------------------------|
| max-age=<seconds> | 缓存有效期(秒),从响应生成时间开始计算,例如max-age=3600表示缓存1小时。 |
| public/private | public表示缓存可被共享(如代理服务器),private表示仅客户端可缓存(默认)。 |
| immutable | 缓存数据不会变化,客户端无需验证(适用于静态资源)。 |

Kotlin 复制代码
private val cacheResponseIntercept = object : Interceptor{
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        //仅针对GET请求设置缓存
        val response = if(request.method == "GET") {
            chain.proceed(request).newBuilder()
                .removeHeader("pragma")    //移除干扰缓存的头(如no-cache)
                .header("Cache-Control","max-age=60")
                .build()
        } else {
            chain.proceed(request)
        }
        return response
    }
}

//配置到OkHttpClient
private val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(cacheResponseIntercept)   //添加应用拦截器(走缓存不会调用网络拦截器)
    .build()

简写

Kotlin 复制代码
private val cacheResponseIntercept = Interceptor { chain ->
    val request = chain.request()
    if (request.method == "GET") {
        chain.proceed(request).newBuilder()
            .removeHeader("pragma")
            .header("Cache-Control","max-age=60")
            .build()
    } else {
        chain.proceed(request)
    }
}

3.2.3 为单个请求固定控制

Kotlin 复制代码
//缓存1小时(强制缓存)
//@Headers("Cache-Control: public, max-age=3600")
//禁止缓存(每次都请求最新数据)
//@Headers("Cache-Control: no-store")
//仅离线时使用缓存(在线时必须请求网络)
@Headers("Cache-Control: public, only-if-cached, max-stale=3600")
@GET("user/{userId}") 
suspend fun getUserById(@Path("userId"): User

3.2.4 根据条件灵活控制

根据网络状态

Kotlin 复制代码
private val cacheIntercepter = Interceptor { chain ->
    val request = if (NetUtils.NO_NETWORK) {
        //无网络,检查30天内的缓存,即使是过期的缓存
        chain.request().newBuilder()
            .cacheControl(CacheControl.Builder().maxAge(60, TimeUnit.SECONDS).build())
            .build()
    } else {
        //有网络,检查10秒内的缓存
        chain.request().newBuilder()
            .cacheControl(CacheControl.Builder().maxAge(60, TimeUnit.SECONDS).build())
            .build()
    }
    chain.proceed(request)
}

根据URL类型

Kotlin 复制代码
private val cacheIntercepter = Interceptor { chain ->
    val request = chain.request()
    val url = request.url.toString()
    if (url.contains("/news/")) {
        //新闻列表缓存3分钟
        chain.proceed(request).newBuilder()
            .header("Cache-Control", "public, max-age=180")
            .build()
    } else if (url.contains("/user/")){
        //用户信息不缓存
        chain.proceed(request).newBuilder()
            .header("Cache-Control", "no-cache")
            .build()
    } else {
        chain.proceed(request)
    }
}
相关推荐
xiangpanf7 小时前
Laravel 10.x重磅升级:五大核心特性解析
android
robotx10 小时前
安卓线程相关
android
消失的旧时光-194310 小时前
Android 面试高频:JSON 文件、大数据存储与断电安全(从原理到工程实践)
android·面试·json
dalancon11 小时前
VSYNC 信号流程分析 (Android 14)
android
dalancon11 小时前
VSYNC 信号完整流程2
android
dalancon11 小时前
SurfaceFlinger 上帧后 releaseBuffer 完整流程分析
android
用户693717500138412 小时前
不卷AI速度,我卷自己的从容——北京程序员手记
android·前端·人工智能
程序员Android13 小时前
Android 刷新一帧流程trace拆解
android
墨狂之逸才14 小时前
解决 Android/Gradle 编译报错:Comparison method violates its general contract!
android
阿明的小蝴蝶14 小时前
记一次Gradle环境的编译问题与解决
android·前端·gradle