Android应用HTTP请求弱网优化

89 阅读13分钟

【原创声明】:本文系原创作品,谢绝任何形式的未经授权的转载。如需转载,请先私信联系我获取许可,谢谢合作!

引言

在浩瀚的互联网世界中,几乎所有的Client/Server架构网络通信都依赖于HTTP请求,可以说,HTTP是现代互联网的基石。而在移动互联网应用中,HTTP请求往往是用户“点击” → “发起请求” → “展示数据”过程中耗时最长、失败率最高的步骤。提升HTTP请求的性能,对于提升用户体验和ROI转化率都至关重要。本文将从HTTP请求的成功率和响应时间两个维度,结合我维护的2000万+ DAU、亿级日请求量Android应用弱网优化经验,介绍如何逐步优化HTTP请求的性能。

一、明确方向

在讨论具体优化措施前我们先看两个简单的公式:

  1. 网络请求成功率
预期网络传输速度=HTTP数据size超时时间{预期网络传输速度} = \frac{\text{HTTP数据size}}{\text{超时时间}}

网络请求是否成功,我们可以理解为超时时间内,能否完成数据的传输。虽然“网络速度”无法改变,且我们面临的是各种弱网环境(如电梯、地铁或地下车库),但我们可以通过减小HTTP数据size或增加超时时间从而提高网络请求成功率

  1. 网络请求响应时间

HTTPS请求时间=DNS解析+TCP握手+TLS握手+RequestHeader传输+RequestBody传输+服务器处理+ResponseHeader传输+ResponseBody传输{HTTPS请求时间} = {DNS解析} + {TCP握手} + {TLS握手} + {Request Header传输} + {Request Body传输} + {服务器处理} + {Response Header传输} + {Response Body传输}

上面的公式展示了从发起HTTPS请求到最终接收到响应过程中,每个阶段可能涉及的时间消耗,我们可以通过分治的思想分别优化每阶段1的时间,从而做到减小整体网络请求响应时间

二、准备工作

孔子曰:“工欲善其事,必先利其器。”——《论语·卫灵公》

本文使用OkHttp2作为基础的网络请求库。OkHttp是一个高效、可靠且功能强大的HTTP客户端,通过采用 责任链模式,为HTTP请求提供了一套灵活且可扩展的架构。

三、打点探测

在优化之前,我们首先需要进行埋点,以尽可能细的粒度记录网络请求各个阶段耗时。我们遵循“数据采集” → “根因分析” → “策略制定” → “优化实施”的闭环流程,借助OkHttp的EventListener精准定位性能瓶颈,进而实现针对性的HTTP请求优化。EventListener通过观察者模式同时结合了模版方法模式“Hook”了HTTP请求的各个阶段,从而更加精确统计请求各个阶段的耗时3

"OkHttp EventListener"

我们可以将不同的HTTP URL为“关键Key”进行统计,从而分接口API统计,方便对比优化前后效果:

class TraceEventListenerDelegate(private val delegate: EventListener?) : EventListener() {
    override fun callStart(call: Call) {
        delegate?.callStart(call)
    }

    override fun callEnd(call: Call) {
        delegate?.callEnd(call)
    }
}

class TraceEventListener : EventListener() {
    private var startTime = 0L
    override fun callStart(call: Call) {
        startTime = SystemClock.elapsedRealtime()
    }

    override fun callEnd(call: Call) {
        val elapsedTime = SystemClock.elapsedRealtime() - startTime
    }
}

class TraceEventListenerFactory : EventListener.Factory {
    override fun create(call: Call): EventListener {
        return if (Random.nextInt(0, 100) < 1) {
            TraceEventListenerDelegate(TraceEventListener())
        } else {
            TraceEventListenerDelegate(null)
        }
    }
}

四、最佳实践

1. 握手时间优化

当发起全新的HTTPS请求时需要先进行TCP的三次握手,然后还要进行TLS的4次握手,也就是我们常说的HTTPS 7次握手,这个过程需要至少三个RTT,其中TCP三次握手1个RTT,TLS四次握手2个RTT(TLS1.1或TLS1.2),这个过程在弱网环境下耗时尤为明显。因此我们应当想办法避免或减少这个过程。

image.png

TCP Handshake

TLS_Handshake.png

TLS Handshake

  1. 连接复用

HTTP的传输层使用的是TCP,对于已经建立连接的服务器,只需要传输数据即可;因此,针对同一个域名同一端口的多个HTTP请求,只需要在首次建立连接时进行HTTPS 7次握手,后续请求可以进行连接复用,从而节省了3个RTT的握手时间。OkHttp的ConnectionPool以及相关类负责实现连接复用的功能。 Kotlin /** * maxIdleConnections 最大空闲连接数 * keepAliveDuration 空闲连接最大存活时间 * timeUnit 时间单位 */ constructor( maxIdleConnections: Int, keepAliveDuration: Long, timeUnit: TimeUnit )

