前言
随着工作年限的增长,我渐渐发现,网络和网络协议,是应用开发绕不过的坎。
只是这么感慨一下,最近工作中有了一些感触,所以写一篇文章记录一下。
本文不是探索网络请求的原理,不是深究Socket之类的底层的东西。这些对于我来说,太虚,太缥缈,太底层了。这次,来探索一些实际点的东西。
背景
领导:小米啊,你看你的项目,为什么你的app,第一次打开的时候,接口请求要1500ms左右的时间,第二次只要200ms左右,这是为什么?
小米:额,我,我去看看吧(感觉有问题,但需要具体看一下)。
领导:你这有问题,去优化一下吧。
小米:好吧。
于是,在不知道具体什么情况的时候,小米开始了自己的研究。
项目搭建
重新搭建一个项目,采用retrofit请求数据,接口嘛,就用玩Android的开放api。
尽量用少的代码,写出一个可用的项目。虽然是小文章,但,麻雀虽小五脏俱全。
好了,不多说了,直接上代码。
xml布局,就三个TextView,一个tvContent显示接口的内容,两个tv添加事件。
点击第一个按钮请求首页文章数据,点击第二个接口请求我的收藏数据。
kotlin
class HttpDemoActivity : AppCompatActivity() {
private lateinit var tvContent: TextView
private lateinit var tv0: TextView
private lateinit var tv1: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_http_demo)
tvContent = findViewById(R.id.tv_content)
tv0 = findViewById(R.id.tv0)
tv1 = findViewById(R.id.tv1)
tvContent.movementMethod = LinkMovementMethod.getInstance()
tv0.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
val result = RetrofitManager.getService().getHomeList()
withContext(Dispatchers.Main) {
tvContent.text = result.getData().toString()
}
}
}
tv1.setOnClickListener {
lifecycleScope.launch {
val result = RetrofitManager.getService().getCollect()
withContext(Dispatchers.Main){
val s = "code: ${result.getCode()}\nmsg: ${result.getMsg()} data: ${result.getData()}"
tvContent.text = s
}
}
}
}
}
然后,是RetrofitManager
kotlin
object RetrofitManager {
private const val TAG = "RetrofitManager"
const val CONNECT_TIME = 30L
const val WRITE_TIME = 30L
const val READ_TIME = 30L
private val httpLogInterceptor by lazy {
val interceptor = HttpLoggingInterceptor()
interceptor.level = HttpLoggingInterceptor.Level.BASIC
interceptor
}
private fun getOkHttpClient(): OkHttpClient {
val connectionPool = ConnectionPool(10, 5, TimeUnit.MINUTES)
val builder = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIME, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIME, TimeUnit.SECONDS)
.readTimeout(READ_TIME, TimeUnit.SECONDS)
.callTimeout(CONNECT_TIME, TimeUnit.SECONDS)
.connectionPool(connectionPool)
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))//强制协议
.addInterceptor(httpLogInterceptor)
return builder.build()
}
private val retrofit: Retrofit = Retrofit.Builder()
.client(getOkHttpClient())
.baseUrl("https://www.wanandroid.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
private val retrofitService = retrofit.create(ServiceApi::class.java)
fun getService(): ServiceApi = retrofitService
}
接下来是接口ServiceApi的代码
kotlin
interface ServiceApi {
@GET("/article/list/0/json")
suspend fun getHomeList(): BaseResponse<HomeData>
@GET("/lg/collect/list/0/json")
suspend fun getCollect(): BaseResponse<CollectData>
}
像BaseResponse、ChapterData、CollectData、HomeData的代码就不放了,可以根据自己需要的,在首页文章列表这里直接查看首页文章列表接口返回的数据,在收藏文章列表 查收藏文章列表返回的数据。
主要的逻辑还是在retrofitManager
中。因为加入了HttpLoggingInterceptor这个拦截器,可以方便查看接口请求的时间,而如果要查看其他的接口数据,可以调整interceptor.level
的值, 它的值有四个,分别是NONE
、BASIC
、HEADERS
、BODY
。
实验
打印结果
点击按钮,请求首页数据,然后再次请求
bash
I/okhttp.OkHttpClient: --> GET https://www.wanandroid.com/article/list/0/json
I/okhttp.OkHttpClient: <-- 200 OK https://www.wanandroid.com/article/list/0/json (642ms, unknown-length body)
I/okhttp.OkHttpClient: --> GET https://www.wanandroid.com/article/list/0/json
I/okhttp.OkHttpClient: <-- 200 OK https://www.wanandroid.com/article/list/0/json (148ms, unknown-length body)
唉,好像确实,第一次请求的时间就是会比第二次慢。多试几次,排除网络波动和数据请求的干扰。
同时,用另一个接口--收藏接口,排除数据库操作的影响。
bash
I/okhttp.OkHttpClient: --> GET https://www.wanandroid.com/lg/collect/list/0/json
I/okhttp.OkHttpClient: <-- 200 OK https://www.wanandroid.com/lg/collect/list/0/json (495ms, unknown-length body
I/okhttp.OkHttpClient: --> GET https://www.wanandroid.com/lg/collect/list/0/json
I/okhttp.OkHttpClient: <-- 200 OK https://www.wanandroid.com/lg/collect/list/0/json (50ms, unknown-length body)
I/okhttp.OkHttpClient: --> GET https://www.wanandroid.com/lg/collect/list/0/json
I/okhttp.OkHttpClient: <-- 200 OK https://www.wanandroid.com/lg/collect/list/0/json (52ms, unknown-length body)
发现,确实,第一次打开app的时候,调用接口请求网络数据,同一个接口的耗时,第一个都会比后面的时间长。
因为在Cookies中没有加入用户名和密码,所以直接返回提醒登录了。
ruby
{"errorCode":-1001,"errorMsg":"请先登录!"}
postman
首先要判断,是不是自己代码的问题。
其实,retorfit+okhttp这一套,大部分的app都在使用,是不存在什么重大的问题的。而RetrofitManager
就这么几行代码,能错到哪里去。
但,本着严谨的态度,用postman请求了上面两个接口。
可以看出,postman发送请求的时候,每个请求的耗时都...都...,怎么每次请求的时间都很长,没有第一次请求消耗时间长,第二次短的现象?不对啊,为什么?难不成,postman发送请求,对连接不会复用?
这,这个问题,先放到一边。排除一个是网线连接,一个是wifi连接,大致可以认为,两者的速度是一致的。而且,postman请求的耗时,明显比客户端第二次请求的时间长。
分析
那么,究竟是为什么,才会导致第一次请求时间这么久的呢?或者,一个https的请求,一共做了什么操作,哪一步是比较久的呢?
初始化
会不会是retrofit和OkHttpClient的初始化,导致第一次请求数据比较慢呢?
将初始化提前,在application中直接调用RetrofitManager.getService()
,提前初始化,或者,在Mainactivity的onCreate中调用上面的代码,提前初始化。
结果发现,打开app后的第一次请求,耗时还是一样,远大于第二次请求。所以,不是初始化才导致第一次请求的耗时远大于第二次请求的耗时。
HTTP
网上讲http的文章和课程很多,比如罗剑锋老师的《透视HTTP协议》,非常基础,简单易懂。
而提到HTTP,最先想到的就是三次握手了。那么,然后呢,一个接口请求,都干了些什么呢?
顺序步骤
查了deepseek,发现,在一个http请求中,主要执行了下面的步骤。
-
DNS 查询阶段: 将域名解析为 IP 地址(dnsStart -> dnsEnd)。
-
TCP 连接阶段: 建立 TCP 三次握手(connectStart -> TCP 握手 -> connectEnd)。
-
TLS/SSL 握手阶段(HTTPS 请求): 在 TCP 连接上建立安全加密通道(secureConnectStart -> secureConnectEnd)。
-
数据发送阶段: 发送 HTTP 请求头和请求体。
-
数据接收阶段: 接收 HTTP 响应头和响应体。
-
请求结束阶段: 请求结束,无论成功还是失败。
那么,在okHttpClient中添加eventListener监听,代码如下
kotlin
builder.eventListener(object : EventListener() {
private var dnsStartTime = 0L
private var dnsEndTime = 0L
private var connectStartTime = 0L
private var connectEndTime = 0L
private var secureConnectStartTime = 0L
private var secureConnectEndTime = 0L
private var requestStartTime = 0L
private var requestEndTime = 0L
private var responseStartTime = 0L
private var responseEndTime = 0L
private var totalStartTime = 0L
override fun callStart(call: Call) {
totalStartTime = System.currentTimeMillis()
Log.e(TAG, "【请求开始】URL: ${call.request().url}")
}
override fun dnsStart(call: Call, domainName: String) {
dnsStartTime = System.currentTimeMillis()
}
override fun dnsEnd(
call: Call,
domainName: String,
inetAddressList: List<InetAddress>
) {
dnsEndTime = System.currentTimeMillis()
Log.e(TAG, "【DNS解析】耗时: ${dnsEndTime - dnsStartTime} ms")
}
override fun connectStart(
call: Call,
inetSocketAddress: InetSocketAddress,
proxy: Proxy
) {
connectStartTime = System.currentTimeMillis()
}
override fun secureConnectStart(call: Call) {
secureConnectStartTime = System.currentTimeMillis()
Log.e(TAG, "【TCP 连接建立】耗时: ${secureConnectStartTime - connectStartTime}")
}
override fun secureConnectEnd(call: Call, handshake: Handshake?) {
secureConnectEndTime = System.currentTimeMillis()
Log.e(TAG, "【SSL/TLS 连接】耗时: ${secureConnectEndTime - secureConnectStartTime} ms")
}
override fun connectionAcquired(call: Call, connection: Connection) {
connectEndTime = System.currentTimeMillis()
Log.e(TAG, "【连接建立】耗时: ${connectEndTime - connectStartTime} ms")
}
override fun connectEnd(
call: Call,
inetSocketAddress: InetSocketAddress,
proxy: Proxy,
protocol: Protocol?
) {
val connectEndTime1 = System.currentTimeMillis()
Log.e(TAG, "【连接完成】耗时: ${connectEndTime1 - connectStartTime} ms")
}
override fun requestHeadersStart(call: Call) {
requestStartTime = System.currentTimeMillis()
}
override fun requestHeadersEnd(call: Call, request: Request) {
requestEndTime = System.currentTimeMillis()
Log.e(TAG, "【请求头发送】耗时: ${requestEndTime - requestStartTime} ms")
}
override fun responseHeadersStart(call: Call) {
responseStartTime = System.currentTimeMillis()
}
override fun responseHeadersEnd(call: Call, response: Response) {
responseEndTime = System.currentTimeMillis()
Log.e(TAG, "【响应头接收】耗时: ${responseEndTime - responseStartTime} ms")
Log.e(TAG, "【HTTP 请求/响应】: ${responseEndTime - requestStartTime} ms")
}
override fun callEnd(call: Call) {
val totalTime = System.currentTimeMillis() - totalStartTime
Log.e(TAG, "【请求完成】总耗时: ${totalTime} ms")
}
override fun callFailed(call: Call, ioe: IOException) {
Log.e(TAG, "请求失败: ${call.request().url}, ${ioe.message}")
ToastUtils.showShort("请求失败: ${call.request().url}, ${ioe.message}")
}
})
其中,几个回调的具体含义及调用顺序如下(基于 HTTPS 请求):
-
callStart 当前请求的生命周期正式开始,OkHttp 开始处理该请求。
-
dnsStart -> dnsEnd 进行DNS查询,将域名解析成IP
-
connectStart 开始建立 TCP 连接时触发,此时客户端开始向服务器发起 TCP 连接(三次握手的起点)。
-
secureConnectStart -> secureConnectEnd (仅 HTTPS) 在 TCP 连接建立完成后,开始进行 TLS/SSL 握手时触发。 已建立的 TCP 连接上执行 TLS/SSL 握手,用于建立安全加密通道
-
connectEnd 整个连接操作(包括 TCP 连接和 TLS/SSL 握手)逻辑上完成
-
connectionAcquired 获取到一个有效连接(可能是复用的连接)时触发,表示 OkHttp 从连接池中成功分配到了一个连接,或者新建连接已准备好使用。
-
requestHeadersStart -> requestHeadersEnd HTTP 请求头的发送开始 -> 请求头发送完成
-
(可选)requestBodyStart -> requestBodyEnd HTTP 请求体的发送开始-> 请求体发送完成 备注: GET 请求没有请求体,因此不会触发这两个回调。
-
responseHeadersStart -> responseHeadersEnd HTTP 响应头接收开始->HTTP 响应头接收完成
-
responseBodyStart -> responseBodyEnd HTTP 响应体接收开始->响应体接收完成,整个 HTTP 响应完成
-
callEnd 或 callFailed HTTP请求完成并成功结束时,或HTTP请求发生错误
其中,4 secureConnectStart 和 secureConnectEnd比较特殊,只有https才会触发。
那么,执行上面的代码,打印日志,查看结果。
yaml
E/RetrofitManager: 【请求开始】URL: https://www.wanandroid.com/lg/collect/list/0/json
E/RetrofitManager: 【DNS解析】耗时: 3 ms
E/RetrofitManager: 【TCP 连接建立】耗时: 132
E/RetrofitManager: 【SSL/TLS 连接】耗时: 421 ms
E/RetrofitManager: 【连接完成】耗时: 554 ms
E/RetrofitManager: 【连接建立】耗时: 555 ms
E/RetrofitManager: 【请求头发送】耗时: 1 ms
E/RetrofitManager: 【响应头接收】耗时: 0 ms
E/RetrofitManager: 【HTTP 请求/响应】: 136 ms
E/RetrofitManager: 【请求完成】总耗时: 721 ms
E/RetrofitManager: 【请求开始】URL: https://www.wanandroid.com/lg/collect/list/0/json
E/RetrofitManager: 【连接建立】耗时: 2849 ms
E/RetrofitManager: 【请求头发送】耗时: 1 ms
E/RetrofitManager: 【响应头接收】耗时: 0 ms
E/RetrofitManager: 【HTTP 请求/响应】: 122 ms
E/RetrofitManager: 【请求完成】总耗时: 134 ms
发现,第一次接口请求和第二次的区别,就在于secureConnectStart 和 secureConnectEnd,即TLS/SSL握手的耗时。
同样,发现,如果是使用了连接池复用连接的话,会直接略过DNS解析、连接握手建立的时间,直接发送请求。
下面是复用连接池和不复用连接池请求的区别:
makefile
// val connectionPool = ConnectionPool(0, 5, TimeUnit.MINUTES)
E/RetrofitManager: 【请求开始】URL: https://www.wanandroid.com/lg/collect/list/0/json
E/RetrofitManager: 【DNS解析】耗时: 21 ms
E/RetrofitManager: 【TCP 连接建立】耗时: 49
E/RetrofitManager: 【SSL/TLS 连接】耗时: 346 ms
E/RetrofitManager: 【连接完成】耗时: 395 ms
E/RetrofitManager: 【连接建立】耗时: 400 ms
E/RetrofitManager: 【请求头发送】耗时: 3 ms
E/RetrofitManager: 【响应头接收】耗时: 0 ms
E/RetrofitManager: 【HTTP 请求/响应】: 140 ms
E/RetrofitManager: 【请求完成】总耗时: 602 ms
E/RetrofitManager: 【请求开始】URL: https://www.wanandroid.com/lg/collect/list/0/json
E/RetrofitManager: 【DNS解析】耗时: 128 ms
E/RetrofitManager: 【TCP 连接建立】耗时: 42
E/RetrofitManager: 【SSL/TLS 连接】耗时: 67 ms
E/RetrofitManager: 【连接完成】耗时: 109 ms
E/RetrofitManager: 【连接建立】耗时: 111 ms
E/RetrofitManager: 【请求头发送】耗时: 4 ms
E/RetrofitManager: 【响应头接收】耗时: 0 ms
E/RetrofitManager: 【HTTP 请求/响应】: 122 ms
E/RetrofitManager: 【请求完成】总耗时: 399 ms
每次都重新进行DNS解析,TCP握手和SSL/TLS链接。
打印请求头会发现,没有connection:keep-alive,原来是http1.1这些是默认情况下会自动添加的,如果不复用连接池,也可以主动在头添加
arduino
builder.addHeader("connection","close")
这样也能做到不复用连接的效果。
优化
思路
到了这里,大致能够了解清楚一次https请求经过了哪些操作,那么,回到最开始,领导对于小米的要求,怎么去优化呢?
DNS可以优化,如果不进行域名解析,直接调用ip地址,速度会快,但,回头看日志,发现DNS解析时间总共也没多少时间,优化聊胜于无。
那么,握手呢?和后端协议,把安全的HTTPS改成HTTP,这样,第一次请求的时间也缩短了。--这种是顾头不顾尾的做法,pass。
chatGPT
问了chatGPT,有 优化DNS解析的,有 预热HTTPS连接的,有 复用连接池的,把这些都用在项目上,有加快,但不多(排除HTTPS预热),聊胜于无吧。
优化
既然这种是正常的现象,那么,如果想要优化,或者说,如果想要用户体验起来更加顺畅,那,该怎么办呢?
市场上类似信息推送流的app,会发现一种情况,当用户使用完app退出后,重新进入app,信息推送流的数据是上一次使用的数据,且app此时进行刷新操作。这样即做到了数据刷新,又优化了空白屏的用户等待的体验。
postman
重新回到上面提到的问题,为什么,post每次请求,时间都是差不多的?
点开详情,查看postman请求头
包含了Connection:keep-alive
的,那么,说明,连接被复用了,那么,按着理论来说,每次post的请求都是有复用的啊,那,为什么,第二次请求的时间,还是比第一次的慢呢?
有一种猜测,虽然,每次请求头里是keep-alive
,但说不定还都是重新进行dns解析和握手的,得想办法验证这个猜测。
抓包
利用Clarles进行抓包,发现能正常抓到postman发送的包,
果然,每次都重新进行DNS解析和TLS握手的,验证猜想。
结论
这次对网络的探索,确实学习到了一些东西。如果是平常开发过程中,自己确实不会注意到在这种情况,先把代码跑起来,先做好项目再说,先把手头上的工作搞定了,而不会注意到,为什么会发现这种现象,这是正常的还是有问题,这对吗?
可能,如果思路不对的话,自己还会从源码开始探索,想着是不是代码出现问题了。当然,也因为有AI,问题排查起来也顺畅很多。
最后
感谢你看到最后,最后说一两点~
1.如果你有不同的看法,欢迎你在文章下面进行评论留言。
2.如果文章对你有帮助,或者你认可的话,请随手点个赞,支持一下。
欢迎私信交流。
(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除,同样,转载请提前告知)