首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >数据压缩与缓存策略:把带宽用到极致

数据压缩与缓存策略:把带宽用到极致

作者头像
陆业聪
发布2026-05-18 12:54:17
发布2026-05-18 12:54:17
1040
举报

📚 Android网络优化系列 · 第4/5篇

从DNS到连接池,打造极速网络体验

✅ 第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱

✅ 第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路

✅ 第3篇:连接优化与复用:让每一次握手都物超所值

👉 第4篇:数据压缩与缓存策略:把带宽用到极致(本篇)

⏳ 第5篇:网络监控与容灾:让网络问题无处遁形

📰 科技要闻

• Android Weekly #725 讨论了 LangGraph Agent 在移动端的集成——Agent调用后端动辄几十KB的JSON响应,压缩的价值比以往更大。今天聊的Protocol Buffers方案,天然适配这类场景。

• ProAndroidDev 新文 "Voice AI on Android: Beyond Speech-to-Text"——语音AI产生的数据流量惊人(音频+中间结果+最终文本),增量传输和流式压缩是刚需。

• Kotlin Blog 发布 OpenTelemetry 深度集成教程——没有可观测性的缓存策略就是薛定谔的优化。本篇的缓存命中率监控方案和OTel指标体系一脉相承。

从一笔"流量账"说起

前三篇我们搞定了DNS解析(P99从2100ms→180ms)、连接建立(复用率从62%提升到91%)。"找到服务器"和"连上服务器"的成本已经压到了极致,但新的瓶颈暴露出来了——数据传输本身

我们App的首屏需要请求6个接口,平均每个接口返回85KB的JSON。6×85=510KB。在4G理想带宽(10Mbps下行)下传输约0.4秒尚可接受,但在3G网络(1Mbps)下就是4秒——加上DNS+握手的时间,首屏轻松破5秒。Google的数据显示,3秒不出内容就会流失53%的移动用户。

更扎心的是:这510KB里,有多少数据是根本不需要传的

我们做了一周的线上流量审计,结论触目惊心:

流量浪费审计结果

• 38%的接口响应体与上次完全相同(用户刷新但数据没变)

• 25%的字段客户端从未读取过(后端一股脑全返回)

• 同一份JSON未经压缩直接传输,体积是Gzip后的4-6倍

• 图片未适配屏幕分辨率,750px宽的手机加载2000px的原图

简单概括:我们的带宽有60%以上浪费在"传了不该传的东西"和"传了没压缩的东西"上。今天的主题就是消灭这些浪费:让该传的数据更小(压缩),让不该传的数据根本不传(缓存)

协议层压缩:Gzip vs Brotli vs Protocol Buffers

数据压缩的第一步在传输层。三种主流方案各有适用场景,不是简单的"新的就好"。

Gzip:老兵不死

OkHttp默认会在请求头加上 Accept-Encoding: gzip,服务端返回gzip压缩的响应后自动透明解压。你不需要任何配置,它就在工作。但很多团队踩了个坑:手动设了Content-Length或者自己拼了Accept-Encoding,反而把OkHttp的自动解压搞坏了。

代码语言:javascript
复制
// ❌ 错误示范:手动设Accept-Encoding会关闭OkHttp的自动解压
val badRequest = Request.Builder()
.url("https://api.example.com/data")
.header("Accept-Encoding", "gzip") // 这会禁用OkHttp自动解压!
.build()
// 你需要手动用GzipSource解压response.body——何必呢?// ✅ 正确做法:什么都不做,OkHttp自动搞定
val goodRequest = Request.Builder()
.url("https://api.example.com/data")
.build()
// OkHttp自动加Accept-Encoding: gzip
// 自动检测Content-Encoding: gzip并解压
// response.body()返回的就是解压后的数据

Gzip对JSON的压缩率通常在70-80%。也就是说85KB的JSON,传输时只有17-25KB。几乎白给的收益。

