ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OkHttp原理与实战:从请求调度到拦截器链的完整拆解

OkHttp原理与实战:从请求调度到拦截器链的完整拆解 不少做Android的朋友背过这道面试题讲讲OkHttp的原理。标准答案无非“连接池”“拦截器”“Dispatcher”这几个词可一旦到了线上真出问题——某个接口间歇性超时、缓存明明配了就是不生效、加了拦截器后Header顺序乱了——光会背概念完全不够用。我这两年维护的App日活百万级网络层就是基于OkHttp自研的封装踩过的坑比想象中多得多。这篇文章不打算给你划重点而是按我自己阅读源码和实战排查的顺序把OkHttp从发起一次请求到拿到响应的完整链路拆开看同时也给出可以直接抄作业的封装方案和问题排查清单。无论你是刚接触OkHttp想明白它怎么工作还是用了一段时间想彻底搞懂底层机制都值得花几分钟读完。1. 回到没有OkHttp的年代HttpURLConnection为什么让人抓狂1.1 老一代网络库的三大痛点最早做Android网络请求官方给的方案是HttpURLConnection或老牌的Apache HttpClient。那时候写一个简单的GET请求代码倒是不复杂但用起来是真的难受。第一个痛点是连接复用几乎等于没有。HttpURLConnection虽然名义上支持keep-alive但在实际设备上每发起一个新请求经常要重新走一遍TCP三次握手。如果接口数量一多光握手耗时就能占掉请求总时长的大头。更要命的是TLS握手一次HTTPS请求需要至少两个RTT才能建立安全通道在高延迟网络下体验很差。第二个痛点是默认不支持HTTP/2。那时候Android设备的网络库基本停留在HTTP/1.1一个连接同时只能串行处理一个请求。如果页面要同时拉好几个资源要么排队等响应要么额外开多条连接这对移动端电量、内存、带宽都是压力。第三个痛点是没有统一的拦截器机制。加公共Header、打印日志、统计耗时、处理错误码这些横切逻辑没有标准的方式只能靠每个人自己封装一层工具类最后往往变成一个大而全的HttpUtils几百行代码塞满各种if-else维护起来极为痛苦。1.2 OkHttp来了它到底改变了什么OkHttp是Square公司开源的网络库2013年发布第一个版本之后在Android社区迅速普及。它做的事情本质上就是针对上面三个痛点逐一给出工程级解答。连接池复用同一个Host的多次请求复用底层的TCP或TLS连接大幅减少握手次数。HTTP/2支持一条连接上多路复用多个请求可以并行跑不需要排队等。拦截器链把请求处理过程拆成一条链每个环节各司其职还可以由开发者插入自定义逻辑。自动重试与重定向遇到路由切换、连接失效时自动处理开发者不用自己维护状态机。到了4.x版本源码完全用Kotlin重写但对外API基本兼容3.x所以你在项目中从3.x升到4.x通常只需要改改依赖版本号最多处理少量编译报错。1.3 依赖引入与最简单的用法OkHttp目前在Maven Central上的最新稳定版本是4.12.0Android工程里这样引入implementation com.squareup.okhttp3:okhttp:4.12.0 implementation com.squareup.okhttp3:logging-interceptor:4.12.0 testImplementation com.squareup.okhttp3:mockwebserver:4.12.0建议把logging-interceptor也一起引进来开发阶段打日志能省太多事。mockwebserver用于本地起一个假服务做测试写单元测试时非常有用。一个最基本的GET请求长这样val client OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build() val request Request.Builder() .url(https://api.example.com/users) .header(Accept, application/json) .get() .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { // 网络异常 } override fun onResponse(call: Call, response: Response) { response.use { // 读取响应体 } } })上手很简单难的是把里面每个机制吃透。从下一章开始我会按一次请求的完整生命周期来拆。2. 从newCall到execute/enqueue一次请求的调度与生命周期2.1 Request与Response两个看起来很简单的对象Request暴露给开发者的接口是典型Builder模式url()设置地址header()或addHeader()添加请求头get()、post(body)、put(body)等指定HTTP方法tag()打标记。值得注意的一点是header(X, a)会覆盖同名旧值而addHeader(X, a)是追加两者语义不同封装通用Header时别搞混。Response包含状态码、响应头、响应体三部分。响应体是一个ResponseBody对象内部本质上是一个流不是一次性把所有字节读进内存。这意味着response.body?.string()只能调用一次第二次调用会直接抛异常因为流已经关闭了。如果你要把响应体读出来既做解析又做缓存先拿到字符串再复用不要多次访问body。另一个容易忽略之处Response是实现了Closeable接口的读取完响应体之后要关闭连接让它归还到连接池。最稳妥的打开方式是response.use { ... }或者直接在onResponse回调里用try-with-resources思路处理。2.2 Dispatcher三队列与并发调度规则client.newCall(request)返回的是一个Call对象可以把它理解成“一次待执行的请求任务”。Call有两种执行方式execute()是同步阻塞enqueue()是异步回调。内部真正做调度的是Dispatcher。它维护了三个队列readyAsyncCalls排队等待执行的异步请求runningAsyncCalls正在执行的异步请求runningSyncCalls正在执行的同步请求当你调用enqueue()时Call并不会立刻跑去线程池里执行而是先进入readyAsyncCalls。Dispatcher会调用自己的promoteAndExecute()方法检查当前能否把你这个请求“提拔”到执行队列。规则有两条硬限制全局正在执行的异步请求数不超过maxRequests默认64同一个Host正在执行的请求数不超过maxRequestsPerHost默认5超过限制的请求会乖乖待在readyAsyncCalls里等前面某个请求结束腾出名额再被调度执行。这里设计的深意是防止客户端对某个服务器发起过量的并发连接把服务端打爆也避免自己在移动网络环境下创建太多socket。Dispatcher内部使用的线程池配置是核心线程数0、最大线程数不设上限、空闲线程存活60秒、使用SynchronousQueue。因为每次任务进来都临时创建线程空闲60秒后自动回收既不会长期占用线程资源又能扛住瞬时并发。我见过一些团队为了“提升并发”自己new了一个固定大小线程池传给Dispatcher但如果你不了解业务并发模型反而会让原本健壮的调度变成瓶颈。大多数场景下用默认Dispatcher就够了。2.3 请求取消tag、cancel与协程协作OkHttp的Call#cancel()可以取消一个请求取消后onFailure会收到IOException。但实际项目中更常见的是按业务标记批量取消。Request.Builder.tag(Object)就是干这个用的给请求打上业务标签然后调用client.dispatcher.cancelAll() client.dispatcher.cancelTag(avatar_upload)网络上会对每个标签遍历运行中的请求并取消。注意cancel()是幂等的重复调用不会出问题。在协程环境里如果你用enqueue包了一层suspend函数需要把协程的取消事件和Call.cancel()联动起来这点在后面的实战封装章节会给出完整代码。3. 拦截器链剥开OkHttp的洋葱看真实请求怎么走3.1 从应用层到网络层的完整顺序OkHttp最核心的设计是拦截器链。它把一次请求的处理拆成多个拦截器串联执行每个拦截器只负责一小块工作。在RealCall.getResponseWithInterceptorChain()里这个链条长这样开发者通过addInterceptor()添加的自定义拦截器RetryAndFollowUpInterceptor负责请求重试与重定向BridgeInterceptor负责把用户的Request转换成真正的网络请求补上Host、Content-Length、Content-Type、User-Agent等Header并透明处理gzipCacheInterceptor负责读缓存、写缓存ConnectInterceptor从连接池中获取连接开发者通过addNetworkInterceptor()添加的拦截器CallServerInterceptor真正向服务器写请求、读响应一句话总结执行顺序应用拦截器先跑网络拦截器在连接建立之后、真正发包之前跑。3.2 Application拦截器与Network拦截器差的不只是位置很多初学者搞不清楚addInterceptor和addNetworkInterceptor的区别以为只是名字不同。它们有两个关键行为差异。第一应用拦截器只执行一次即使请求因为401重定向、因为路由切换重试应用拦截器也只在最开始执行一次网络拦截器则不同只要发生重定向或重试它就会跟着再执行一遍。如果你在网络拦截器里做了耗时统计遇到重试时统计到的是多次请求的总和这点容易误导排查。第二应用拦截器看得到最终响应它调用chain.proceed()拿到的是CacheInterceptor处理后的结果如果缓存命中了应用拦截器根本感知不到网络请求发生过网络拦截器在缓存策略中处于“看了缓存但没命中才走到这一步”的位置所以它能更真实地反映网络开销。第三应用拦截器可以短路请求直接返回一个Response而不再往下走网络拦截器做不到这一点它后面还有CallServerInterceptor必须执行。维度addInterceptor应用拦截器addNetworkInterceptor网络拦截器执行时机请求链最前面连接建立之后、发请求之前重定向/重试时调用次数总是一次每次重试/重定向都会执行能否看到真实网络开销不能可以能否直接构造Response返回可以不可以典型用途加统一Header、鉴权、日志统计真实网络耗时、抓包3.3 动手写两个实用拦截器统一加Header应该算应用拦截器最典型的用途。移动端经常需要给每个请求带上X-Client-Version、X-Device-Id这类信息写在每个业务调用里既不现实也容易漏。class HeaderInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request().newBuilder() .addHeader(X-Client-Version, BuildConfig.VERSION_NAME) .addHeader(X-Device-Id, DeviceIdHelper.getDeviceId()) .build() return chain.proceed(request) } }另一个非常实用的是耗时统计拦截器。我想强调的是放在应用拦截器位置统计到的是一个请求的完整链路耗时包括排队、连接、读写和重试放在网络拦截器位置统计到的是单次网络往返更接近“这个接口到底多慢”。class TimingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val start System.nanoTime() val response chain.proceed(chain.request()) val costMs TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) Log.d(OkHttpTiming, ${response.request.url} 耗时 ${costMs}ms 状态码 ${response.code}) return response } }用这个拦截器你能快速定位是哪个接口慢、是整体链路慢还是单次网络慢不用猜。3.4 拦截器链的“洋葱模型”到底怎么理解初学者常常被chain.proceed()搞晕它到底是往前传还是往回传我用一个比方解释。想象这个链条是一层一层的洋葱。请求从最外层进来每个拦截器先处理自己的逻辑然后调用chain.proceed(request)把它传到下一层直到最内层的CallServerInterceptor真正把请求发出、拿到响应。响应拿到后从最内层一层一层往外回传每层再做各自的后置处理。所以A → B → C这三个拦截器请求方向是A先执行前半段再到B执行前半段再到C执行前半段响应回来后C先执行后半段然后是B最后才是A拿到完整结果。这也解释了为什么应用拦截器“最后才看到响应”——它是最外层。4. 连接池、超时与缓存开发中最好用也最容易用错的三个机制4.1 连接池为什么能省掉一半耗时一次HTTPS请求全链路走下来主要耗时分配大致是DNS解析、TCP三次握手、TLS握手、发送请求头、等待响应、接收响应体。其中TCP握手和TLS握手是最贵的尤其在弱网环境下一次握手就可能几百毫秒。连接池的思路很简单如果连接还没断不要用完就丢用完了放回池子下次同一个Host直接拿出来继续用。OkHttp内部的ConnectionPool默认允许保留maxIdleConnections5个空闲连接每个连接空闲时长超过keepAliveDuration5分钟之后会被清理。有个点要注意HTTP/2连接因为天然支持多路复用一条连接可以同时在跑多个请求所以连接池对HTTP/2连接的处理和HTTP/1.1不一样。HTTP/2连接通常一直保持活跃不占空闲连接名额。想验证连接复用到底起没起作用最直接的办法是抓包看有没有反复出现TCP握手包。如果同一个Host的多次请求每次都出现SYN/SYN-ACK/ACK三次握手说明连接没复用上如果只有第一个请求有握手后面请求直接就是数据包说明连接池正常工作。4.2 超时参数不止一个四个超时分别管什么OkHttp的超时配置有四个维度很多人只配了前三个connectTimeout默认10秒TCP连接建立的超时时间包括DNS解析和三次握手。readTimeout默认10秒两次读取之间的最大间隔时间不是整个请求的总时长。如果服务端每15秒才返回一段数据哪怕整个请求要读60秒只要两次读取间隔不超过10秒就不会超时。writeTimeout默认10秒写入请求数据时的超时时间上传大文件时尤其重要。callTimeout默认0即无限整条请求从开始到结束的总时长上限它覆盖了DNS、连接、读写、重试和重定向的全过程。这个参数是3.x后续版本才加上的很多人不知道。如果接口有硬性SLA比如“最多给我3秒出结果”设置callTimeout比单独设置readTimeout更可靠。配置超时还有一个细节对单个请求覆盖超时用newBuilder()是推荐做法而不是直接改OkHttpClient的全局配置因为大部分接口用默认值就够了只有个别慢接口需要单独放宽val request Request.Builder() .url(url) .build() val response client.newBuilder() .readTimeout(30, TimeUnit.SECONDS) .callTimeout(60, TimeUnit.SECONDS) .build() .newCall(request) .execute()4.3 缓存机制不是配了Cache就一定会缓存OkHttp自带一套HTTP缓存实现整体实现得相当标准但它有个前提服务端返回的响应头里要带缓存相关的字段比如Cache-Control、ETag、Last-Modified。如果服务端什么都不返回OkHttp不会自作主张帮你缓存。开启缓存的方式很简单val cache Cache(File(context.cacheDir, okhttp_cache), 10 * 1024 * 1024) val client OkHttpClient.Builder() .cache(cache) .build()缓存生效逻辑基于HTTP缓存规范大致分两类强制缓存响应头带Cache-Control: max-age6060秒内再次请求直接读本地缓存不发请求。协商缓存缓存过期后请求带上If-None-Match或If-Modified-Since服务器如果判断没变化返回304OkHttp用本地缓存作为响应如果有变化返回200OkHttp用新响应覆盖缓存。实际项目里最常见的坑是明明开启了Cache却发现每个请求都发了网络包。排查思路很简单看服务端响应头里有没有Cache-Control。很多后端同学自己在代理层配了缓存但源站没配或者用了一堆Pragma: no-cacheOkHttp就会严格按“不缓存”处理。遇到这种业务需求除非你和后端协商好缓存策略否则别指望OkHttp能自动帮你拦截请求。5. HTTPS、DNS与证书校验线上环境最容易翻车的角落5.1 DNS解析默认策略与自定义DnsOkHttp默认的DNS策略是调用系统的InetAddress.getAllByName()这在绝大多数场景下是没问题的。但有几个场景会让你不得不考虑自定义Dns实现。一是HTTPDNS。移动端经常被运营商Local DNS劫持或污染换成HTTPDNS服务能拿到更准确的IP。OkHttp的Dns接口只要求实现一个方法传入域名返回InetAddress列表。class HttpDns : Dns { override fun lookup(hostname: String): ListInetAddress { // 从HTTPDNS服务获取IP列表然后转换 return Dns.SYSTEM.lookup(hostname) } }二是域名灰度。比如你想让部分流量先打到某台内网机器上测试可以在自定义Dns里做规则匹配直接返回指定IP避免改服务端域名解析影响全局。三是预解析。App冷启动后马上要发一批请求DNS解析会成为瓶颈。可以提前在后台线程把常用域名解析好缓存起来自定义Dns直接走本地缓存能明显降低首屏请求的耗时。5.2 HTTPS证书校验别为了省事信任所有证书很多开发者在对接自签名证书的测试环境时图省事直接写一个X509TrustManager把所有证书都信任了。这种做法极其危险等于给中间人攻击开了一扇门应用一旦上线任何人在同一WiFi下都能劫持你的流量。OkHttp的安全做法是固定证书公钥。把服务端证书的公钥SHA-256指纹内置到App里用CertificatePinner来校验val certificatePinner CertificatePinner.Builder() .add(api.example.com, sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) .build() val client OkHttpClient.Builder() .certificatePinner(certificatePinner) .build()这样即使系统把某个CA签发的证书都信任了只要对方拿不出你预埋的那个公钥连接照样会被拒绝。对自签名证书的开发和测试环境更推荐的做法是预埋证书本身并在代码里单独校验同时结合CertificatePinner做双重保护而不是全局放行。HostnameVerifier也一样不要默认信任所有域名。它在OkHttpClient.Builder里可以配置但除非你很清楚风险不然保持默认即可。5.3 常见SSL异常长什么样线上报错最多的几类SSL问题我列一下方便你排错时快速定位SSLHandshakeException证书链验证失败要么是自签名证书没被信任要么是证书过期要么是中间证书不完整。CertificatePinningException固定公钥校验失败通常意味着服务端换了证书App里的指纹没有同步更新。SSLPeerUnverifiedException主机名验证失败最常见的是服务器配了证书但域名不匹配。排查手段记住一句出了问题先抓包看证书链再用openssl s_client命令行对服务端证书做检查把这些基础确认完之后再谈代码层面的问题。6. 实战封装与踩坑记录协程、进度、WebSocket与问题清单6.1 把enqueue包装成suspend函数裸用OkHttp的Callback接口在协程时代非常别扭。下面是我在项目里用了很久的一个扩展函数完整处理了协程取消与请求取消的联动suspend fun Call.await(): Response suspendCancellableCoroutine { cont - enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { cont.resume(response) } override fun onFailure(call: Call, e: IOException) { if (cont.isCancelled) return cont.resumeWithException(e) } }) cont.invokeOnCancellation { cancel() } }用起来就清爽多了val response client.newCall(request).await() response.use { val text it.body?.string() // 继续业务逻辑 }这里有两个细节要注意。第一onResponse里不要直接resume一个已经被取消的协程否则可能抛异常所以要在onFailure里判断cont.isCancelled。第二由于回调线程是OkHttp的线程池线程resume后续代码执行在哪个线程由协程上下文决定如果要在主线程更新UI外层用withContext(Dispatchers.Main)包一层就够了。6.2 上传下载进度两个自定义类搞定OkHttp官方没有直接提供进度回调但实现起来很简单。核心思路是拦截流的数据写入或读取动作。上传进度要包一层RequestBody。下面这个类会把写入的字节数累加通过监听器回调class ProgressRequestBody( private val delegate: RequestBody, private val listener: (bytesWritten: Long, totalBytes: Long) - Unit ) : RequestBody() { override fun contentType() delegate.contentType() override fun contentLength(): Long delegate.contentLength() override fun writeTo(sink: BufferedSink) { val countingSink object : ForwardingSink(sink) { private var bytesWritten 0L override fun write(source: Buffer, byteCount: Long) { super.write(source, byteCount) bytesWritten byteCount listener(bytesWritten, contentLength()) } }.buffer() delegate.writeTo(countingSink) countingSink.flush() } }下载进度则包一层ResponseBody用ForwardingSource数读出来的字节数class ProgressResponseBody( private val delegate: ResponseBody, private val listener: (bytesRead: Long, totalBytes: Long) - Unit ) : ResponseBody() { override fun contentType() delegate.contentType() override fun contentLength(): Long delegate.contentLength() override fun source(): BufferedSource { val source delegate.source() return object : ForwardingSource(source) { private var totalBytesRead 0L override fun read(sink: Buffer, byteCount: Long): Long { val bytesRead super.read(sink, byteCount) if (bytesRead ! -1L) { totalBytesRead bytesRead listener(totalBytesRead, contentLength()) } return bytesRead } }.buffer() } }注意contentLength()可能返回-1特别是分块传输时未知总长度前端要做对应处理。进度回调会非常频繁建议自己在回调里做节流比如每秒最多回调一次否则UI疯狂刷新很容易卡顿。6.3 WebSocket长连接OkHttp的另一面OkHttp除了普通的HTTP请求还支持WebSocket长连接而且实现得非常干净。用法上先构建一个ws://或wss://的Request然后通过newWebSocket挂上监听器val request Request.Builder() .url(wss://api.example.com/chat) .build() val webSocket client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { // 连接建立成功可以发消息了 webSocket.send({\type\:\hello\}) } override fun onMessage(webSocket: WebSocket, text: String) { // 收到服务端消息 } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { // 服务端主动关闭 } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { // 连接异常 } }) // 主动关闭 webSocket.close(1000, bye)我踩过的坑是WebSocket的连接复用同一个OkHttpClient如果业务上把WebSocket和普通HTTP请求混在一个Client里使用注意别在App退出时直接dispatcher.executorService.shutdown()否则会影响所有请求。正确做法是让OkHttpClient保持单例WebSocket断开会自动释放对应的socket。WebSocket本身没有强制的心跳机制但多数服务端会配置空闲超时踢掉连接。客户端要自己实现应用层心跳定期发一个ping或业务心跳包防止连接被中途切断。心跳频率一般比服务端超时时间短一点比如服务端60秒没消息就断开客户端就每30秒发一个心跳。6.4 高频坑位清单与排查思路最后把这几年遇到的OkHttp高频问题整理成一张表每条都是从真实项目里来的不夸张。现象原因解决方案response.body.string()调用两次抛异常ResponseBody是一次性流读取完即关闭只在use块里读一次或者先缓存字符串超时时间设置了但没效果可能只设置了*全局Client个别请求用newBuilder()覆盖了检查是否在请求级覆盖了超时加了Cache但每次请求都走网络服务端没有返回缓存头和后端确认Cache-Control或业务层自己做缓存network拦截器日志重复打印请求重定向或重试导致多次执行明确用应用拦截器还是网络拦截器连接池不生效每个请求都握手服务端主动关闭了连接或客户端每次新建Client将OkHttpClient设为单例配合服务端keep-alive参数大文件上传内存暴涨一次性把文件读进内存构建RequestBody用流式RequestBody或自定义RequestBody并发200个请求时大量等待超过maxRequests64或maxRequestsPerHost5评估并发模型按需调整Dispatcher参数排查网络问题我自己的固定路径是五步走先看异常类型和堆栈再确认是DNS层、连接层、超时层还是数据层的问题接着抓包看请求是否真的发出然后看服务端有没有收到、返回了什么最后再回到代码里查是拦截器顺序问题、Client复用问题还是业务线程问题。九成的问题在这一套流程下都能定位剩下那一成通常需要服务端配合日志才能查透。我对OkHttp源码读过不止一遍每读一遍都有新的理解。最早以为它就是一堆工具类封装后来才明白拦截器链这种设计思路其实可以迁移到很多场景里不只是网络请求。我个人非常建议你把RealCall.getResponseWithInterceptorChain()这段代码自己打开看一遍再配合这篇文章提到的连接池和Dispatcher机制基本就能建立起对OkHttp的完整认知了。
返回列表