值得注意的是,我们每次创建OkHttpClient时默认会新建一个ConnectionPool,因此我们应该只创建一个全局唯一的ConnectionPool实例对象,在创建新OkHttpClient时传入这个全局唯一实例对象即可。当网络发生切换时(蜂窝网络和Wi-Fi相互切换),由于DNS结果可能会更新,旧的连接不一定是最佳路由路径,因此我们需要调用ConnectionPoolevictAll()方法手动清理连接池,并通过预连接机制创建新连接等待后续使用。

 val client = OkHttpClient.Builder().connectionPool(connectionPool).build();
  1. 预连接机制

我们可以在Activity的onResume回调内通过ConnectionPool.connectionCount()判断当前可用的连接数量,如果可用连接数量为0则说明连接已经被超时回收。这时我们可以向服务器发送一个HEAD请求4,从而快速完成HTTPS的7次握手,当用户真正点击界面发起业务HTTP请求时,即可复用已经建立的连接,从而提升用户体验,这条优化措施在应用从后台回到前台的场景优化尤为明显。且由于HEAD请求一般由Nginx服务器处理,因此对服务器的影响几乎可以忽略。

  1. 收敛域名

对于一些复杂且庞大的业务场景,服务端也许会通过不同的域名切分不同的业务模块,这对于OkHttp的连接复用是不友好的,所以我们应尽量将不同的域名进行合并,从而最大程度的进行连接复用提升性能。如果需要对业务场景进行区分,可以通过增加业务命名的路径段(Path Segment)的方式区分不同业务,例如:www.example.com/v1/configwww.example.com/v1/feed

2. 消除无意义重定向

当客户端请求到一个重定向URL时,客户端需要根据服务器返回的Location字段解析出URL并再次发起HTTP请求,这个过程,这意味着客户端得到想要的结果需要至少进行两次HTTP请求,这样会极其耗时,如果重定向的URL是一个全新的域名,大概率需要进行DNS查询和HTTPS的七次握手。因此,我们应该尽量消除无意义的重定向,直接请求重定向后的URL。

3. Accept-Encoding添加Brotli

Brotli是Google推出的一种用于替代传统GZip的无损压缩算法,基于LZ77算法的一个现代变体、霍夫曼编码和二阶上下文建模。从下表我们可以看到,对于相同的压缩级别5,brotli在压缩率比gzip高8%的前提下,端侧解压缩速度快26%。需要注意的是,Nginx需要添加Brotli的相关插件才能与客户端Content negotiation协商成功。

Codec6LevelCompression RatioCompression SpeedDecompression Speed
brotli22.7778.93 MiB/s256.88 MiB/s
brotli42.8455.03 MiB/s255.38 MiB/s
brotli63.1516.34 MiB/s262.37 MiB/s
brotli83.216 MiB/s264.09 MiB/s
gzip22.5357.59 MiB/s200.3 MiB/s
gzip42.6242.89 MiB/s202.84 MiB/s
gzip62.6821.68 MiB/s208.9 MiB/s
gzip82.699.59 MiB/s210.25 MiB/s
deflate21.9037.06 MiB/s143.87 MiB/s
deflate41.9528.17 MiB/s150.01 MiB/s
deflate61.9914.59 MiB/s154.48 MiB/s
deflate81.999.83 MiB/s155.73 MiB/s

好消息是OkHttp已经为我们提供了Brotli的Interceptor,详情请参考:github.com/square/okht…

    OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(BrotliInterceptor.INSTANCE)
    .build();

4. 使用HTTP2

HTTP/2 的主要优势

  • 二进制传输:
    HTTP/1.1 是基于文本协议,解析复杂。HTTP/2 将所有消息转为二进制帧,传输和解析更高效、可靠。
  • 多路复用:
    同一个 TCP 连接中可同时发送多个请求和响应,互不阻塞。解决了 HTTP/1.1 的「队头阻塞」(Head-of-Line Blocking)问题。这个特性在应用同时发送多个网络请求(例如:冷启动场景)表现尤为明显。
  • 头部压缩:
    使用 HPACK 算法压缩请求与响应头,显著减少重复字段带来的冗余开销。

5. 适当增加超时时间

我们回到网络请求成功率这个公式,我们可以看出,在数据量不变的前提下,超时时间越大,对预期网络传输速度的大小要求也就越小。同时,需要澄清一点,增大超时时间并不会影响现有可以请求成功的请求完成时间,就行龟兔赛跑,兔子快速跑到终点后,我们宣布比赛结束,这时的请求成功率为50%。但是我们可以等乌龟跑完再宣布比赛结束(增加超时时间),这时兔子的比赛成绩不会改变,但是请求成功率却增加到了100%。

6. HttpDNS

HTTPDNS是面向多端应用(移动端APP,PC客户端应用)的基于HTTP/HTTPS协议的域名解析服务,可以有效解决传统域名解析容易被劫持、解析不准确、更新不及时、服务不稳定等问题,从而实现精准调度,解决移动互联网服务中域名解析异常带来的困扰。

  • 安全防劫持:
    HTTP/HTTPS 查询结果不易被篡改。

  • 更精准的调度:
    HttpDNS 服务器可以根据用户真实IP做智能解析,让用户访问离自己最优的节点,RTT更低,请求响应更快。

  • 支持自定义策略:
    比如失败切换、优先级路由、流量调度等。

  • 跨平台一致性:
    移动端 App、IoT 设备等都能自己做域名解析,不依赖操作系统。