Brotli:更高压缩率的代价

Brotli(Google出品)在同等CPU消耗下比Gzip多压缩15-25%。85KB的JSON用Gzip压到20KB,Brotli能压到15KB。但OkHttp不像Gzip那样自动支持,需要引入额外依赖:

代码语言:javascript
复制
// build.gradle.kts
implementation("org.brotli:dec:0.1.2")// Brotli解压拦截器
class BrotliInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept-Encoding", "br, gzip") // 优先Brotli,降级Gzip
.build()
val response = chain.proceed(request)
val encoding = response.header("Content-Encoding")
if (encoding == "br") {
val body = response.body ?: return response
val decompressed = BrotliInputStream(body.byteStream())
val newBody = decompressed.readBytes()
.toResponseBody(body.contentType())
return response.newBuilder()
.removeHeader("Content-Encoding")
.body(newBody)
.build()
}
return response
}
}val client = OkHttpClient.Builder()
.addInterceptor(BrotliInterceptor())
.build()

什么时候该用Brotli?响应体大、变化频率低的场景——比如配置下发、离线包、WebView资源。对于小体量的API响应(<10KB),Brotli相比Gzip的绝对收益可能只有几百字节,不值得引入额外依赖。

Protocol Buffers:从根上减少数据量

Gzip和Brotli是"把大文件压小",Protocol Buffers(Protobuf)是"从一开始就少生成数据"。本质区别:JSON用文本表示数据(字段名+冒号+引号+值),Protobuf用紧凑的二进制编码——字段名变成数字tag,不需要分隔符。

同一份数据:

压缩效果对比(1000条用户列表数据)

• JSON原文:850KB

• JSON + Gzip:178KB(压缩率79%)

• JSON + Brotli:142KB(压缩率83%)

• Protobuf原文:280KB(原始就比JSON小67%)

• Protobuf + Gzip:95KB(压缩率89%)

• Protobuf + Brotli:82KB(压缩率90%)

Protobuf+Gzip比JSON+Gzip小47%。对高频、大体量接口来说,这是质的差距。

代码语言:javascript
复制
// user.proto
syntax = "proto3";message UserList {
repeated User users = 1;
}message User {
int64  id        = 1;
string name      = 2;
string avatar    = 3;
int32  level     = 4;
int64  lastLogin = 5;
}// Retrofit + Protobuf配置
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(ProtoConverterFactory.create()) // Wire或官方protobuf-lite
.client(client)
.build()interface UserApi {
@GET("users")
suspend fun getUsers(): UserList // 直接反序列化成Proto对象
}

选型决策:新项目或重构时优先Protobuf(配合gRPC更佳);存量JSON接口,确保Gzip开启,大响应体再加Brotli。三者不冲突——Protobuf是数据格式,Gzip/Brotli是传输编码,可以叠加。

OkHttp缓存机制:让不变的数据根本不传

压缩解决了"传得更小"的问题。但更好的优化是根本不传——如果数据没变,为什么要再传一遍?

HTTP协议原生支持这套逻辑,OkHttp实现了完整的HTTP缓存语义。核心思路是两层验证:

强缓存:服务端通过Cache-Control头告诉客户端"这份数据X秒内都是新鲜的,直接用本地的,别来问我"。命中强缓存=零网络IO,延迟等于磁盘读取时间(通常<5ms)。

协商缓存:强缓存过期后,客户端带着ETag或Last-Modified去问服务端"我本地这份还能用吗?"。如果没变,服务端返回304 Not Modified(空body),客户端继续用本地数据。成本=一次请求的RTT,但省去了response body传输。

代码语言:javascript
复制
// 启用OkHttp磁盘缓存
val cacheDir = File(context.cacheDir, "http_cache")
val cacheSize = 50L * 1024 * 1024 // 50MB
val cache = Cache(cacheDir, cacheSize)val client = OkHttpClient.Builder()
.cache(cache)
.build()// 就这么简单。OkHttp会自动:
// 1. 解析响应的Cache-Control / Expires / ETag / Last-Modified
// 2. 缓存满足条件的GET响应到磁盘
// 3. 后续请求自动判断是走强缓存还是协商缓存

