写点什么

由浅入深,聊聊 OkHttp 的那些事 (很长,很细节)

作者:Petterp
  • 2023-02-14
    北京
  • 本文字数:9311 字

    阅读完需:约 31 分钟

由浅入深,聊聊OkHttp的那些事(很长,很细节)

引言

Android 开发的世界中,有一些组件,无论应用层技术再怎么迭代,作为基础支持,它们依然在那里。比如当我们提到网络库时,总会下意识想到一个名字,即 OkHttp


尽管对于大多数开发者而言,通常情况下使用的是往往它的封装版本 Retrofit ,不过其底层依然离不开 Okhttp 作为基础支撑。而无论是自研网络库的二次封装,还是个人使用,OkHttp 也往往都是不二之选。


故本篇将以最新视角开始,用力一瞥 OkHttp 的设计魅力。


本文对应的 OkHttp 版本: 4.10.0


本篇定位 中高难度,将从背景到使用方式,再到设计思想与源码解析,尽可能全面、易懂。

背景

每一个技术都有其变迁的历史背景与特性,本小节,我们将聊一聊 Android 网络库 的迭代史,作为开篇引语,润润眼。 🔖


关于 Android 网络库 的迭代历史,如下图所示:



具体进展如下:


  • HttpClient

  • Android1.0 时推出。但存在诸多问题,比如内存泄漏,频繁的 GC 等。5.0 后,已被弃用;

  • HttpURLConnection

  • Android2.2 时推出,比 HttpClient 更快更稳定,Android4.4 之后底层已经被 Okhttp 替代;

  • volley

  • Google 2013 年开源,基于 HttpURLConnection 的封装,具有良好的扩展性和适用性,不过对于复杂请求或者大量网络请求时,性能较差。目前依然有不少项目使用(通常是老代码的维护);

  • okhttp

  • Square 2013 年开源,基于 原生 Http 的底层设计,具有 快速稳定节省资源 等特点。是目前诸多热门网络请求库的底层实现,比如 RetrofitRxHttp 等;

  • Retrofit

  • Square 2013 年开源,基于 OkHttp 的封装,目前 主流 的网络请求库。

  • 通过注解方式配置网络请求、REST 风格 api、解耦彻底、经常会搭配 Rx 等 实现 框架联动;


上述的整个过程,也正是伴随了 Android 开发的各个时期,如果将上述分为 5 个阶段 的话,那么则为:


HttpClient -> HttpURLConnection -> volley -> okhttp -> Retrofit*


通过 Android 网络库 的迭代历史,我们不难发现,技术变迁越来越趋于稳定,而 OkHttp 也已经成为了基础组件中不可所缺的一员。

设计思想

当聊到 OkHttp 的设计思想,我们想知道什么?


应用层去看,熟练的开发者会直接喊出拦截器,巴拉巴拉…

而作为初学者,可能更希望的事广度与解惑,OkHttp 到底牛在了什么地方,或者说常说的 拦截器到底是什么 ? 🧐


在官方的描述中,OkHttp 是一个高效的 Http 请求框架 ,旨在 简化 客户端网络请求,提高 请求效率。


具体设计思想与特性如下:


  • 连接复用 :避免在每个请求之间重新建立连接。

  • 连接池 降低了请求延迟 (HTTP/2 不可用情况下);

  • 自动重试 :在请求失败时自动重试请求,从而提高请求可靠性。

  • 自动处理缓存 :会按照预定的缓存策略处理缓存,以便最大化网络效率。

  • 支持 HTTP/2, 并且允许对同一个主机的所有请求共享一个套接字(HTTP/2);

  • 简化 Api:Api 设计简单明了,易于使用,可以轻松发起请求获取响应,并处理异常。

  • 支持 gzip 压缩 :OkHttp 支持 gzip 压缩,以便通过减少网络数据的大小来提高网络效率。


特别的,如果我们的服务器或者域名有 多个 IP 地址OkHttp 将在 第一次 连接失败时尝试替代原有的地址(对于 IPv4+IPv6 和托管在冗余数据中心的服务是必需的)。并且支持现代 TLS 功能(TLS 1.3、ALPN、证书固定)。它可以配置为回退以实现广泛的连接。


总的来说,其设计思想是通过 简化请求过程提高请求效率提高请求可靠性,从而提供 更快的响应速度


