
如果你在客户端开发里待过一段时间大概率每天都会和 OkHttp 打交道。但“会用”和“真的理解”之间隔着一条挺宽的沟。我见过不少同学调接口两三年配置永远是那几行样板代码唯一换过的就是 baseUrl也见过有人面试前狂背“OkHttp 有哪些拦截器”背完没几天就忘得干干净净。这个情况其实不怪大家因为 OkHttp 的文档写得偏工程化网上大多数教程又只停留在“怎么用”很少有人愿意把请求从发起到响应的整条链路摊开讲清楚。这篇“OkHttp 核心完全指南”就是想把这层窗户纸捅破。我打算从一次网络请求的完整生命周期讲起拆开 Dispatcher 的调度方式再把五个内置拦截器逐个拎出来看最后落到连接池、缓存语义和实际项目里的参数调优、避坑经验。适合谁看用过 OkHttp 但没读过源码的人面试前想系统梳理网络层的人以及准备给项目做网络层封装、想知其所以然的人。放心我会尽量讲人话复杂的地方用小例子和类比垫着保证你能看懂、能记住、能拿去用。1. 先从最朴素的问题开始OkHttp 到底在解决什么1.1 原生 API 的别扭之处在 OkHttp 流行之前Java 和 Android 上最常用的 HTTP 客户端是 HttpURLConnection。不是说它完全不能用而是它在几个关键点上非常别扭。第一API 长得啰嗦什么 setRequestMethod、setDoInput、setDoOutput、getInputStream 一套下来代码又长又容易错。第二连接复用能力薄弱每次请求都重新走 TCP 三次握手在高延迟的移动网络环境下体验很差。第三响应体的读取要自己处理字符集、自己处理缓冲稍不注意就乱码。第四也是最致命的一点——几乎没有扩展能力。你想统一打印日志想给所有请求加公共签名想在重定向时做点特殊处理原生 API 基本不给机会只能硬改业务代码。OkHttp 的出现本质上就是把这四个问题当成设计目标来解的API 做得干净、连接池做进底层、流式读写处理好编码细节以及用一套拦截器机制把“请求前-请求中-请求后”的所有扩展点全部开放出来。这也是为什么我用完几年后再看其他语言的 HTTP 库总感觉 OkHttp 的设计确实领先一个身位。1.2 OkHttp 在整个技术栈里的位置很多人以为 OkHttp 只是 Android 的专属库其实不对。它是个纯粹的 Java/Kotlin HTTP 客户端既能跑在 Android 上也能跑在普通 JVM 的后端项目里。你在 Retrofit、Glide、一些跨平台网络框架、甚至某些云端函数里都可能间接用到它。换句话讲理解了 OkHttp等于把客户端网络层最底层的那块地基给摸清了。不过 OkHttp 也并不是什么都干。它不帮你做线程切换、不替你把 JSON 自动映射成对象更不管你的业务错误码怎么统一——这些是 Retrofit 和其他封装层的事。OkHttp 的边界很清楚它就是负责“把请求安全、高效、正确地发出去再把响应完整地带回来”。搞懂这个边界后面看任何封装层都不至于发怵。2. 一次请求从发起到回调同步、异步与 Dispatcher 的调度规则2.1 execute 和 enqueue两种模式差在哪用 OkHttp 发起请求有三步构造 OkHttpClient、构造 Request、拿到 Call 再执行。Call 是一个“一次请求的任务对象”真正干活的是它背后的 RealCall。RealCall 暴露两种执行方式。execute() 是同步的调用线程会一直阻塞到拿到响应或抛异常enqueue() 是异步的把请求丢给内部的调度器 Dispatcher 后立刻返回等结果通过 Callback 回调通知。业务上绝大多数场景都应该走 enqueue因为网络请求的耗时是不可控的同步阻塞主线程会发生什么不用我多说。2.2 Dispatcher 内部的三组队列Dispatcher 是异步请求的“调度大脑”它内部维护了三组容器readyAsyncCalls 等待队列、runningAsyncCalls 执行队列、runningSyncCalls 同步请求清单。同步请求虽然不走线程池但 Dispatcher 同样记录着它们目的是为了支持 cancelAll() 这类全局操作。异步请求的调度逻辑是动态的。每次有新请求进入会先看看当前正在跑的异步请求数是否超过 maxRequests默认 64以及同 host 的并发数是否超过 maxRequestsPerHost默认 5。如果没超限就把请求塞进 runningAsyncCalls 并丢给线程池执行超限了就先放在 readyAsyncCalls 里排队。某个请求跑完Dispatcher 会在 finally 里调用 finished()把任务从运行队列移除再从等待队列里“提拔”新任务进来。这里有个很多人没注意的细节OkHttp 异步线程池用的是 SynchronousQueue核心线程数为 0最大线程数是 Integer.MAX_VALUE。听上去很吓人仿佛会无限创建线程但实际上因为 runningAsyncCalls 的数量被 maxRequests 限死了真正同时执行的任务数是有上限的。线程的创建和销毁恰恰符合了移动端“突发网络请求”的模型——平时不占线程高并发时能尽量跑满配额。2.3 从 newCall 到 onResponse中间经历了什么拿 enqueue 举例子最外层流程长这样先通过 OkHttpClient.newCall(request) 拿到 RealCall然后调用 enqueueDispatcher 接收这个 AsyncCall经过 promote 之后在某个线程池里执行内部会去调用拦截器链 getResponseWithInterceptorChain()一路穿过各种拦截器拿到 Response最后触发 callback.onResponse。而 onResponse 这个回调执行线程其实是 Dispatcher 线程池里的工作线程不是主线线程。如果你要在回调里更新 UI必须自己切回主线程。这一点经常被新手忽略导致“只能在主线程操作 View”的崩溃。处理办法有两个要么在回调里手动 runOnUiThread要么用 Retrofit 并且指定了主线程切换的回调器更方便的做法是接到 Response/业务数据后再统一 post 到主线程。3. 拦截器链条拆解五个内置拦截器各自在干什么3.1 拦截器链的全貌与执行顺序OkHttp 最让人上头的设计就是拦截器链。你可以自定义拦截器把它插进执行流程里每个拦截器只负责一件事请求按顺序穿过去响应再逆序穿回来。默认的链条顺序大致是顺序拦截器主要职责0用户自定义应用拦截器统一加参数、日志、鉴权等只执行一次1RetryAndFollowUpInterceptor处理连接失败重试、重定向、鉴权重试2BridgeInterceptor把用户的 Request 转成 HTTP 报文要求的标准格式3CacheInterceptor按 HTTP 缓存规范读取/更新缓存4ConnectInterceptor从连接池获取连接建立 TCP/TLS 连接5CallServerInterceptor真正把请求写到 socket读取响应6用户自定义网络拦截器能观察到实际网络传输前后的完整数据重试时会重复执行Responses 就像一个洋葱Request 从外层往里钻Response 从里往外抛每一层都能选择“让请求继续传下去”还是“直接短路返回”。3.2 逐一拆解内置拦截器RetryAndFollowUpInterceptor 是“兜底英雄”。连接失败、路由不可达、socket 异常它来判断要不要换一个路由再试一次服务端返回 3xx 重定向或 401 鉴权这类“需要进一步处理”的状态码它来决定是否创建一个新的请求继续追。它内置了最大重定向次数限制默认 20 次防止出现无限循环。BridgeInterceptor 像翻译官把用户的语义化请求翻译成 HTTP 协议要求的格式。比如自动补上 Host 头根据请求体大小设置 Content-Length 或分块传输编码补 User-Agent对响应做 gzip 解压。如果你自己设置了 Accept-EncodingOkHttp 就不会再自动解压了这是很多自定义协议兼容问题的一个坑。CacheInterceptor 是 HTTP 缓存规范在 OkHttp 里的实现。它跟浏览器缓存规则很像根据 Cache-Control 等字段判断是否命中缓存、是否要发条件请求。跳过它不稀奇但真正理解它的人不多我后面单独开一章讲。ConnectInterceptor 的名字听起来很短但它负责的事很重——从连接池挑连接、建立 TCP 和 TLS、创建用于收发数据的 HttpCodec。CallServerInterceptor 则是终点站把请求头、请求体真正写到 socket再读回响应头、响应体。到了这一层就完全是“物理层面”的 IO 了。3.3 应用拦截器和网络拦截器最大的认知盲区我面试人的时候特别喜欢问“addInterceptor 和 addNetworkInterceptor 的区别”绝大多数人答得含糊。其实区别很本质应用拦截器在 RetryAndFollowUp 之前所以一次用户操作只执行一次不管中间重试了多少次、重定向了多少次。它适合做业务层面的公共逻辑比如统一加签名参数、统计用户操作次数。网络拦截器在 Connect 和 CallServer 之间所以每一次真实发往网络的请求都会触发一次重试、重定向时会被重复执行。它适合做“网络字节层面”的日志和加密因为你能看到桥接后的真实请求头、真实响应头。举一个具体例子你请求了 A 地址服务端 302 跳到 B 地址。应用拦截器只会看到一次 A 请求网络拦截器会先看到一次 A 请求再看到一次 B 请求。理解了这一点你就懂为什么不建议把签名计算放在网络拦截器里——重定向后签名上下文对不上很容易出问题。3.4 自定义拦截器最常见的两种姿势实际开发里几乎每个正经项目都会写两个自定义拦截器日志拦截器、公共参数拦截器。日志拦截器一般作为应用拦截器添加打印请求方法、URL、耗时、响应状态码公共参数拦截器负责给所有请求自动拼上 token、客户端版本号、时间戳之类。写拦截器有一点要注意chain.proceed(request) 之后拿到的 Response 是“下游拦截器的最终结果”你自己可以先对 Request 做加强再对 Response 做统一处理。例如统一做错误码解析、统一做 token 失效判断完全不需要上层业务到处写重复的 try-catch。4. 连接池复用与 HTTP/2OkHttp 快在物理层面4.1 三次握手到底有多贵要明白 OkHttp 为什么会比其他客户端快得先算算 TCP 连接的成本。一次普通 TCP 连接至少需要三次握手在移动网络环境下往返时延随便 50-100 毫秒光握手就浪费 150-300 毫秒。如果是 HTTPS还得叠加 TLS 握手1-RTT 甚至 2-RTT 的耗时直接翻倍。如果每次请求都重新连接那绝大部分时间都耗在握手而不是业务数据传输上。连接池的思路就是一条 TCP/TLS 连接用完之后先不断开保留在池子里下次请求同一 host 时直接捞出来复用。OkHttp 底层通过 ConnectionPool 管理这些连接因为它足够快所以我经常把它类比成“高速公路上的 ETC 通道”——不用再停车取卡了。4.2 ConnectionPool 的复用逻辑与清理机制连接池核心参数有两个最大空闲连接数 maxIdleConnections默认 5空闲连接存活时间 keepAliveDuration默认 5 分钟。它内部维护了一个连接队列和一个清理任务每隔固定时间扫描一次把超过空闲时间的连接关闭并移除。复用的前提是“路由匹配”。连接是按 Address 和 Route 来区分的简单理解就是协议、host、端口这些要素要一致。如果你一会儿用 http一会儿用 https或者两个请求带着完全不同的 header 策略连接池就不是那么容易命中。另外OkHttp 默认启用了连接失败重试机制复用连接时遇到“连接已被服务端关闭”这类问题会自动重新建立实际体验上用户几乎感知不到。在 Android 里还要注意一点OkHttpClient 对象最好是全局单例。如果每个页面都 new 一个 OkHttpClient连接池和线程池都会跟着重建等于完全放弃了复用优化。我见过一个团队把 OkHttpClient 做成每次请求都新建的线上请求耗时从平均 200ms 直接飙升到 800ms排查到最后就是连接池成了摆设。4.3 HTTP/2 多路复用一条连接多个并发请求HTTP/1.x 的队头阻塞问题让并发请求一般要靠多个连接分担而 OkHttp 对 HTTP/2 的支持让同一 TCP 连接上可以同时跑多个流。也就是说即使并发 10 个请求到同一个 host可能底层只有一条 HTTP/2 连接在传输。服务器开启 HTTP/2 后OkHttp 会自动尝试协商协议升级用户完全无感但效果非常明显——尤其是大量小请求并发时省掉的不只是握手消耗还有连接数量过多带来的资源开销。Debug 页面上看连接复用命中率是我自己平时排查网络性能最喜欢看的指标。5. 缓存并没有默认开启HTTP 缓存语义与 CacheInterceptor 的设计5.1 缓存不是“存起来”那么简单很多人一提缓存第一反应是“把响应体存到本地下次直接用”。HTTP 缓存规范要远比这复杂。一个响应能不能缓存取决于请求方法、状态码、Cache-Control 字段、Expires 字段、ETag、Last-Modified 等多种因素。OkHttp 很克制默认情况下你什么缓存都不开因为不是所有接口都适合缓存。想启用需要手动构造一个 Cache 对象并传给 OkHttpClient指定缓存目录和上限大小。Cache 内部基于磁盘缓存库实现超出上限会按照 LRU 策略淘汰旧数据。5.2 CacheInterceptor 的判定流程命中判断逻辑大致是如果某个请求之前缓存过且没有过期直接返回缓存响应连网络都不走如果缓存已过期但存有 ETag 或 Last-Modified就带着 If-None-Match 或 If-Modified-Since 发出条件请求服务端返回 304 时更新缓存元数据后复用旧响应体如果服务端明确要求不缓存那就老老实实走完整网络链路。这个设计对消费者透明到什么程度呢你哪怕加了自定义拦截器也不容易感知到“其实这次返回是缓存的结果”除非你去观察响应头里是否有 Age 之类字段。这正是我强调它“讲 HTTP 语义”的原因——它不是随便设计一个缓存 key 的表而是尊重协议规范、跟服务端协商一致的系统。5.3 项目里缓存开关怎么设计我的建议是全局可以建一个 OkHttpClient 且带上 Cache但每个 Request 的缓存策略要通过 Cache-Control 头精细控制。像用户头像、版本信息、首页配置这类“不太变”的资源设置一个合理的 max-age像订单状态、支付结果这种强实时接口设置 no-store避免老数据造成业务纠纷。还有个很容易踩的坑如果你给同一个 URL 的 GET 请求动态加了不同的 query 参数缓存 key 是完整 URL所以不会互相污染。但如果你用同一个 URL 去 POST 不同数据POST 默认不进缓存属于规范行为别指望它缓存下来。6. 项目里最值得聊的配置与踩坑实录6.1 Dispatcher 和超时参数怎么调才合理OkHttp 默认的 connectTimeout、readTimeout、writeTimeout 都是 10 秒。这组参数在普通宽带上够用但对弱网场景偏长容易让用户等太久反过来压得太小又会在服务端慢响应时频繁超时。我的经验是内部服务接口 readTimeout 可以压到 5-8 秒面向弱网的外部接口保留 15-20 秒connectTimeout 通常 10 秒足够。Dispatcher 的 maxRequests 和 maxRequestsPerHost 也要按场景调。如果 App 瞬间会发起十几次图片请求到同一个 CDN host默认的 perHost5 会变成瓶颈请求都在排队。我一般会结合业务压测来定图片类 App 可能把 perHost 提到 8-10但要留意线程和内存开销的平衡。6.2 拦截器顺序产生的坑与日志脱敏自定义拦截器如果加的位置不对问题非常隐蔽。比如你把“打印完整请求 URL”的日志拦截器放到了网络拦截器位置结果重定向后打印出来的 URL 变了排查问题反而被误导。又比如你在应用拦截器里改写了请求体但网络拦截器里的日志打印的是修改后的报文和业务层想对账就对不上。另一个经验是日志要脱敏。别把请求参数里的登录密码、token 直接打进日志文件一旦日志泄露就是安全事故。我现在的做法是统一封装一个日志拦截器用正则把敏感字段替换成星号再输出。6.3 大文件上传下载与流式处理上传大文件时如果直接把整个文件读到内存再构造 RequestBody内存分分钟爆掉。正确姿势是自定义 RequestBody重写 contentLength() 和 writeTo()用输入流往输出流里一点点搬数据。下载大文件同理拿到 Response 以后不要直接调用 body().string()而是用 byteStream() 边读边写配合文件输出流落盘。这两个操作看着不起眼却是线上内存问题的一大来源。下载还要注意一个细节读取完响应体务必要 close。OkHttp 的连接归还连接池很大程度上依赖响应体被正确消费或关闭如果漏了 close底层连接无法复用甚至会导致连接池堆积空闲连接。我排查过好几次“连接池满”的问题最后发现是某个下载场景没有 close 响应体。6.4 几个不算热门但很实用的 API在 OkHttp 的进阶能力里有几个很容易被忽略但很实用的点EventListener 可以监听整个请求生命周期里每个阶段的事件可以用来做精细的监控打点Dns 接口允许你自定义域名解析比如内置 IP 列表、容灾切换WebSocket 支持让 OkHttp 不只是普通的 HTTP 客户端还能做长连接场景。这些功能不是每个项目都会用到但知道它们存在、知道它们能干什么遇到问题时你至少知道去哪找答案。尤其 EventListener我在排查“到底卡在连接建立还是等待响应”这类问题时靠它一张打印日志就定位了。写到这OkHttp 的核心东西基本都过了一遍调度、拦截器、连接池、缓存语义还有那些在真实项目里藏得很深的坑。我个人这几年最大的体会是网络层的问题大多数不是“哪个 API 不会用”而是“不知道请求真正经历了什么”。把这条链路想明白配置参数和踩坑经验就都顺理成章了。如果你正打算给项目做网络层重构或者面试前希望把 OkHttp 讲得比别人深一点这篇指南应该能帮你少走很多弯路。