CacheControl的精细控制

但现实不是每个接口都适合同一套缓存策略。用户信息10分钟缓存合理,但消息列表可能需要实时。OkHttp通过CacheControl类让你在请求级别精细控制:

代码语言:javascript
复制
/**
* 分场景缓存策略
*/
object CacheStrategy {// 配置类接口:强缓存1小时,过期后协商
val CONFIG = CacheControl.Builder()
.maxAge(1, TimeUnit.HOURS)
.build()// 列表类接口:强缓存30秒,保证基本不闪白屏
val LIST = CacheControl.Builder()
.maxAge(30, TimeUnit.SECONDS)
.build()// 实时接口:跳过强缓存,但仍走协商缓存(304)
val REALTIME = CacheControl.Builder()
.maxAge(0, TimeUnit.SECONDS) // 等于must-revalidate
.build()// 完全不缓存(支付、验证码等敏感接口)
val NO_CACHE = CacheControl.FORCE_NETWORK
}// 使用方式
val configRequest = Request.Builder()
.url("https://api.example.com/config")
.cacheControl(CacheStrategy.CONFIG)
.build()val feedRequest = Request.Builder()
.url("https://api.example.com/feed")
.cacheControl(CacheStrategy.LIST)
.build()

离线兜底:stale-if-error + FORCE_CACHE

缓存还有一个杀手级用途——网络不可用时提供降级体验。用户在地铁里打开App,没有网络,总比白屏强:

代码语言:javascript
复制
/**
* 网络感知的缓存拦截器
* 有网:正常请求(走协商缓存)
* 断网:使用过期缓存兜底
*/
class OfflineCacheInterceptor(
private val context: Context
) : Interceptor {override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()if (!isNetworkAvailable(context)) {
// 断网时:允许使用7天内的过期缓存
request = request.newBuilder()
.cacheControl(
CacheControl.Builder()
.maxStale(7, TimeUnit.DAYS)
.build()
)
.build()
}
return chain.proceed(request)
}private fun isNetworkAvailable(context: Context): Boolean {
val cm = context.getSystemService<ConnectivityManager>()
return cm?.activeNetwork != null
}
}val client = OkHttpClient.Builder()
.cache(cache)
.addInterceptor(OfflineCacheInterceptor(context)) // application拦截器
.build()

用户体验的差距:有这个拦截器,断网时打开App还能看到上次的内容(加个提示条"使用离线数据");没有的话,6个接口全部失败,白屏。

增量更新与差分传输

缓存解决了"数据没变就不传"的问题。但还有一种情况:数据变了一点点,但你把整个响应体重新传了一遍。

举个例子:用户的消息列表有100条消息,每次刷新新增了2条。按传统做法,服务端返回完整的102条。实际有效信息只有新增的2条——98%的数据是重复传输

方案一:游标分页 + 增量拉取

代码语言:javascript
复制
/**
* 增量拉取方案
* 核心:客户端告知"我本地最新的数据版本号/时间戳",服务端只返回增量部分
*/
interface MessageApi {
// 首次拉取:全量
@GET("messages")
suspend fun getMessages(
@Query("limit") limit: Int = 50
): MessageResponse// 增量拉取:只返回sinceVersion之后的数据
@GET("messages/incremental")
suspend fun getMessagesIncremental(
@Query("since_version") sinceVersion: Long,
@Query("limit") limit: Int = 100
): IncrementalResponse
}data class IncrementalResponse(
val added: List<Message>,     // 新增
val updated: List<Message>,   // 修改
val deletedIds: List<Long>,   // 删除
val currentVersion: Long     // 服务端最新版本号
)/**
* Repository层:自动判断全量/增量
*/
class MessageRepository(
private val api: MessageApi,
private val dao: MessageDao,
private val prefs: SharedPreferences
) {
suspend fun syncMessages() {
val localVersion = prefs.getLong("msg_version", 0L)if (localVersion == 0L) {
// 首次:全量拉取
val resp = api.getMessages()
dao.insertAll(resp.messages)
prefs.edit { putLong("msg_version", resp.version) }
} else {
// 后续:增量拉取
val resp = api.getMessagesIncremental(localVersion)
dao.insertAll(resp.added)
dao.updateAll(resp.updated)
dao.deleteByIds(resp.deletedIds)
prefs.edit { putLong("msg_version", resp.currentVersion) }
}
}
}