应用层的整个请求框架图如下:


使用方式

在开始探究设计原理与思想之前,我们还是要先看看最基础的使用方式,以便为后续做一些铺垫。


// build.gradleimplementation "com.squareup.okhttp3:okhttp:4.10.0"
复制代码


// Android Manifest<uses-permission android:name="android.permission.INTERNET" />
复制代码


发起一个 get 请求



拦截器的使用



总结起来就是下面几步:


  1. 创建 OkHttpClient 对象;

  2. 构建 Request ;

  3. 调用 OkHttpClient 执行 request 请求 ;

  4. 同步阻塞 或者 异步回调 方式接收结果;


更多使用方式,可以在搜索其他同学的教程,这里仅仅只是作为后续解析原理时的必要基础支撑。

源码分析

基础配置

OkHttpClient

val client = OkHttpClient.Builder().xxx.build()
复制代码


由上述调用方式,我们便可以猜出,这里使用了 构建者模式 去配置默认的参数,所以直接去看 OkHttpClient.Builder 支持的参数即可,具体如下:



具体的属性意思在代码中也都有注释,这里我们就不在多提了。


需要注意的是,在使用过程中,对于 OkHttpClient 我们还是应该缓存下来或者使用单例模式以便后续复用,因为其相对而言还是比较重。



Request

指客户端发送到服务器的 HTTP 请求


OkHttp 中,可以使用 Request 对象来构建请求,然后使用 OkHttpClient 对象来发送请求。通常情况下,一个请求包括了 请求头请求方法请求路径请求参数url 地址 等信息。主要是用来请求服务器返回某些资源,如网页、图片、数据等。


具体源码如下所示:


Request.Builder().url("https://www.baidu.com").build()
复制代码


open class Builder {  // url地址  internal var url: HttpUrl? = null         // 请求方式  internal var method: String                   // 请求头  internal var headers: Headers.Builder             // 请求体  internal var body: RequestBody? = null           // 请求tag  internal var tags: MutableMap<Class<*>, Any>    }
复制代码



发起请求

execute()

用于执行 同步请求 时调用,具体源码如下:


client.newCall(request).execute()
复制代码


接下来我们再去看看 client.newCall() , 即请求发起时的逻辑。



当我们使用 OkHttpClient.newCall() 方法时,实际是创建了一个新的 RealCall 对象,用于 应用层与网络层之间的桥梁,用于处理连接、请求、响应以及流 ,其默认构造函数中需要传递 okhttpClient 对象以及 request


接着,使用了 RealCall 对象调用了其 execute() 方法开始发起请求,该方法内部会将当前的 call 加入我们 Dispatcher 分发器内部的 runningSyncCalls 队列中取,等待被执行。接着调用 getResponseWithInterceptorChain() ,使用拦截器获取本次请求响应的内容,这也即我们接下来要关注的步骤。



enqueue()

执行 异步请求 时调用,具体源码如下:


client.newCall(request).enqueue(CallBack)
复制代码



当我们调用 RealCall.enqueue() 执行异步请求时,会先将本次请求加入 Dispather.readyAsyncCalls 队列中等待执行,如果当前请求是 webSocket 请求,则查找与当前请求是同一个 host 的请求,如果存在一致的请求,则复用先前的请求。


接下来调用 promoteAndExecute() 将所有符合条件可以请求的 Call 从等待队列中添加到 可请求队列 中,再遍历该请求队列,将其添加到 线程池 中去执行。


继续沿着上面的源码,我们去看 asyncCall.executeOn(executorService) ,如下所示:



上述逻辑也很简单,当我们将任务添加到线程池后,当任务被执行时,即触发 run() 方法的调用。该方法中会去调用 getResponseWithInterceptorChain() 从而使用拦截器链获取服务器响应,从而完成本次请求。请求成功后则调用我们开始时的 callback 对象 的 onResponse() 方法,异常(即失败时)则调用 onFailure() 方法。



拦截器链

在上面我们知道,他们最终都走到了 RealCall.getResponseWithInterceptorChain() 方法,即使用 拦截器链 获取本次请求的响应内容。不过对于初看 OkHttp 源码的同学,这一步应用会有点迷惑,拦截器链 是什么东东👾?


在解释 拦截器链 之前,我们不妨先看一下 RealCall.getResponseWithInterceptorChain() 方法对应的源码实现,然后再去解释为什么,也许更容易理解。


具体源码如下:



上述的逻辑非常简单,内部会先创建一个局部拦截器集合,然后将我们自己设置的普通拦截器添加到该集合中,然后添加核心的 5 大拦截器,接着再将我们自定义的网络拦截器也添加到该集合中,最终才添加了真正用于执行网络请求的拦截器。接着创建了一个拦截器责任链 RealInterceptorChain ,并调用其 proceed() 方法开始执行本次请求。



责任链模式

在上面我们说到了,要解释 OkHttp 的拦截器链,我们有必要简单聊一下什么是责任链模式?


责任链模式(Chain of Responsibility)是一种处理请求的模式,它让多个处理器都有机会处理该请求,直到其中某个处理成功为止。责任链模式把多个处理器串成链,然后让请求在链上传递。

摘自 责任链模式 @廖雪峰


Android 中常见的事件分发为例:当我们的手指点击屏幕开始,用户的触摸事件从 Activity 开始分发,接着从 windows 开始分发到具体的 contentView(ViewGroup) 上,开始调用其 dispatchTouEvent() 方法进行事件分发。在这个方法内,如果当前 ViewGroup 不进行拦截,则默认会继续向下分发,寻找当前 ViewGroup 下对应的触摸位置 View ,如果该 View 是一个 ViewGroup ,则重复上述步骤。如果事件被某个 view 拦截,则触发其 onTouchEvent() 方法,接着交由该 view 去消费该事件。而如果事件传递到最上层 view 还是没人消费,则该事件开始按照原路返回,先交给当前 view 自己的 onTouchEvent() ,因为自己不消费,则调用其 父ViewGrouponTouchEvent() ,如此层层传递,最终又交给了 Act 自行处理。上述这个流程,就是 责任链模式 的一种体现。


如下图所示:



上图来自 Android事件分发机制三:事件分发工作流程 @一只修仙的猿




看完什么是责任链模式,让我们将思路转回到 OkHttp 上面,我们再去看一下 RealInterceptorChain 源码。




上述逻辑如下:


