Android网络优化系列 · 第4/5篇
从DNS到连接池,打造极速网络体验
第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱
第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路
第4篇:数据压缩与缓存策略:把带宽用到极致(本篇)
⏳ 第5篇:网络监控与容灾:让网络问题无处遁形
从一笔"流量账"说起
前三篇我们搞定了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的自动解压搞坏了。
scss
// 错误示范:手动设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那样自动支持,需要引入额外依赖:
kotlin
// 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响应(
压缩效果对比(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%。对高频、大体量接口来说,这是质的差距。
ini
// 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,延迟等于磁盘读取时间(通常
传统架构 vs 离线优先架构
• 传统:点击保存 → 等网络响应(200ms-几秒)→ UI更新
• 离线优先:点击保存 → Room写入(
从网络流量角度看,离线优先还天然具有请求合并能力:用户离线时编辑了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自动选最优格式:
kotlin
/**
* 图片加载最佳实践(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"
}
渐进式加载:先模糊后清晰
哪怕图片格式和尺寸都优化了,大图在弱网下还是要等。渐进式加载的思路是:先用极小的数据量呈现一个模糊预览,让用户知道"这里有内容",然后逐步加载高清版本。
scss
/**
* 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+连接优化已上线),叠加数据压缩与缓存优化:
优化组合拳
-
Gzip确认全量开启 + 高频大接口加Brotli
-
核心接口迁移Protobuf
-
OkHttp磁盘缓存50MB + 分接口CacheControl策略
-
列表类接口改增量拉取
-
离线包启用BsDiff差分更新
-
图片全量WebP + Android 12+ AVIF + CDN尺寸适配
-
BlurHash渐进式加载
-
核心功能离线优先(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的进化之路
第4篇:数据压缩与缓存策略:把带宽用到极致(本篇)
⏳ 第5篇:网络监控与容灾:让网络问题无处遁形
--- 系列持续更新中,关注不迷路 ---