增量拉取的流量节省极其可观。我们实测:消息列表接口的平均响应体积从45KB降到3.2KB(93%的流量节省),P99响应耗时从350ms降到80ms。

方案二:二进制差分(BsDiff)用于大资源更新

对于更大块的数据——比如离线包、配置文件、客户端数据库预填充——可以用BsDiff做二进制级别的差分:

代码语言:javascript
复制
/**
* 差分更新流程
*
* 1. 客户端上报本地资源版本hash
* 2. 服务端计算diff(oldVersion, newVersion)生成patch
* 3. 客户端下载patch,本地apply还原新版本
* 4. 校验新版本hash → 成功则替换,失败则全量下载
*/
class DiffUpdateManager(
private val context: Context
) {
suspend fun updateResource(resourceId: String): Boolean {
val localFile = getLocalResource(resourceId)
val localHash = localFile?.md5() ?: ""// 请求差分包
val patchInfo = api.checkUpdate(resourceId, localHash)when {
patchInfo.status == "up_to_date" -> return true
patchInfo.patchUrl != null -> {
// 差分更新:下载patch → apply → 校验
val patch = downloadFile(patchInfo.patchUrl)
val newFile = BsDiff.patch(localFile!!, patch)
if (newFile.md5() == patchInfo.targetHash) {
replaceResource(resourceId, newFile)
return true
}
// hash不匹配:降级全量
}
}
// 全量下载(首次安装或差分失败)
val fullFile = downloadFile(patchInfo.fullUrl!!)
replaceResource(resourceId, fullFile)
return true
}
}

典型收益:一个5MB的离线包,版本间只改了200KB的内容,差分patch只有180KB。下载5MB变成下载180KB——流量节省96%。在弱网环境下,这是"能不能更新成功"的关键区别。

离线优先架构:Room + WorkManager + 网络同步

前面的缓存方案是"有网时优化、断网时兜底"。但对于核心业务功能(聊天、笔记、待办),用户期望断网也能正常使用——先在本地操作,等网络恢复再同步。这就是离线优先(Offline-First)架构。

核心原则:本地数据库是唯一真相来源(Single Source of Truth),网络只是同步通道

代码语言:javascript
复制
/**
* 离线优先架构核心组件
*
* 数据流:UI → ViewModel → Repository → Room (本地优先)
*                                          ↓ (后台同步)
*                                     WorkManager → API
*/// 1. Entity:增加同步状态字段
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val content: String,
val updatedAt: Long = System.currentTimeMillis(),
val syncStatus: SyncStatus = SyncStatus.PENDING // 关键!
)enum class SyncStatus { SYNCED, PENDING, CONFLICT }// 2. Repository:写操作先落库,再触发同步
class NoteRepository(
private val dao: NoteDao,
private val workManager: WorkManager
) {
// UI层调这个 → 立即生效(毫秒级)
suspend fun saveNote(note: NoteEntity) {
dao.upsert(note.copy(syncStatus = SyncStatus.PENDING))
// 触发后台同步(即使App被杀也会执行)
enqueueSyncWork()
}fun observeNotes(): Flow<List<NoteEntity>> = dao.observeAll()private fun enqueueSyncWork() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val work = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30, TimeUnit.SECONDS
)
.build()
workManager.enqueueUniqueWork(
"note_sync",
ExistingWorkPolicy.REPLACE,
work
)
}
}// 3. SyncWorker:网络恢复时自动执行
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {override suspend fun doWork(): Result {
val pending = dao.getPending() // 所有PENDING的笔记
return try {
pending.forEach { note ->
api.syncNote(note.toRequest())
dao.updateStatus(note.id, SyncStatus.SYNCED)
}
Result.success()
} catch (e: IOException) {
Result.retry() // WorkManager会按指数退避重试
}
}
}