  • getResponseWithInterceptorChain() 方法内部最终调用 RealInterceptorChain.proceed() 时,内部传入了一个默认的 index ,这个 index 就代表了当前要调用的 拦截器 item ,并在方法内部每次创建一个新的 RealInterceptorChain 链,index+1,再调用当前拦截器 intercept() 方法时,然后将下一个链传入;

  • 最开始调用的是用户自定义的 普通拦截器,如果上述我们添加了一个 CustomLogInterceptor 的拦截器,当获取 response 时,我们需要调用 Interceptor.Chain.proceed() ,而此时的 chain 正是下一个拦截器对应的 RealInterceptorChain

  • 上述流程里,index 从 0 开始,以此类推,一直到链条末尾,即 拦截器集合长度-1 处;

  • 当遇到最后一个拦截器 CallServerInterceptor 时,此时因为已经是最后一个拦截器,链条肯定要结束了,所以其内部肯定也不会调用 proceed() 方法。

  • 相应的,为什么我们在前面说 它 是真正执行与服务器建立实际通讯的拦截器?

  • 因为这个里会获取与服务器通讯的 response ,即最初响应结果,然后将其返回上一个拦截器,即我们的网络拦截器,再接着又向上返回,最终返回到我们的普通拦截器处,从而完成整个链路的路由。


参照上面的流程,即大致思路图如下:


<img src="https://cdn.staticaly.com/gh/Petterpx/ImageRespoisty@main/img/petterp-image.3v05jqplxmu0.png" alt="petterp-image" style="zoom:50%;" />



拦截器

RetryAndFollowUpInterceptor

见名知意,用于 请求失败重试 工作以及 重定向 的后续请求工作,同时还会对 连接 做一些初始化工作。



上述的逻辑,我们分为四段进行分析:


  1. 请求时如果遇到异常,则根据情况去尝试恢复,如果不能恢复,则抛出异常,跳过本次请求;如果请求成功,则在 finally 里释放资源;

  2. 如果请求是重试之后的请求,那么将重试前请求的响应体设置为 null,并添加到当前响应体的 priorResponse 字段中;

  3. 根据当前的 responseCode 判断是否需要重试,若不需要,则返回 response ;若需要,则返回 request ,并在后续检查当前重试次数是否达到阈值;