7. TLS优化

 客户端与服务器在通过TLS 交换数据之前,必须协商建立加密信道。协商内容包括TLS 版本、加密套件,必要时还会验证证书。然而,协商过程的每一步都需要一个分组在客户端和服务器之间往返一次,因而所有TLS 连接启动时都要经历一定的延迟。弱网环境下每增加一次往返都会增加较高的网络延迟,从而导致网络请求过慢甚至超时。如果舍弃TLS层又会增加数据被截获的风险,如何保证“安全”和“速度”兼得呢?

  1. 减少证书链的长度
    确保证书链的长度最小,也就是确保信任链中不包含不必要的证书。由于服务器证书是在握手期间发送的,而服务端发送证书时,TCP连接大概率是一个处于“慢启动”算法阶段的新TCP连接。如果证书size超过了TCP的初始拥塞窗口,那么证书发送就会让握手多了一个RTT,从而导致服务器停下来等待客户端的ACK消息。

  2. 使用Session Ticket
    完整TLS握手会带来额外的延迟和计算量,从而给所有依赖安全通信的应用造成。我们可以使用“Session Ticked”机制让客户端和服务器在重连时跳过完整握手过程,从而减少 RTT(往返时延)、提升性能。

    工作过程如下:

    • 第一次握手时:
      服务端生成一份“会话状态”(包含主密钥、算法等)。用一个只有服务端自己知道的加密密钥(Session Ticket Key) 加密这份状态。把加密结果发送给客户端(称为 Session Ticket)7 客户端保存这个Ticket(在Android BoringSSL中)

    • 下次重连时:
      客户端在ClientHello中携带这个 Ticket。服务端解密Ticket,恢复出之前的会话参数。双方跳过完整握手,直接恢复加密通信。

  1. 启用TLSv1.3
    相比于TLSv1.2 TLSv1.3是一次性能飞跃。

    • Full Handshake
      从TLSv1.2的2-RTT降低到1-RTT

    • 0-RTT 握手
      在介绍0-RTT握手前,需要先声明0-RTT不是第一次连接时就能用的。它需要依赖之前的一次成功的"Full Handshake"连接。

      当客户端再次访问该服务器时: 客户端在ClientHello阶段就发送上次的Session Ticket、提前加密好的0-RTT 数据(Early Data) 服务端验证这个Session Ticket是否有效,如果有效服务端立即解密并处理0-RTT 数据(Early Data)

    • 算法协商更快、密钥派生更安全

8. 业务逻辑优化

  1. 动态Feed流size
    动态计算当前手机分辨率可以展示的Feed流数据条数,防止出现完整显示一屏幕数据需要请求两次Feed流或一次请求大于1.5屏(根据埋点确定用户习惯动态调整)数据的情况,从而造成多次请求,或数据请求冗余的情况。
  2. 接口数据合并
    在保证业务逻辑解耦的前提下,尽量将一个页面的多个HTTP请求按业务逻辑进行合并,尤其是多个小数据量的HTTP请求,防止碎片化的HTTP请求增加传输时间。
  3. 接口数据拆分
    如果对于单条数据量特别大的HTTP请求,我们也应该进行分页加载,甚至可以按照优先级拆分成多个接口分段刷新。
  4. 分段刷新
    对于一个页面的多个HTTP请求,尽量不要等待所有HTTP请求成功后再刷新页面,建议进行分段刷新,减少空白页面给用户带来的"焦虑感"。
  5. 减少HTTP请求
    任何网络请求都不如没有请求更快,因此去掉没有必要的请求,或者进行本地缓存也是一个不错的选择。

9. 其他优化

  1. 增加机房
    对于有条件的公司可以选择在不同的地理位置适当布设机房(多点部署 / CDN 边缘节点),减少传输距离,从而降低RTT和丢包率。
  2. HTTP over CDN
    对于一些短时间变化很小、QPS很高的接口API,我们可以将Response(例如:JSON)静态存入CDN,通过CDN来加速请求和响应。

五、总结

弱网场景是移动开发中不可避免的,我们要做的是直面困难勇于挑战,通过一次次不断的迭代,挖掘潜在的性能瓶颈,使我们的应用拥有更好的用户体验。

参考资料

  • [1] 《Web性能权威指南》

Footnotes

  1. 服务器处理阶段不在本文的讨论范围内,故不作详细探讨。

  2. 为了方便大家阅读代码,我们以OkHttp 4.12.x代码进行讲解。生产环境,建议大家更新到最新的稳定版本。github.com/square/okht…

  3. 这里要注意埋点的频率,防止出现每个网络请求都打点的情况。可以通过随机数采样的方式降低频率,并过滤一些无意义的统计,例如:下载文件的URL等

  4. 需要确定服务端Nginx支持HEAD请求

  5. 这里我们取压缩级别4的数据进行对比

  6. 数据源:quixdb.github.io/squash-benc…

  7. 需要注意的是,使用Session Ticket会存在重放攻击的风险,并且如果Ticket Key泄露,可能导致已发送HTTPS请求失去前向保密性