网络接口请求实践

前言

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

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

本文不是探索网络请求的原理,不是深究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.如果文章对你有帮助,或者你认可的话,请随手点个赞,支持一下。

欢迎私信交流。

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

相关推荐
QING61844 分钟前
Kotlin containsValue用法及代码示例
android·kotlin·源码阅读
QING6181 小时前
Kotlin coerceAtMost用法及代码示例
android·kotlin·源码阅读
QING6181 小时前
Kotlin commonSuffixWith用法及代码示例
android·kotlin·源码阅读
QING6181 小时前
Kotlin coerceAtLeast用法及代码示例
android·kotlin·源码阅读
光军oi2 小时前
Mysql从入门到精通day5————子查询精讲
android·数据库·mysql
鸿蒙布道师11 小时前
鸿蒙NEXT开发Base64工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
jiet_h11 小时前
Android adb 的功能和用法
android·adb
美狐美颜sdk12 小时前
美颜SDK兼容性挑战:如何让美颜滤镜API适配iOS与安卓?
android·深度学习·ios·美颜sdk·第三方美颜sdk·视频美颜sdk
居然是阿宋12 小时前
深入理解 YUV 颜色空间:从原理到 Android 视频渲染
android·音视频
KevinWang_13 小时前
DialogFragment 不适合复用
android