  4. 重复上述步骤,直到步骤三成功。


在第一步时,获取 response 时,需要调用 realChain.proceed(request) ,如果你还记得上述的责任链,所以这里触发了下面的拦截器执行,即 BridgeInterceptor



BridgeInterceptor

用于 客户端和服务器 之间的沟通 桥梁 ,负责将用户构建的请求转换为服务器需要的请求。比如添加 content-typecookie 等,再将服务器返回的 response 做一些处理,转换为客户端所需要的 response,比如移除 Content-Encoding ,具体见下面源码所示:



上述逻辑如下:


  1. 首先调用 chain.request() 获取原始请求数据,然后开始重新构建请求头,添加 header 以及 cookie 等信息;

  2. 将第一步构建好的新的 request 传入 chain.proceed() ,从而触发下一个拦截器的执行,并得到 服务器返回的 response。然后保存 response 携带的 cookie,并移除 header 中的 Content-EncodingContent-Length,并同步修改 body



CacheInterceptor

见名知意,其用于网络缓存,开发者可以通过 OkHttpClient.cache() 方法来配置缓存,在底层的实现处,缓存拦截器通过 CacheStrategy 来判断是使用网络还是缓存来构建 response。具体的 cache 策略采用的是 DiskLruCache


Cache 的策略如下图所示:



具体源码如下所示:



具体的逻辑如上图所示,具体可以参照上述的 Cache 流程图,这里我们再说一下 CacheStrategy 这个类,即决定何时使用 网络请求、响应缓存。


CacheStrategy




ConnectInterceptor

实现与服务器真正的连接。



上述流程如下:


  • 初始化 一个 exchange 对象;

  • 根据 exchange 对象来复制创建一个新的连接责任链;

  • 执行该连接责任链。


那 Exchange 是什么呢?


在官方的解释里,其用于 传递单个 HTTP 请求和响应对,在 ExchangeCode 的基础上担负了一些管理及事件分发的作用。

具体而言,ExchangeRequest 相对应,新建一个请求时就会创建一个 Exchange,该 Exchange 负责将这个请求发送出去并读取到响应数据,而具体的发送与接收数据使用的则是 ExchangeCodec


相应的,ExchangeCode 又是什么呢?


ExchangeCodec 负责对 request 编码及解码 Response ,即写入请求及读取响应,我们的请求及响应数据都是通过它来读写。

通俗一点就是,ExchangeCodec 是请求处理器,它内部封装了 OkHttp 中执行网络请求的细节实现,其通过接受一个 Request 对象,并在内部进行处理,最终生成一个符合 HTTP 协议标准的网络请求,然后接受服务器返回的 HTTP 响应,并生成一个 Response 对象,从而完成网络请求的整个过程。


额外的,我们还需要再提一个类,ExchangeFinder


用于寻找可用的 Exchange ,然后发送下一个请求并接受下一个响应。


虽然上述流程看起来似乎很简单,但我们还是要分析下具体的流程,源码如下所示:

RealCall.initExchange()

初始化 Exchage 的过程。


ExchangeFinder 找到一个新的或者已经存在的 ExchangeCodec,然后初始化 Exchange ,以此来承载接下来的 HTTP 请求和响应对。




ExchangeFinder.find()

查找 ExchangeCodec(请求响应编码器) 的过程。



接下来我们看看查找 RealConnection 的具体过程:



上述的整个流程如下:


上述会先通过 ExchangeFinderRealConnecionPool 中尝试寻找已经存在的连接,未找到则会重新创建一个 RealConnection(连接) 对象,并将其添加到连接池里,开始连接。然后根据找到或者新创建 RealConnection 对象,并根据当前请求协议创建不同的 ExchangeCodec 对象并返回,最后初始化一个 Exchange 交换器并返回,从而实现了 Exchange 的初始化过程。


在具体找寻 RealConnection 的过程中,一共尝试了 5 次,具体如下:


  1. 尝试重连 call 中的 connection,此时不需要重新获取连接;

  2. 尝试从连接池中获取一个连接,不带路由与多路复用;

  3. 再次尝试从连接池中获取一个连接,带路由,不带多路复用;

  4. 手动创建一个新连接;