这个架构的用户体验提升是质变级的:

传统架构 vs 离线优先架构

• 传统:点击保存 → 等网络响应(200ms-几秒)→ UI更新

• 离线优先:点击保存 → Room写入(<5ms)→ UI立即更新 → 后台静默同步

• 传统断网:操作失败 → 报错 → 用户数据丢失

• 离线优先断网:操作正常 → 标记PENDING → 有网后自动补传

从网络流量角度看,离线优先还天然具有请求合并能力:用户离线时编辑了10次,只需要同步最终版本。10次网络请求变1次。

图片网络优化:最大的流量黑洞

在大多数App中,图片占据60-80%的网络流量。一个feed页面加载20张图片,每张300KB-1MB,轻松就是6-20MB。这是网络优化的最大杠杆点。

格式选择:WebP/AVIF的代差优势

同一张1080p照片的格式对比

• JPEG(quality=80):380KB

• WebP(quality=80):245KB(-35%)

• AVIF(quality=80):165KB(-57%)

• WebP无损:520KB(比JPEG大,但无损)

Android版本支持

• WebP有损:Android 4.0+(覆盖率接近100%)

• WebP无损/透明:Android 4.2+

• AVIF:Android 12+(API 31,当前覆盖约55%)

最佳实践:让CDN根据客户端Accept头自适应返回格式。客户端通过请求头声明能力,CDN自动选最优格式:

代码语言:javascript
复制
/**
* 图片加载最佳实践(Coil 3.x示例)
*/
val imageLoader = ImageLoader.Builder(context)
.components {
// 支持AVIF解码(Android 12+自动启用)
if (Build.VERSION.SDK_INT >= 31) {
add(AvifDecoder.Factory())
}
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizeBytes(256L * 1024 * 1024) // 256MB磁盘缓存
.build()
}
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.crossfade(true)
.build()/**
* CDN URL 动态适配
* 根据设备能力和屏幕尺寸生成最优图片URL
*/
fun buildImageUrl(
originalUrl: String,
widthPx: Int,
context: Context
): String {
val format = when {
Build.VERSION.SDK_INT >= 31 -> "avif"
else -> "webp"
}
val density = context.resources.displayMetrics.density
val targetWidth = (widthPx * density).toInt()
// 大多数CDN支持URL参数控制格式和尺寸
return "${originalUrl}?format=${format}&w=${targetWidth}&q=80"
}

渐进式加载:先模糊后清晰

哪怕图片格式和尺寸都优化了,大图在弱网下还是要等。渐进式加载的思路是:先用极小的数据量呈现一个模糊预览,让用户知道"这里有内容",然后逐步加载高清版本。

代码语言:javascript
复制
/**
* BlurHash渐进式加载方案
*
* 后端为每张图生成BlurHash字符串(20-30字节),随接口一起下发
* 客户端立即渲染模糊占位图 → 异步加载真图 → crossfade过渡
*/
@Composable
fun ProgressiveImage(
imageUrl: String,
blurHash: String?, // "LEHLk~WB2yk8pyo0adR*.7kCMdnj" — 约25字节
modifier: Modifier = Modifier
) {
val placeholder = remember(blurHash) {
blurHash?.let { BlurHashDecoder.decode(it, 32, 32) }
}AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.placeholder(placeholder?.let { BitmapDrawable(it) })
.crossfade(300)
.build(),
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Crop
)
}// 接口返回示例:
// { "imageUrl": "https://cdn.../photo.webp", "blurHash": "LEHLk~WB2yk8..." }
// blurHash只有25字节,但能渲染出辨识度极高的模糊缩略图

