本文是「Android网络优化」系列第1篇,共5篇。从DNS到连接池,打造极速网络体验。
从一个线上故障说起
上个月我们收到一批用户反馈:App在某些场景下"卡白屏"------准确说是首屏接口迟迟没有返回。查日志发现,同一个接口在WiFi下200ms搞定,切到4G弱信号环境直接飙到6-8秒,最夸张的case等了15秒才拿到数据。
第一反应是服务端慢了。但服务端的access log显示处理时间只有50ms------耗时几乎全在链路上。
这里有个很多人忽略的事实:一次HTTP请求的耗时,大部分不在服务端。尤其在移动端,网络链路本身的开销往往占总时间的80%以上。你觉得是服务端慢,其实是DNS解析花了2秒,是TLS握手重来了一次,是TCP连接在弱网下反复重传。
今天就来完整拆一下,一次HTTP请求到底经历了什么,每个环节的性能陷阱在哪,以及OkHttp/Retrofit架构下我们能从哪些地方下手优化。
一次HTTP请求的完整链路
当你调用 retrofit.create(ApiService::class.java).getData() 的那一刻,底层实际上要跑完这样一条链路:
- DNS解析:把域名翻译成IP地址(0-2000ms,取决于缓存命中情况)
- TCP三次握手:和服务器建立可靠连接(1个RTT,约50-300ms)
- TLS握手:HTTPS加密协商(1-2个RTT,约100-500ms)
- HTTP请求发送:发请求头+请求体(取决于body大小)
- 服务端处理:后端逻辑执行(通常最快的环节,讽刺不?)
- HTTP响应接收:收响应头+响应体(取决于数据量和带宽)
- 数据解析:JSON/Proto反序列化(CPU密集,通常10-100ms)
用OkHttp的EventListener可以精确测量每个阶段的耗时:
kotlin
class NetworkTimingListener : EventListener() {
private var callStartMs = 0L
private var dnsStartMs = 0L
private var connectStartMs = 0L
private var tlsStartMs = 0L
private var requestStartMs = 0L
private var responseStartMs = 0L
override fun callStart(call: Call) {
callStartMs = System.currentTimeMillis()
}
override fun dnsStart(call: Call, domainName: String) {
dnsStartMs = System.currentTimeMillis()
}
override fun dnsEnd(call: Call, domainName: String, inetAddressList: List) {
val dnsMs = System.currentTimeMillis() - dnsStartMs
reportMetric("dns_time", dnsMs)
}
override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) {
connectStartMs = System.currentTimeMillis()
}
override fun secureConnectStart(call: Call) {
tlsStartMs = System.currentTimeMillis()
}
override fun secureConnectEnd(call: Call, handshake: Handshake?) {
val tlsMs = System.currentTimeMillis() - tlsStartMs
reportMetric("tls_time", tlsMs)
}
override fun connectEnd(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?) {
val connectMs = System.currentTimeMillis() - connectStartMs
reportMetric("connect_time", connectMs)
}
override fun responseHeadersStart(call: Call) {
responseStartMs = System.currentTimeMillis()
}
override fun responseHeadersEnd(call: Call, response: Response) {
// TTFB = 从请求发出到收到第一个响应字节
val ttfb = responseStartMs - requestStartMs
reportMetric("ttfb", ttfb)
}
override fun callEnd(call: Call) {
val totalMs = System.currentTimeMillis() - callStartMs
reportMetric("total_time", totalMs)
}
}
注册也很简单:
kotlin
val client = OkHttpClient.Builder()
.eventListenerFactory { NetworkTimingListener() }
.build()
上线跑了一周数据之后,真实的耗时分布让我很意外:
erlang
4G环境 P50耗时分布(某业务接口):
DNS解析 : 120ms (18%)
TCP握手 : 150ms (22%)
TLS握手 : 200ms (30%)
请求发送 : 20ms ( 3%)
服务端处理 : 45ms ( 7%)
响应接收 : 130ms (20%)
────────────────────────────
总计 : 665ms
看到没?DNS + TCP + TLS三项握手就占了70%。服务端才用了7%。如果这个连接是复用的(命中连接池),前三项直接归零,总耗时能降到195ms------性能提升3倍多,一行代码都不用改。
这就是为什么网络优化要从链路开始看,而不是只盯着服务端响应时间。
移动端网络的特殊挑战
桌面端做网络优化相对简单------网络稳定、带宽充裕、延迟可控。移动端就是另一回事了。
挑战一:弱网环境
地铁里、电梯里、地下车库------用户不会因为信号差就不用你的App。实测数据:国内4G网络在地铁场景下,丢包率可以飙到30%以上,RTT从50ms暴涨到2000ms+。
弱网对TCP的影响是灾难性的。TCP的拥塞控制算法(BBR/Cubic)在丢包时会大幅降低发送窗口,一次重传就可能让吞吐量掉90%。更要命的是TLS握手------一旦握手过程中丢包,需要从头来,而TLS 1.2的完整握手需要2个RTT(4次网络交互),在RTT=2秒的弱网下就是4秒起步。
kotlin
// 弱网检测:通过ConnectivityManager监听网络质量变化
class NetworkQualityMonitor(private val context: Context) {
fun isWeakNetwork(): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java)
val nc = cm.getNetworkCapabilities(cm.activeNetwork) ?: return true
// 下行带宽低于150Kbps认为是弱网
val downBandwidth = nc.linkDownstreamBandwidthKbps
if (downBandwidth 800 // ms
}
fun adaptTimeouts(builder: OkHttpClient.Builder): OkHttpClient.Builder {
return if (isWeakNetwork()) {
builder
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
} else {
builder
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
}
}
}
挑战二:网络切换
WiFi切4G,4G切WiFi,4G切5G------每次切换,TCP连接就断了。因为TCP连接是通过四元组(源IP:源端口 → 目标IP:目标端口)标识的,IP一变连接就失效了。
最实用的方案是快速检测网络切换并主动清理连接池:
kotlin
// 监听网络切换,主动清理无效连接
class NetworkSwitchHandler(
private val client: OkHttpClient
) {
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onLost(network: Network) {
// 网络丢失,清空连接池中对应的连接
client.connectionPool.evictAll()
}
override fun onAvailable(network: Network) {
// 新网络可用,可以预热关键域名的连接
prewarmConnections()
}
}
private fun prewarmConnections() {
CRITICAL_HOSTS.forEach { host ->
Executors.IO.execute {
try {
client.newCall(
Request.Builder().url("https://$host")
.head().build()
).execute().close()
} catch (_: Exception) { }
}
}
}
}
挑战三:NAT与运营商劫持
运营商的基站对HTTP流量做NAT,有些运营商的NAT超时非常激进------空闲30秒就把映射关系回收了。你以为连接还活着,实际上中间设备已经把通道拆了。
更恶心的是HTTP劫持。某些运营商在HTTP响应中注入广告代码,或篡改DNS响应。HTTPS能防住内容篡改但防不住DNS劫持。
应对方案:HTTPS是底线;HttpDNS解决DNS劫持(下一篇详聊);连接池keepAlive设为20-25秒;定期发心跳保活关键连接。
挑战四:异构网络环境
你的用户可能在:2G信号的山区、东南亚的3G网络、公司内网的代理后面、校园WiFi的多层NAT背后。同一套超时配置不可能适配所有场景。
建议搭建网络质量分级体系------根据实时检测到的RTT和带宽动态调整策略:
kotlin
enum class NetworkGrade {
EXCELLENT, // WiFi/5G,RTT1000ms
}
object NetworkStrategy {
fun getConfig(grade: NetworkGrade): NetworkConfig = when (grade) {
EXCELLENT -> NetworkConfig(
connectTimeout = 3.seconds,
enablePrefetch = true,
imageQuality = Quality.HIGH
)
GOOD -> NetworkConfig(
connectTimeout = 5.seconds,
enablePrefetch = true,
imageQuality = Quality.MEDIUM
)
FAIR -> NetworkConfig(
connectTimeout = 10.seconds,
enablePrefetch = false,
imageQuality = Quality.LOW
)
POOR -> NetworkConfig(
connectTimeout = 15.seconds,
enablePrefetch = false,
imageQuality = Quality.THUMBNAIL,
enableCompression = true
)
TERRIBLE -> NetworkConfig(
connectTimeout = 20.seconds,
enablePrefetch = false,
imageQuality = Quality.NONE,
enableCompression = true,
useCacheOnly = true // 极端情况先展示缓存
)
}
}
网络性能度量:你至少需要这几个指标
做优化之前得先有数据,否则就是盲人摸象。至少采集这四个核心指标:
1. TTFB(Time To First Byte)
从请求发出到收到第一个响应字节的时间。衡量网络链路+服务端处理的综合指标。
2. RTT(Round Trip Time)
一个数据包从客户端到服务端再返回的时间。可以通过TCP握手的SYN-ACK延迟来近似测量。
3. 请求成功率
区分网络层失败(DNS超时、连接超时、读超时)vs 业务层失败(HTTP 4xx/5xx)。
4. 连接复用率
你的请求有多大比例命中了连接池?我们优化前复用率只有40%,优化后提升到85%,网络耗时直接降一半。
kotlin
// 通过EventListener统计连接复用率
class ConnectionReuseTracker : EventListener() {
private var isConnectionReused = false
override fun connectionAcquired(call: Call, connection: Connection) {
// 如果没有触发connectStart,说明是复用的连接
isConnectionReused = !hadConnectStart
}
override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) {
hadConnectStart = true
}
override fun callEnd(call: Call) {
MetricsCollector.report(
"connection_reused",
if (isConnectionReused) 1 else 0
)
}
}
OkHttp/Retrofit架构下的优化切入点
有了全链路的认知和度量数据,优化方向就很清楚了:
| 层次 | 优化方向 |
|---|---|
| DNS层 | 自定义Dns接口实现HttpDNS / DNS缓存 / DNS预解析 |
| 连接层 | 连接池调优 / HTTP/2启用 / 域名收敛 / 连接预热 / TLS Session复用 |
| 数据层 | Gzip/Brotli压缩 / Protocol Buffers替代JSON / 增量同步 / 缓存策略 |
| 监控层 | EventListener埋点 / 弱网降级 / 多IP容灾 / 异常告警 |
Quick Win 1:域名收敛
确保所有域名共享同一个OkHttpClient实例(共享连接池):
kotlin
// 错误做法:每个Retrofit实例用独立Client
val apiRetrofit = Retrofit.Builder()
.client(OkHttpClient()) // 独立连接池
.baseUrl("https://api.example.com")
.build()
// 正确做法:共享Client
val sharedClient = OkHttpClient.Builder()
.connectionPool(ConnectionPool(15, 5, TimeUnit.MINUTES))
.build()
val apiRetrofit = Retrofit.Builder()
.client(sharedClient)
.baseUrl("https://api.example.com")
.build()
val cdnRetrofit = Retrofit.Builder()
.client(sharedClient) // 共享连接池!
.baseUrl("https://cdn.example.com")
.build()
Quick Win 2:启用HTTP/2
OkHttp默认支持HTTP/2,但需要服务端也支持。确认方法:
kotlin
override fun connectEnd(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?) {
// protocol = h2 表示HTTP/2协商成功
Log.d("Network", "Protocol: $protocol")
if (protocol != Protocol.HTTP_2) {
reportH2Failure(call.request().url.host)
}
}
Quick Win 3:DNS预解析
App启动时把核心域名提前解析好:
kotlin
object DnsPreResolver {
private val criticalHosts = listOf(
"api.yourapp.com",
"cdn.yourapp.com",
"auth.yourapp.com"
)
fun prewarm() {
Executors.IO.execute {
criticalHosts.forEach { host ->
try {
InetAddress.getAllByName(host)
} catch (_: Exception) { }
}
}
}
}
总结与下一篇预告
回顾一下这篇讲了什么:
- 一次HTTP请求的完整链路:DNS → TCP → TLS → HTTP → 响应,握手环节占总耗时70%+
- 移动端四大网络挑战:弱网、网络切换、NAT/劫持、异构网络
- 必须采集的四个指标:TTFB、RTT、成功率、连接复用率
- 三个立刻能用的Quick Win:域名收敛、HTTP/2确认、DNS预解析
最核心的观点:网络优化的第一步不是优化,是度量。没有EventListener的数据,你不知道瓶颈在哪。先把监控加上,跑一周数据,然后再有针对性地优化。
下一篇我们聊DNS优化------运营商DNS的坑你可能想象不到:域名解析指向错误的CDN节点、DNS缓存被污染、LocalDNS递归查询超时......HttpDNS方案如何解决这些问题,OkHttp自定义Dns接口怎么接入,以及大厂实战中的DNS容灾策略,下篇见。