  5. 再次尝试从连接池中获取一个连接,带路由与多路复用;


Exchange 初始化完成后,再复制该对象创建一个新的 Exchange ,并执行下一个责任链,从而完成连接的建立。



networkInterceptors

网络拦截器,即 client.networkInterceptors 中自定义拦截器,与普通的拦截器 client.interceptors 不同的是:


由于网络拦截器处于倒数第二层,在 RetryAndFollowUpInterceptor 失败或者 CacheInterceptor 返回缓存的情况下,网络拦截器无法被执行。而普通拦截器由于第一步就被就执行到,所以不受这个限制。



CallServerInterceptor

链中的最后一个拦截器,也即与服务器进行通信的拦截器,利用 HttpCodec 进行数据请求、响应数据的读写。


具体源码如下:



先写入要发送的请求头,然后根据条件判断是否写入要发送的请求体。当请求结束后,解析服务器返回的响应头,构建一个新的 response 并返回;如果 response.code100,则重新读取响应体并构建新的 response。因为这是最底层的拦截器,所以这里肯定不会再调用 proceed() 再往下执行。

小结

至此,关于 OkHttp 的分析,到这里就结束了。为了便于理解,我们再串一遍整个思路:


OkHttp 中,RealCallCall 的实现类,其负责 执行网络请求 。其中,请求 requestDispatcher 进行调度,其中 异步调用 时,会将请求放到到线程池中去执行; 而同步的请求则只是会添加到 Dispatcher 中去管理,并不会有线程池参与协调执行。


在具体的请求过程中,网络请求依次会经过下列拦截器组成的责任链,最后发送到服务器。


  1. 普通拦截器,client.interceptors()

  2. 重试、重定向拦截器 RetryAndFollowUpInterceptor

  3. 用于客户端与服务器桥梁,将用户请求转换为服务器请求,将服务器响应转换为用户响应的的 BridgeInterceptor

  4. 决定是否需要请求服务器并写入缓存再返回还是直接返回服务器响应缓存的 CacheInterceptor;

  5. 与服务器建立连接的 ConnectInterceptor

  6. 网络拦截器,client.networkInterceptors();

  7. 执行网络请求的 CallServerInterceptor;


而相应的服务器响应体则会从 CallServerInterceptor 开始依次往前开始返回,最后由客户端进行处理。


需要注意的是,当我们 RetryAndFollowUpInterceptor 异常或者 CacheInterceptor 拦截器直接返回了有效缓存,后续的拦截器将不会执行。

常见问题

OkHttp 如何判断缓存有效性?


这里其实主要说的是 CacheInterceptor 拦截器里的逻辑,具体如下:


OkHttp 使用 HTTP 协议 中的 缓存控制机制 来判断缓存是否有效。如果请求头中包含 "Cache-Control""If-None-Match" / "If-Modified-Since" 字段,OkHttp 将根据这些字段的值来决定是否使用缓存或从网络请求响应。


Cache-Control 指 包含缓存控制的指令,例如 "no-cache""max-age" ;

If-None-Match 指 客户端缓存的响应的 ETag 值,如果服务器返回相同的 ETag 值,则说明响应未修改,缓存有效;

If-Modified-Since 指 客户端缓存的响应的最后修改时间,如果服务器确定响应在此时间后未更改,则返回 304 Not Modified 状态码,表示缓存有效。


相应的,OkHttp 也支持自定义缓存有效性控制,开发者可以创建一个 CacheControl 对象,并将其作为请求头添加到 Request 中,如下所示:


// 禁止OkHttp使用缓存val cacheControl = CacheControl.Builder()            .noCache()            .build()val request = Request.Builder()            .cacheControl(cacheControl)            .url("https://www.baidu.com")            .build()
复制代码


OkHttp 如何复用 TCP 连接?


这个其实主要说的是 ConnectInterceptor 拦截器中初始化 Exchange 时内部做的事,具体如下:


OkHttp 使用连接池 RealConnectionPool 管理所有连接,连接池将所有活动的连接存储在池中,并维护了一个空闲的连接列表(TaskQueue),当需要新的连接时,优先尝试从这个池中找,如果没找到,则 重新创建 一个 RealConnection 连接对象,并将其添加到连接池中。在具体的寻找连接的过程中,一共进行了下面 5 次尝试:


  1. 尝试重连 RealCall 中的 connection,此时不需要重新获取连接;

  2. 尝试从连接池中获取一个连接,不带路由与多路复用;

