
做Android开发做到两三年之后很多人会碰到一个说不清道不明的瓶颈业务代码写起来很顺可是一遇到网络问题就发怵。为什么连接超时之后不能直接重试为什么同一个接口在WiFi下表现正常一切到移动网络就频繁卡顿为什么服务端说“明明返回了”App这边却一直报解析异常这些问题的根因基本都能回溯到计算机网络这门“基础课”上。这篇东西是我完整一轮Android计算机网络实战学习之后沉淀下来的总结原原本本记录了从TCP/IP协议栈、HTTP/HTTPS原理出发到亲手写一个轻量级网络库再到对照OkHttp/Retrofit做细节优化最后收敛到弱网调试和线上问题排查的全过程。无论你是刚想把网络短板补齐的初级开发者还是已经写过几个网络封装类、但没系统梳理过网络体系的中级Android开发这篇内容都可以当成一份按图索骥的参考。1. 为什么我在Android进阶计划里专门加了一轮网络实战1.1 一个线上问题让我把计算机网络重新捡起来让我真正下定决心补网络课的是一次不算特别复杂的线上反馈。用户说App首页转圈时间特别长有时候甚至要等十几秒才出内容。当时先怀疑是不是服务端响应慢结果后端同学拉日志告诉我接口本身耗时只有几十毫秒。又怀疑是不是CDN出问题排查了一圈也没有结论。最后抓包看时间线才发现域名解析阶段消耗掉了大部分时间——DNS服务器那边返回很慢客户端自身又没有做合理的缓存等于每次冷启动后头几个请求都卡在“从域名到IP”这一步。那次之后我就意识到自己在学校学过的计算机网络知识基本已经还给老师了。什么三次握手、拥塞窗口、TLS协商背题的时候倒背如流真到线上定位问题时完全串不起来。原因是当初纯粹是“为了考试而学”没有把这些协议放到Android的真实运行环境里去理解。移动端的网络环境远比PC复杂网络切换频繁、RTT普遍更高、弱网场景到处都是同样是超时在不同阶段发生处理方式可能完全相反。所以这一轮学习我给自己定的目标是不看那种几百页的理论教材而是拿“一个Android请求从发出到收到响应”这条完整链路做主线把每一个环节上涉及的网络协议、代码配置、真实坑点全部过一遍。这条链路走通之后再遇到什么奇奇怪怪的网络问题脑子里至少有一张地图知道该往哪个方向排查。1.2 学习范围怎么划不是把教材背一遍而是围着请求链路走一遍计算机网络的教材体系非常庞大从物理层的调制解调到数据链路层的MAC地址再到路由器的各种选路协议如果照着课本一章章啃没个半年下不来而且大部分内容对做应用层开发的Android工程师来说是一辈子也用不上的“冷知识”。我的做法是站在一个普通请求的视角去划范围。一个请求在Android端会经过这些环节应用层构造HTTP报文 - DNS解析域名 - 建立TCP连接遇到HTTPS还要先做TLS握手- 发送请求数据 - 等待服务端响应 - 读取并解析响应体。这条链路上每一环都需要理解它为什么这么设计以及在Android端有没有对应的API可以控制和优化。那些“请求发出之后、数据在骨干网里怎么被路由器转发”的细节我选择只做科普级了解不在上面花时间。因为Android开发者能够影响的边界基本到“发起连接”和“管理连接”为止。反过来一旦边界画清楚了很多概念就自动定位了例如HTTP报文结构影响你怎么封装参数、TCP连接复用影响你怎么设计连接池、TLS握手影响你对HTTPS性能的预期。这轮学习结束之后我自己最大的收获不是背下了多少个协议细节而是建立了一套“请求链路地图”后续的所有代码和排查都是在这张地图上做文章。2. 先把这几块网络基础吃透后面的实战才不会懵2.1 四层模型看移动端再复杂的协议也离不开分层的思路谈TCP/IP必须先谈分层。大学课本喜欢把OSI七层模型挂在嘴边但实际互联网跑的是TCP/IP四层模型应用层、传输层、网络层、链路层。Android开发打交道最多的是应用层和传输层网络层和链路层更多由系统、路由器去处理我们只需要知道它们大致做了什么。我用一个日常点外卖的流程来解释四层模型特别容易记。应用层相当于你拿着菜单点菜HTTP协议负责把“我要一份红烧肉、不要辣”这个诉求按约定格式写好传输层相当于外卖平台的派单系统它保证这份订单有没有被商家收到、有没有被骑手取走对应到TCP的确认和重传机制网络层相当于路网规划IP协议决定了这份订单从哪个区域调度、走哪条主干道链路层则是实际执行“骑车穿街过巷”的过程对应到物理链路上的帧传输。Android端有一个容易被忽略的事实链路层会因为WiFi和蜂窝网络切换而发生彻底变化。你可能在同一个App页面里前一秒请求还走在WiFi的链路上后一秒系统自动切到4G/5G之前建立的TCP连接已经失效了。这种场景下应用层看到的现象就是“连接被重置”或“请求超时”。如果理解了分层你就明白这不是服务端的问题而是链路层状态变了需要做的是监听网络切换并进行连接重建。2.2 TCP握手、连接池与拥塞控制慢的原因往往藏在这些细节里TCP是传输层最核心的协议它提供的可靠性建立在确认、重传、排序等一系列机制之上。建立连接需要三次握手释放连接需要四次挥手。握手过程简单说就是客户端先问“在吗”服务端回“在的你能听清吗”客户端再回“能听清我们开始吧”。这三次对话在网络上各需要一个往返时间也就是3个RTT的一部分实际上三次握手在前两次完成时第三个ACK可以和首个请求数据一起发出但TLS握手还需要额外RTT。移动网络下的RTT通常在50到200毫秒之间相比局域网内小于5毫秒的RTT要慢一个数量级。所以一次全新HTTPS请求在移动网络上光是建立TCP连接加上TLS协商就可能消耗掉300毫秒以上这还不包括DNS解析和真正传数据的时间。这就是为什么HTTP连接池如此重要——复用已经建立好的连接可以省掉每次握手和TLS协商的开销。除了握手TCP还有一个直接影响移动端体验的机制拥塞控制。TCP发送方会维护一个拥塞窗口刚建立连接时窗口很小然后以“慢启动”的方式逐步扩大发送量。如果在弱网环境下发生丢包拥塞窗口会急剧收缩发送速率骤降。我见过不少同学在弱网测试时发现“请求变慢了”第一反应是去调大超时时间但实际上丢包率不变调大超时只是让失败变得更慢正确的思路是控制并发请求数、压缩请求体、以及做好请求的优先级调度。2.3 HTTP/1.1、HTTP/2与HTTP/3每个版本更迭都在解决一类真实问题HTTP协议一路演进本质上都是在解决“多个资源如何更快更高效地到达客户端”的问题。HTTP/1.1时代一个TCP连接同一时间只能处理一个请求而且后面的请求要等前面的响应返回才能发送这就是经典的队头阻塞。那时候浏览器的应对策略是同时开6个连接但连接数量终归有限每个连接又要承担握手开销。HTTP/2的核心改进是把所有请求和响应都切成二进制帧在一个TCP连接上通过“流”并发传输。帧与帧之间可以交错理论上不再需要排队等前面的响应。但这里还有一个隐藏问题虽然应用层的队头阻塞解决了TCP层的丢包重传仍旧会阻塞整条连接一个包丢了后面所有已到的数据也得等重传完成才能继续处理。HTTP/3干脆把传输层换成了基于UDP的QUIC协议在用户态实现了可靠传输和多路复用每个流之间互相独立一个流丢包只影响自己不再拖累其他流。Android端在OkHttp 4.x上可以通过配置Protocol列表启用HTTP/2HTTP/3的支持则依赖底层实现和系统能力目前生产环境大规模使用的还不算多。作为移动端开发理解这些版本差异的意义在于当你在线上看到“并发明明不高但响应就是慢”的时候要能想到HTTP协议版本对连接复用的影响而不是盲目加服务器资源。2.4 HTTPS握手与证书校验加密不是后端单方面的工作很多人觉得HTTPS是后端的事情App端只要把URL里的http换成https就行。实际上TLS握手的完整性和证书校验客户端是绝对的主角之一。TLS握手的过程大致是客户端先发ClientHello告诉服务端自己支持的TLS版本和加密套件服务端回ServerHello选好加密套件同时下发自己的证书客户端验证证书是否可信然后双方通过密钥交换算法协商出会话密钥之后的所有数据都用会话密钥加密传输。整个过程通常需要2个RTT这也是“HTTPS比HTTP慢”的主要来源。Android端最容易踩的坑是自签名证书。开发阶段连的测试环境经常是自签名证书有些同学图省事直接写一个信任所有证书的X509TrustManager结果App成功连上测试环境但线上也失去了证书校验能力等于HTTPS白做了。我见过某公司一整个版本的App都带着这种“万能信任”的代码上线安全性形同虚设。正确做法是开发阶段只在调试包里放开证书校验或者只对测试域名做特殊信任发布包必须走系统标准校验。进阶一点的做法是用CertificatePinner固定服务端的证书公钥就算系统信任链被突破攻击者也无法用其他证书冒充服务端。从实战角度看理解TLS握手还有一个实际收益你能正确判断耗时分布。用抓包工具看一条HTTPS请求的时间线如果发现secureConnect阶段占了很大比重那问题大概率在TLS配置或网络RTT上而不是服务端接口慢。3. 手写一个轻量级Android网络库把原理落进代码3.1 网络库的分层设计先画清楚边界再动手写纸上谈兵终觉浅这一轮学习最有收获的部分是我真的花了几天时间手写了一个轻量级网络库。当然我没有打算跟OkHttp叫板目标很单纯把前面学的协议知识落到代码里去看看一个网络请求从调用到回调内部究竟要经过哪些环节。写之前我画了一个简单的分层图。最上层是API接口层对外提供get、post这类方法调用方只需要传URL、参数、回调中间是请求调度层负责维护线程池和请求队列控制并发数决定一个请求什么时候真正开始执行再往下是协议处理层负责把请求参数拼成符合HTTP规范的报文并解析响应数据最底层是连接管理层管Socket连接的创建、复用和销毁同时负责超时控制。这么一分层边界清楚了很多。API层不做任何网络操作调度层不知道自己发的是HTTP还是HTTPS协议层不关心数据走哪个Socket连接层不感知业务。这套思路和OkHttp做的基本同构。我原来以为“手写网络库”是一个练代码能力的事做完了才发现真正能让你技术成长的是画好这些边界之后再往每一层里填细节。3.2 请求调度线程池、队列与并发限制网络请求天然不能跑在主线程所以线程池和调度策略是第一关。Android上有多种线程池选择但我并没有直接用ExecutorService现成的新建线程池而是仿照OkHttp的Dispatcher思路设计了两层限制全库最大并发数以及同一域名下的最大并发数。为什么要有域名级别的限制因为HTTP/1.1时代同一个域名能有效利用的连接数量有限如果无脑并发20个请求到同一域名TCP连接会互相争抢带宽整体吞吐反而下降。实现上我用了一个阻塞队列保存等待中的任务用计数器控制并发每当一个请求结束就激活下一个任务。关键代码不算长但线程安全细节不少。比如计数器的增减必须和任务完成状态严格同步不然活跃连接数会出现“幽灵值”导致任务队列永远等不到新的许可。class RequestDispatcher( private val maxRequests: Int 64, private val maxRequestsPerHost: Int 5 ) { private val executor ThreadPoolExecutor( 4, maxRequests, 60, TimeUnit.SECONDS, ArrayBlockingQueue(maxRequests * 2) ) fun enqueue(call: Call) { executor.execute { // 真正的执行逻辑创建Socket、发送报文、读取响应 } } }我在调度这一层还做了一件事请求优先级。头像加载、列表首屏请求这种用户能感知的请求应该优先于埋点上报这类后台任务。优先级的实现没有用复杂的优先级队列而是把任务分成两个队列高优先级队列永远先取。这个设计后来在弱网场景下效果非常明显首屏关键请求不再被一堆后台请求堵住。3.3 连接管理、重试与超时把异常控制交给框架连接管理是网络库的核心难点。我实现的连接池原理不算复杂用一个Map保存“hostport”到连接集合的映射发请求时先从池里取一个空闲连接用完放回去超过闲置时间的连接定期清理。真正难的是处理“连接已失效但池子不知道”的情况。最经典的场景是服务端因为空闲超时主动断开了连接客户端Socket还在池里下次拿它发请求时第一次write可能成功了但read会立刻收到EOF。这个问题我在手写网络库时用了一个粗暴但有效的方案从池里取出连接后先判断Socket的isConnected和isClosed状态再把读超时临时设得很短比如50毫秒探测一下。但这个方案有副作用正常请求有可能被误判成失效连接。后来我看了OkHttp的实现发现它处理得更聪明它会把连接建立时间、最近一次使用时间都记下来在真正发请求前做一层“复用检查”同时把异常情况通过重试机制兜住如果一个连接在写请求之前就失败了就换池里下一个连接再试一次如果请求已经发出去了才失败那就不能盲目重试因为服务端可能已经执行了请求重试可能造成重复写入。重试和超时是紧密相关的。我的网络库里有三组超时参数连接超时、读超时、写超时。连接超时覆盖DNS解析和TCP握手阶段读超时是发完请求后等待响应头的最长时间写超时是发送请求体的最长时间。三者的默认值完全不该一样我见过有些项目把三个值都设成一样的20秒结果连接都建立不了时光等DNS解析就能卡住20秒用户体验极差。合理做法是连接超时较短比如5-10秒读超时看业务而定列表请求可以15秒上传大文件要更长写超时建议不低于连接超时。4. 打开OkHttp和Retrofit的联动链路实战库背后在做什么4.1 拦截器链一次请求依次穿过哪些关卡手写轮子是为了理解原理生产环境我仍然推荐直接用OkHttp。它最漂亮的设计就是拦截器链模式。我第一次看OkHttp源码时觉得链式调用有点绕把它拆开之后就明白了一次请求会被一层一层拦截器依次处理每个拦截器决定是否继续往下传递拿到响应后再一层层返回。拦截器分为两类。Application Interceptor应用拦截器在链条最外层它能看到用户最原始的请求和最终响应但看不到重试、重定向等内部过程Network Interceptor网络拦截器在内层它能看到实际发出去的请求和实际收到的响应包括重试和重定向后的结果。两个拦截器经常被混淆抓包时也只认网络拦截器的日志才准确因为应用拦截器看到的请求可能是“计划中”的请求和实际线上发的不是同一个。自定义拦截器的写法也很直接实现Interceptor接口在intercept方法里拿到chain调用chain.proceed(request)继续执行。一个常见需求是全局日志打印class LoggingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val start System.currentTimeMillis() Log.d(NetLib, -- ${request.method} ${request.url}) val response chain.proceed(request) Log.d(NetLib, -- ${response.code} ${request.url} 耗时 ${System.currentTimeMillis() - start}ms) return response } }注意这里的响应必须原样返回不能提前吃掉否则上层就收不到完整数据了。如果你需要打印响应体最好使用response.peekBody()而不是直接读body因为body只能被消费一次。4.2 超时、重试与幂等参数不能随便调不少项目直接给OkHttp设置一个全局超时时间然后线上出了问题就把它调大这是最典型的“治标不治本”。OkHttp的connectTimeout、readTimeout、writeTimeout三个参数也需要分别设置前面手写网络库时提到的原则在这里同样适用。我见过一个后台日志系统全局readTimeout设成了30秒实际服务端响应只要2秒但在弱网环境下网络抖动很容易把2秒的请求拖成10秒而30秒的超时又让失败反馈变得极慢用户早就放弃操作了。更关键的是重试策略。OkHttp默认在连接失败时会自动重试一次前提是请求可以被安全重试。它内部会根据请求方法、是否已发送请求体等信息做判断。如果是POST请求且请求体已经写入再重试就可能产生重复数据。自己实现RetryInterceptor时一定要把幂等性放在第一位。什么时候可以无脑重试GET请求、请求体为空的POST、以及服务端明确做了幂等处理的接口。其余情况要么不重试要么提示用户手动重试。class RetryInterceptor( private val maxRetryTimes: Int 2 ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { var retryCount 0 while (true) { try { return chain.proceed(chain.request()) } catch (e: IOException) { if (retryCount maxRetryTimes) { throw e } retryCount val delayMillis 300L * (1 shl retryCount) // 300ms、600ms、1200ms指数退避 Thread.sleep(delayMillis) } } } }指数退避是重试策略里值得记住的技巧。固定间隔的重试会在同一时刻集中撞击服务端指数退避则让后续重试的间隔逐次放大能给服务端留出恢复时间。我在线上遇到过服务端发布重启时客户端大量瞬间重试用指数退避之后恢复期间的重试请求数量明显陡降。4.3 自定义DNS与其他网络优化点OkHttp还有一个被很多人忽略的可扩展点Dns接口。默认情况下域名解析走系统的netd也就是底层DNS服务器解析整个过程对开发者来说是黑盒。但在实际项目中我发现系统DNS的缓存时间不一定适合你的业务。有的域名IP变更频繁系统却缓存了很长时间客户端一直连到旧IP上有的域名系统不缓存每次都触发完整解析平白多了几十毫秒。通过自定义Dns实现可以控制解析策略和缓存策略。OkHttp的Dns接口只需要实现一个lookup方法返回IP地址列表。比较简单的优化是给解析结果加一层自己的LRU缓存并设置合理的TTL。更进一步的做法是做HTTPDNS绕过系统DNS的UDP解析直接通过HTTP请求向专门的解析服务获取IP列表好处是可以自己控制缓存、调度和故障切换。我自己实现过一版简单的HTTPDNS接入复杂度确实比默认DNS高不少但带来的收益在弱网和网络切换频繁的场景下非常可观。class CustomDns(private val delegate: Dns Dns.SYSTEM) : Dns { override fun lookup(hostname: String): ListInetAddress { val cacheKey hostname cache[cacheKey]?.let { return it } val addresses delegate.lookup(hostname) cache[cacheKey] addresses return addresses } }这里有个小坑自定义Dns里不要做过于耗时的同步操作因为它在请求的关键路径上阻塞太久会直接把连接建立时间拖慢。解析结果放到子线程处理或者提前预热会更稳。5. 调试与排查实录这些坑我都替你踩过5.1 弱网环境不是把超时调大就完事弱网是移动端特有的排查场景PC上很难复现但又真实存在。我踩过的第一个坑是混淆了“连接超时”和“读超时”。弱网环境下TCP握手阶段就可能失败抛出的是ConnectException连接建立成功但服务端响应迟迟不来抛出的才是SocketTimeoutException。两种异常的处理策略完全不同前者可以安全重试后者需要看业务场景盲目重试大概率只是浪费一次资源。我在做弱网优化时养成了一个习惯抓包工具的限速功能一定要会用。把网络限速到几十KB/s并加入丢包模拟后很多平时测不出来的问题就现形了。最常见的问题是请求体或响应体稍微大一点整个请求的耗时就成倍增长图片列表页在弱网下因为并发请求太多把带宽全部抢走首屏关键请求反而被挤到后面。我优化时把图片请求和接口请求拆成了两个调度池并给接口请求更高的优先级弱网下的首屏速度提升非常明显。还有一类问题容易被归因成“手机不行”网络切换。从WiFi切到蜂窝网络时原来基于WiFi的TCP连接不能继续使用如果客户端没有监听ConnectivityManager的ACTION_CONNECTIVITY_CHANGED广播并重建连接池就会出现“用户走出办公室就没网”的假象。实际上网络是通的失效的只是旧连接。处理方式很简单网络类型变化时清空连接池让后续请求重新建立连接。5.2 前后端联调中的协议细节坑联调阶段最能暴露协议理解的不足。第一个经典坑是Content-Length不一致。服务端响应的Header里写的长度比实际响应体长或者短客户端在读取body时会报EOFException或者一直等不到数据。我跟某个后端联调时遇到过一种隐蔽情况后端服务的框架在网关层做了压缩但Content-Length还是压缩前的大小导致OkHttp读取到一半就结束。定位方式很简单抓包看原始响应对比Header里的Content-Length和实际body字节数。第二个坑是响应体的字符集。大部分时候Retrofit默认按UTF-8解析但如果服务端返回的Content-Type里没有显式声明charset同时实际用的又不是UTF-8就会出现中文乱码。这个问题在JSON返回时少见但在一些老接口返回纯文本或XML时会遇到。正确的修法是让后端在Response Header里明确声明charset客户端不要自己瞎猜。第三个坑是报文格式的包容性。HTTP Header的字段名不分大小写但有些后端框架返回的Header名是大写开头例如Content-Type有些是小写例如content-type兼容的HTTP解析器应该统一处理。OkHttp内部做了归一化而我手写的网络库里一开始对Header做了严格的Map匹配结果部分接口解析不出Content-Type导致响应体被当成未知编码后来改成忽略大小写才解决问题。这个细节虽然小但暴露了我对HTTP协议“字段名不区分大小写”这一点的轻视。5.3 本地抓包与全链路耗时分析调试HTTP/HTTPS请求时抓包是效率最高的手段。电脑端抓包工具可以查看完整的请求头、响应头和body还能模拟各种网络状态。在Android开发中最常用的方式是让手机连接同一个局域网然后把手机的HTTP代理指向电脑上抓包工具的监听端口。抓包HTTPS需要先在手机上安装抓包工具的CA根证书这一步在调试机上做就可以了。但抓包工具不是万能的遇到客户端不走系统代理的情况就抓不到而且部分坏运营商网络环境还会干扰代理端口。这时候另一个方案就很有用直接在OkHttp里挂EventListener把整个请求的生命周期事件打印出来。EventListener能拿到dnsStart、dnsEnd、connectStart、connectEnd、secureConnectStart、secureConnectEnd、requestHeadersStart、requestHeadersEnd这些阶段配合时间戳就能画出一条完整的请求时间轴。class TimelineListener : EventListener() { override fun dnsStart(call: Call, domainName: String) { logTime(dnsStart: $domainName) } override fun dnsEnd(call: Call, domainName: String, inetAddressList: ListInetAddress) { logTime(dnsEnd: ${inetAddressList.joinToString()}) } override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { logTime(connectStart: $inetSocketAddress) } override fun secureConnectEnd(call: Call) { logTime(secureConnectEnd) } }这个方法让我养成了一个非常好的排查习惯遇到网络慢先看时间轴哪一个阶段占比最大就盯哪一个。曾经有个接口服务端响应只要30毫秒但客户端每次都卡时间轴显示dnsStart到dnsEnd之间的空挡接近300毫秒定位到是自定义DNS的缓存逻辑里加了一把全局锁所有域的解析请求在排队。这种问题靠抓包工具根本看不出来只有EventListener能定位到准确环节。使用EventListener要注意别在回调里做耗时操作因为它运行在OkHttp的调度线程上阻塞时间长了会影响整个请求池的效率。我一般是把事件和纳秒时间戳记录到内存队列里请求结束后再统一输出。最后聊几点实在的心得这轮网络实战学习下来我个人最大的感受是计算机网络这门课并不是背完就扔的纸面理论它更像一张拓扑地图。知道地图上有哪些节点、每个节点负责什么、节点之间怎么连接遇到故障时才能按图索骥找到断点。如果让我给同样想补网络的Android开发者一个建议我会说不要急着把《TCP/IP详解》啃完而是先在电脑上装一个抓包工具随便打开一个App完整看一次某个接口从DNS解析到响应返回的原始过程。看多了之后再去学协议细节你会发现每个字段、每个标志位都有它存在的现实理由。还有一个很实用的小技巧给自己布置一个“把所有请求的时间线画出来”的小任务对请求做一次静态体检——DNS解析耗时多少、TCP连接耗时多少、TLS握手耗时多少、服务端响应用了多少。我每到一个新项目都会做这件事往往能发现一些“别人觉得正常”但实际可以优化的网络路径。计算机网络不是一锤子买卖它是一套越用越顺手的地图工具平常多画几遍线上才不会迷路。