数据压缩与缓存策略:把带宽用到极致 | Android网络优化系列(4)

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

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

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

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

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

第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+连接优化已上线),叠加数据压缩与缓存优化:

优化组合拳

  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篇:网络监控与容灾:让网络问题无处遁形

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

相关推荐
詩飛1 小时前
Spring Boot 事务管理完全指南
后端
程序员陆业聪1 小时前
网络监控与容灾:让网络问题无处遁形 | Android网络优化系列(5·完结)
后端
fliter1 小时前
Rust 能帮你捕获什么,又不能捕获什么
后端
ZHOUPUYU1 小时前
PHP8高性能Web开发实战指南
后端·html·php
fliter1 小时前
一个 Emoji 是怎么让 rust-analyzer 崩溃的
后端
天涯明月19931 小时前
AEnvironment深度研究报告
人工智能·后端·云原生
springXu1 小时前
windows arm64上的VS CODE的GoLang环境的搭建
开发语言·后端·golang
怕浪猫2 小时前
听说后端又死了?AI 时代前端后端都怎么样了
后端·面试
IT_陈寒2 小时前
Redis突然吃掉所有内存,我的服务差点挂了
前端·人工智能·后端