  3. 再次尝试从连接池中获取一个连接,带路由,不带多路复用;

  4. 手动创建一个新连接;

  5. 再次尝试从连接池中获取一个连接,带路由与多路复用;


当然 OkHttp 也支持自定义连接池,具体如下:



上述代码中,创建了一个新的连接池,并设置其保留最多 maxIdleConnections 个空闲连接,并且连接的存活期为 keepAliveDuration 分钟。


OKHttp 复用 TCP 连接的好处是什么?


OkHttp 是由连接池管理所有连接,通过连接池,从而可以限制连接的 最大数量,并且对于空闲的连接有相应的 存活期限 ,以便在长时间不使用后关闭连接。当请求结束时,并且将保留该连接,便于后续 复用 。从而实现了在多个请求之间共享连接,避免多次建立和关闭 TCP 连接的开销,提高请求效率。


OkHttp 中的请求和响应 与 网络请求和响应,这两者有什么不同?


OkHttp 中的的请求和响应指的是客户端创建的请求对象 Request 和 服务端返回的响应对象 Response,这两个对象用于定义请求和响应的信息。网络请求和响应指的是客户端向服务端发送请求,服务端返回相应的过程。


总的来说就是,请求和响应是应用程序内部自己的事,网络请求和响应则是发生在网络上的请求和响应过程


OkHttp 应用拦截器和网络拦截器的区别?


  • 从调用方式上而言,应用拦截器指的是 OkhttpClient.intercetors ,网络拦截器指的是 OkHttpClient.netIntercetors

  • 从整个责任链的调用来看,应用拦截器一定会被执行一次,而网络拦截器不一定会执行或者执行多次情况,比如当我们 RetryAndFollowUpInterceptor 异常或者 CacheInterceptor 拦截器直接返回了有效缓存,后续的拦截器将不会执行,相应的网络拦截器也自然不会执行到;当我们发生 错误重试 或者 网络重定向 时,网络拦截器此时可能就会执行多次。

  • 其次,除了 CallServerInterceptorCacheIntercerceptor 缓存有效之外,每个拦截器都应该至少调用一次 realChain.proceed() 方法。但应用拦截器可以调用多次 processed() 方法,因为其在请求流程中是可以递归调用;而网络拦截器只能调用一次 processed() 方法,否则将导致请求重复提交,影响性能,另外,网络拦截器没有对请求做修改的可能性,因此不需要再次调用 processed() 方法。

  • 使用方式的 本质而言,应用拦截器可以 拦截和修改请求和响应 ,但 不能修改网络请求和响应 。比如使用应用拦截器添加请求参数、缓存请求结果;网络拦截器可以拦截和修改网络请求和响应。例如使用网络拦截器添加请求头、修改请求内容、检查响应码等。

  • 在相应的执行顺序上,网络拦截器是 先进先出(FIFO) ,应用拦截器是 先进后出(FILO) 的方式执行。

结语

本篇中,我们从网络库的迭代历史,一直到 OkHttp 的使用方式、设计思想、源码探索,最后又聊了聊常见的一些问题,从而较系统的了解了 OkHttp 的方方面面,也解释了 OkHttp应用层 的相关问题,当然这些问题我相信也仅仅只是冰山一角🧩。 更多面试相关,或者实际问题,仍需要我们自己再进行完善,从而形成全面的透析力。


这篇文章断断续续写了将近两周,其中肯定有不少部分存在缺陷或者逻辑漏洞,如果您发现了,也可以告诉我。


通过这篇文章,于我个人而言,也是完成了对于 OkHttp应用层 一次较系统的了解,从而也完善了知识拼图中重要的一块,期待作为读者的你也能有如此或者更深的体会。🏃🏻

更多

这是 解码系列 - OkHttp 篇,如果你觉得这个系列写的还不错,不妨点个关注催更一波,当然也可以看看其他篇:


参阅

关于我

我是 Petterp ,一个 Android 工程师 ,如果本文对你有所帮助,欢迎 点赞、评论、收藏,你的支持是我持续创作的最大鼓励!

发布于: 2023-02-14阅读数: 23
用户头像

Petterp

关注

还未添加个人签名 2020-07-16 加入

还未添加个人简介

评论

发布
暂无评论
由浅入深,聊聊OkHttp的那些事(很长,很细节)_android_Petterp_InfoQ写作社区