用户感知的差异:传统方案是"空白→突然出现图片"(跳跃感强),渐进式是"模糊色块→逐渐清晰"(自然流畅)。实测首屏感知加载时间减少40%——物理加载时间没变,但用户觉得快了。

综合实战效果

在同一个日活500万的App上(前三篇的DNS+连接优化已上线),叠加数据压缩与缓存优化:

优化组合拳

1. Gzip确认全量开启 + 高频大接口加Brotli

2. 核心接口迁移Protobuf

3. OkHttp磁盘缓存50MB + 分接口CacheControl策略

4. 列表类接口改增量拉取

5. 离线包启用BsDiff差分更新

6. 图片全量WebP + Android 12+ AVIF + CDN尺寸适配

7. BlurHash渐进式加载

8. 核心功能离线优先(Room + WorkManager)

AB测试结果(叠加DNS+连接优化后的增量)

• 平均单次会话流量:8.2MB → 3.1MB(-62%)

• 首屏接口总流量:510KB → 145KB(-72%)

• HTTP缓存命中率:0% → 43%(43%的请求零网络IO)

• 首屏加载时间P50:195ms → 120ms(-75ms)

• 首屏加载时间P99(3G网络):2800ms → 980ms(-65%)

• 断网可用功能覆盖率:0% → 85%(核心功能离线可用)

• 月均用户流量消耗:1.8GB → 0.7GB(-61%)

最有价值的两个数字:首屏P99从2800ms降到980ms——3G网络下的极端用户也能在1秒内看到内容;月均流量从1.8GB降到0.7GB——在流量资费敏感的市场(东南亚、印度),这直接影响留存率。

小结与选型决策

这篇的核心原则就两条:传输的数据越小越好(压缩),不需要传的数据就别传(缓存+增量)。具体策略:

• 确保Gzip默认开启(OkHttp白送的优化,别自己搞坏了)

• 大响应体加Brotli,新接口考虑Protobuf

• OkHttp磁盘缓存+分级CacheControl,消灭重复传输

• 列表数据走增量同步,大资源走BsDiff差分

• 核心功能离线优先:Room → WorkManager → 后台静默同步

• 图片是最大流量来源——格式(WebP/AVIF)+ 尺寸适配 + 渐进式加载三管齐下

落地优先级建议:

第一步(零风险/立竿见影):确认Gzip + OkHttp缓存开启 + 图片WebP

第二步(中等投入/高回报):增量拉取改造 + CDN尺寸适配 + CacheControl策略

第三步(架构升级):核心接口Protobuf + 离线优先架构

第四步(长尾优化):Brotli + AVIF + BsDiff差分 + BlurHash

四篇写完,我们的网络全链路已经从"找到服务器"(DNS)→"连上服务器"(连接)→"高效传数据"(压缩+缓存)全部优化到位了。但有一个问题一直悬着:你怎么知道线上到底有没有生效?怎么在问题发生时第一时间发现?

下一篇我们聊网络监控与容灾——建立完整的网络可观测性体系,让每一毫秒的优化效果都有数据可证,让每一个网络异常都无处遁形。没有监控的优化就是玄学。

📚 Android网络优化系列 · 第4/5篇

从DNS到连接池,打造极速网络体验

✅ 第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱

✅ 第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路

✅ 第3篇:连接优化与复用:让每一次握手都物超所值

✅ 第4篇:数据压缩与缓存策略:把带宽用到极致(本篇)

⏳ 第5篇:网络监控与容灾:让网络问题无处遁形

— 系列持续更新中,关注不迷路 —

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-05-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档