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

本文是「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容灾策略,下篇见。

相关推荐
程序员陆业聪1 小时前
渲染引擎与性能拆解:自绘vs原生渲染vs Bridge的终极对决|跨平台框架深度对决②
android
程序员陆业聪9 小时前
技术选型决策树:什么团队、什么项目该选什么框架 | 跨平台框架深度对决(4)
android
星辰徐哥10 小时前
Rust异步测试与调试的实践指南
android·java·rust
星河耀银海10 小时前
C++ 运算符重载:自定义类型的运算扩展
android·java·c++
阿巴斯甜11 小时前
Activity 之间大量数据传递有哪些方案?
android
阿巴斯甜11 小时前
必看1
android
帅次12 小时前
副作用 API:LaunchedEffect、DisposableEffect、SideEffect
android·compose·disposable·sideeffect·launched·ondispose
流年如夢13 小时前
单链表的应用 --> 简单通讯录的实现
android·数据结构·链表
用户860225046747219 小时前
Jetpack ViewModel 入门与实践
android