网络接口请求实践

前言

随着工作年限的增长,我渐渐发现,网络和网络协议,是应用开发绕不过的坎。

只是这么感慨一下,最近工作中有了一些感触,所以写一篇文章记录一下。

本文不是探索网络请求的原理,不是深究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的值, 它的值有四个,分别是NONEBASICHEADERSBODY

实验

打印结果

点击按钮,请求首页数据,然后再次请求

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请求中,主要执行了下面的步骤。

  1. DNS 查询阶段: 将域名解析为 IP 地址(dnsStart -> dnsEnd)。

  2. TCP 连接阶段: 建立 TCP 三次握手(connectStart -> TCP 握手 -> connectEnd)。

  3. TLS/SSL 握手阶段(HTTPS 请求): 在 TCP 连接上建立安全加密通道(secureConnectStart -> secureConnectEnd)。

  4. 数据发送阶段: 发送 HTTP 请求头和请求体。

  5. 数据接收阶段: 接收 HTTP 响应头和响应体。

  6. 请求结束阶段: 请求结束,无论成功还是失败。

那么,在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 请求):

  1. callStart 当前请求的生命周期正式开始,OkHttp 开始处理该请求。

  2. dnsStart -> dnsEnd 进行DNS查询,将域名解析成IP

  3. connectStart 开始建立 TCP 连接时触发,此时客户端开始向服务器发起 TCP 连接(三次握手的起点)。

  4. secureConnectStart -> secureConnectEnd (仅 HTTPS) 在 TCP 连接建立完成后,开始进行 TLS/SSL 握手时触发。 已建立的 TCP 连接上执行 TLS/SSL 握手,用于建立安全加密通道

  5. connectEnd 整个连接操作(包括 TCP 连接和 TLS/SSL 握手)逻辑上完成

  6. connectionAcquired 获取到一个有效连接(可能是复用的连接)时触发,表示 OkHttp 从连接池中成功分配到了一个连接,或者新建连接已准备好使用。

  7. requestHeadersStart -> requestHeadersEnd HTTP 请求头的发送开始 -> 请求头发送完成

  8. (可选)requestBodyStart -> requestBodyEnd HTTP 请求体的发送开始-> 请求体发送完成 备注: GET 请求没有请求体,因此不会触发这两个回调。

  9. responseHeadersStart -> responseHeadersEnd HTTP 响应头接收开始->HTTP 响应头接收完成

  10. responseBodyStart -> responseBodyEnd HTTP 响应体接收开始->响应体接收完成,整个 HTTP 响应完成

  11. 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.如果文章对你有帮助,或者你认可的话,请随手点个赞,支持一下。

欢迎私信交流。

(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除,同样,转载请提前告知)

相关推荐
安东尼肉店4 小时前
Android compose屏幕适配终极解决方案
android
2501_916007475 小时前
HTTPS 抓包乱码怎么办?原因剖析、排查步骤与实战工具对策(HTTPS 抓包乱码、gzipbrotli、TLS 解密、iOS 抓包)
android·ios·小程序·https·uni-app·iphone·webview
feiyangqingyun6 小时前
基于Qt和FFmpeg的安卓监控模拟器/手机摄像头模拟成onvif和28181设备
android·qt·ffmpeg
用户20187928316710 小时前
ANR之RenderThread不可中断睡眠state=D
android
煤球王子10 小时前
简单学:Android14中的Bluetooth—PBAP下载
android
小趴菜822710 小时前
安卓接入Max广告源
android
齊家治國平天下10 小时前
Android 14 系统 ANR (Application Not Responding) 深度分析与解决指南
android·anr
ZHANG13HAO10 小时前
Android 13.0 Framework 实现应用通知使用权默认开启的技术指南
android
【ql君】qlexcel10 小时前
Android 安卓RIL介绍
android·安卓·ril
写点啥呢10 小时前
android12解决非CarProperty接口深色模式设置后开机无法保持
android·车机·aosp·深色模式·座舱