
简介该压缩包为Android平台使用Apache HttpClient访问Web站点的实操示例面向初中级Android开发者重点演示DefaultHttpClient与AndroidHttpClient两个子实现类的用法适合理解HTTP请求、连接管理等基础网络编程场景。包内共51个文件涵盖Java源码、class编译文件、AndroidManifest配置、XML布局与资源、PNG图标、依赖jar包以及可直接安装的APK整体约1.18MB目录结构完整便于对照源码与编译产物。已有410人学习浏览可作为快速上手Android网络编程的参考项目。通过该资源可看到两种HttpClient的调用差异、项目工程配置方法以及资源打包流程借助附带的APK与源码能直接运行或二次修改省去从零搭建环境的步骤。1. 为什么还要碰HttpClient老Android项目里那套快被遗忘的网络层老App还在用Apache HttpClient访问一个微Web站点换到新手机上直接崩报错NoClassDefFoundError代码却检查不出任何问题。这类“曾经能跑换个环境就翻车”的工程不算少存量App、设备配套客户端、内网工具网络层都攥着一把HttpClient代码而它们访问的微Web站点大多是低功耗网关、嵌入式设备或轻量级内网服务。本文就把这套组合一次讲透它凭什么适合微站点、最小代码怎么写、超时和连接池参数怎么设以及五个绕不开的坑。维护老项目的开发者可以直接拿来排查想补HTTP客户端原理的人也能从这里看到一次真实的请求如何走出Android。2. HttpClient凭什么是微站点友好选型逻辑与三个核心机制2.1 从“系统自带”到“弃儿”HttpClient和HttpURLConnection的多轮博弈早期Android SDK内置了Apache HttpClient开发者不需要引入任何依赖直接new DefaultHttpClient()就能发起请求。同一时期的HttpURLConnection存在明显短板连接复用不稳定响应编码需要自己解析Cookie管理要额外写一堆代码。对企业应用和配套设备来说两个字“能跑”就胜过一切HttpClient顺势成了默认选择。HttpURLConnection后来逐步补齐了缺陷而Android 6.0API 23直接把这个系统的HttpClient运行时移除了。这一刀砍下去存量工程并没有在编译期坏掉——SDK里还留着stub类打包照样过运行到用户手机上才蹦出NoClassDefFoundError。这也是很多人排查半天找不到原因的关键编译时类和运行时类不一致老代码悄悄变成了“黑匣子”。站在做微站点访问的角度当年选HttpClient不是没有道理。它提供的连接复用、Cookie自动管理和多级超时控制正好命中设备类服务的痛点。问题在于它默认配置并不是为移动端准备的也不是为弱设备准备的所以才需要今天这篇文章把配置收敛清楚。2.2 连接池、Cookie与超时HttpClient赢在细节HttpClient最实用的三个机制第一个是连接复用。一次TCP握手在手机上大约几十到几百毫秒在弱网环境更夸张而微站点设备往往CPU很弱频繁新建连接会占满它的协议栈。HttpClient内部由连接管理器维护连接池请求完成后连接归还下次请求优先复用这对微站点场景是实打实的收益。第二个是CookieStore。很多微站点根本没有RESTful概念就是发个表单POST服务端回一个SessionID后续请求带着它就认为你登录了。HttpClient的CookieStore负责从Set-Cookie里取值、自动附加到后续请求头逻辑全都封装好开发者不需要手工拼Cookie字符串。第三个是多级超时。HttpClient提供了三个维度连接建立超时、从连接池取连接的超时、等待响应数据的超时。默认情况下这些值很多是“无限等待”在Android上就意味着用户看到App一直转圈。微站点设备处理能力弱有时候一个请求要好几秒才回超时设得太激进会误判失败设得太宽又会让应用层卡死必须单独调。一次请求在HttpClient内部的执行链路大致是HttpGet先找到目标路由连接管理器从池里取出或新建连接走SSL握手如果有然后发送请求头和数据读取响应最后归还连接。所有跟连接有关的动作都经过连接管理器而不是每次new一个Socket。2.3 微Web站点的画像一个低配HTTP服务对客户端的三个要求所谓微Web站点指的不是微服务架构而是真正“微型”的Web服务跑在低功耗处理器上内存以KB计算网络协议栈常是精简实现甚至直接写的裸机代码。它可能只支持HTTP/1.0不实现chunked传输不处理gzip请求头稍微复杂一点就直接拒绝。对客户端而言这不是“随便发个请求就能通”的服务反而是最挑剔的那一类。我习惯把微站点的特征和客户端策略列成一张表微站点特征客户端应该怎么做并发处理能力弱几个连接就打死连接池上限设小单路由并发克制响应慢处理一个请求要几秒甚至更久socket超时不能太短但要设上限协议实现不完整只认HTTP/1.0必要时强制降级协议版本并关闭连接复用不声明字符集或直接是GBK/GB2312显式指定UTF-8或按响应头动态取值使用自签名证书内网调试时临时信任生产必须正规证书这套画像决定了后面所有参数选择。不是把并发调大就能更快也不是重试越多越可靠而是要把客户端变成一个“谦逊的访问者”连接数克制、超时宽容、解析灵活。第3章开始落地这套策略。3. 把第一个请求跑通依赖、单例与GET/POST的最小实现3.1 引入依赖Android 6.0之后的兼容做法与混淆配置老工程如果只依赖Android系统内置的HttpClient在新系统上必然跑不起来。第一步是把完整依赖打进来让APK自己携带实现不依赖运行时的那个stub。以Apache HttpClient 4.5.x系列为例Gradle依赖配置如下dependencies { // 4.5.14 属于 4.5.x 稳定线兼容 Android 6.0 implementation org.apache.httpcomponents:httpclient:4.5.14 }依赖引入后如果项目开了混淆还需要保留HttpClient相关类不然release包会在运行时出现找不到方法的情况-keep class org.apache.http.** { *; } -dontwarn org.apache.http.** -keep class org.apache.http.conn.** { *; }这里有一个容易被忽略的细节Android低版本系统里也带着一份HttpClient版本老旧。引入完整依赖后APK内打包的是新版本运行时优先用自己带的类不会跟系统冲突反而消除了老系统那份低版本实现带来的隐藏问题。3.2 全局单例连接管理器与请求配置的一次性初始化HttpClient的构建成本不低尤其是连接管理器、CookieStore和SSL上下文这些对象应该全局只建一次。每次new一个客户端连接池和Cookie全部失效性能差还丢登录态。我一般用一个静态持有者来做单例代码如下public class HttpHolder { // 全局唯一的CookieStore供所有请求共用 private static final BasicCookieStore COOKIE_STORE new BasicCookieStore(); private static final CloseableHttpClient CLIENT; static { // 连接池微站点并发能力弱总连接数宁小勿大 PoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(10); // 所有站点合计最多10条连接 cm.setDefaultMaxPerRoute(4); // 单个微站点最多4条连接 RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(5000) // TCP握手超时 .setSocketTimeout(8000) // 等待响应数据超时 .setConnectionRequestTimeout(2000) // 从连接池取连接的超时 .build(); CLIENT HttpClients.custom() .setConnectionManager(cm) .setDefaultRequestConfig(requestConfig) .setDefaultCookieStore(COOKIE_STORE) // 最多重试2次且不重试已发送过的请求 .setRetryHandler(new DefaultHttpRequestRetryHandler(2, false)) .build(); } public static CloseableHttpClient get() { return CLIENT; } }三个超时参数connectTimeout负责TCP握手socketTimeout负责建立连接后等数据的时长connectionRequestTimeout负责在连接池里排队取连接的时长。稍后第4章会展开它们的联动关系这里先按基础值跑通流程。连接池的10/4是面向微站点的保守值如果是访问普通云服务可以适当调大。3.3 微站点GET/POST模板字符集、实体消费与连接归还全局客户端准备好之后最常用的两个操作就是GET和POST表单。直接给一份可抄的最小模板public String doGet(String url) throws IOException { HttpGet httpGet new HttpGet(url); // 微站点多数不实现gzip直接要求返回原始内容 httpGet.setHeader(Accept-Encoding, identity); try (CloseableHttpResponse resp (CloseableHttpResponse) HttpHolder.get().execute(httpGet)) { int code resp.getStatusLine().getStatusCode(); // 对于微站点非2xx也应该拿到响应体便于排查 HttpEntity entity resp.getEntity(); if (entity ! null) { // 字符集必须显式指定默认ISO-8859-1会乱码 return EntityUtils.toString(entity, UTF-8); } } return null; } public String doPost(String url, MapString, String params) throws IOException { HttpPost httpPost new HttpPost(url); ListNameValuePair pairs new ArrayList(); for (Map.EntryString, String entry : params.entrySet()) { pairs.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); } httpPost.setEntity(new UrlEncodedFormEntity(pairs, UTF-8)); httpPost.setHeader(Accept-Encoding, identity); try (CloseableHttpResponse resp (CloseableHttpResponse) HttpHolder.get().execute(httpPost)) { HttpEntity entity resp.getEntity(); if (entity ! null) { return EntityUtils.toString(entity, UTF-8); } } return null; }这里最关键的一步是EntityUtils.toString它内部会读完整响应体并消费实体连接才能归还到池里。如果换成手动读取entity.getContent()的流必须自己读完并关闭否则连接会泄漏。try-with-resources负责关闭响应归还连接的动作也包含在CloseableHttpResponse的close逻辑里。Accept-Encoding设为identity是跟微站点打交道的一个实用技巧。很多轻量站点收到gzip请求头后要么直接返回空要么抛出解析异常。设置identity等于是告诉服务端“别压缩给我原始数据”虽然传输量大一点但兼容性最好。3.4 自签名证书与非UTF-8编码微站点两个常见例外微站点带有HTTPS时往往是自签名证书或过期证书直接访问会抛SSLHandshakeException。内网调试阶段可以临时信任所有证书做法如下// 仅限内网调试生产环境必须换回默认证书校验 SSLContext sslContext SSLContexts.custom() .loadTrustMaterial(null, (chain, authType) - true) .build(); SSLConnectionSocketFactory sf new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); CloseableHttpClient client HttpClients.custom() .setSSLSocketFactory(sf) .setConnectionManager(cm) // 复用前面的连接池 .setDefaultRequestConfig(requestConfig) .build();loadTrustMaterial回调里直接返回true等于不校验任何证书NoopHostnameVerifier跳过域名校验。这套组合能访问几乎所有自签名HTTPS设备但风险很大必须限定在调试环境或者纯内网且无敏感数据的场景。编码问题则是另一个高频坑。3.3的代码固定用UTF-8解析但部分国产设备固件返回的是GBK或GB2312。更稳妥的做法是先从Content-Type头里提取charset取不到再用默认值。无论哪种情况都不要调用不带字符集的EntityUtils.toString(entity)那会回落到ISO-8859-1中文一定乱码。4. 参数边界超时、连接池与重试的三个取舍4.1 三个超时参数谁控制连接谁控制响应谁控制排队很多开发者只设置了connectTimeout就以为万事大吉这是对超时模型最常见的误读。HttpClient的RequestConfig里三个超时各管一段缺一个都会在特定阶段卡死。参数控制阶段失败表现微站点建议值connectTimeoutTCP握手设备离线时长时间等待3000-5000 mssocketTimeout连接建立后读取响应设备处理慢导致读取超时5000-8000 msconnectionRequestTimeout从连接池取连接池被占满时排队超时1000-2000 msconnectTimeout设太短的后果很直接微站点在弱网下握手本来就慢可能还没握完就被判失败。socketTimeout是三个里最容易误伤的一个因为微站点的服务常常是同步处理收到请求后要计算几秒才返回第一字节如果按普通API的2秒标准来设大概率把正常请求当成超时。connectionRequestTimeout则负责保护调用方池子里没有空闲连接时不让调用方无限排队。我一般在调试阶段先抓包看一次完整请求耗时再反向调整这三个值。设备离线和设备在线但处理慢在抓包数据里是两种完全不同的现象不要凭感觉拍脑袋改参数。4.2 连接池的两个数字协议栈的复用和微站点的承受力连接池的核心配置只有两个数字maxTotal和defaultMaxPerRoute。maxTotal是全局连接数上限defaultMaxPerRoute是针对同一个目标主机协议IP端口的上限。访问微站点时实际生效的基本就是defaultMaxPerRoute因为通常只访问一个设备。微站点的承受力是这类场景下最容易误判的点。普通服务器可以轻松扛住几百并发但低功耗设备往往只有一个网络线程连接超过五六个就开始丢包甚至无响应。客户端设了8个并发连接设备实际只能处理2个剩下的连接全部堆积在设备协议栈里最后把设备直接打死。所以我对微站点的默认值很保守maxTotal设10defaultMaxPerRoute设4个别更弱的设备还要再降。连接池还有一个容易被忽略的参数setValidateAfterInactivity作用是在复用连接前先检查连接是否还活着。微站点设备或中间路由器经常静默回收空闲连接客户端拿旧连接发请求就会碰到NoHttpResponseException。设置如下cm.setValidateAfterInactivity(5000); // 空闲超过5秒的连接复用前先校验这个校验有开销但只发生在连接空闲超过指定时间时对正常流量的影响很小。微站点网络不稳定时这5秒的设置能避免大量“连接已失效却还在复用”的问题。4.3 重试策略只对“没发出去”的请求重试重试是微站点场景最容易埋雷的配置。平台自带的DefaultHttpRequestRetryHandler(2, false)只重试部分IO异常但对微站点来说重试本身可能就是灾难。很多嵌入式设备的固件不是幂等的客户端POST一次是一次实际操作网络超时后客户端重试两次设备上就执行了三次操作。自定义重试策略的正确写法是按HTTP方法和异常类型做双重判断CloseableHttpClient client HttpClients.custom() .setRetryHandler(new HttpRequestRetryHandler() { Override public boolean retryRequest(IOException exception, int executionCount, HttpContext context) { // 最多重试2次不无限重试 if (executionCount 2) { return false; } HttpRequest request (HttpRequest) context.getAttribute( HttpClientContext.HTTP_REQUEST); String method request.getRequestLine().getMethod(); // 只重试GET和HEADPOST等有副作用的操作一律不重试 if (!GET.equalsIgnoreCase(method) !HEAD.equalsIgnoreCase(method)) { return false; } // 连接超时通常没触达服务端可以重试 // NoHttpResponse是服务端可能已处理完但响应丢失GET可以重试 return exception instanceof ConnectTimeoutException || exception instanceof NoHttpResponseException; } }) .build();这个策略的核心是“不确定是否执行成功就不重试”。ConnectTimeoutException意味着握手都没完成服务端几乎不可能收到数据NoHttpResponseException则比较暧昧请求可能已经到达但响应丢失对GET来说重试是安全的因为GET本身没有副作用。POST在微站点场景里必须视为有副作用宁可让用户手动再点一次也不要在设备端产生重复记录。5. 避坑清单访问微站点最容易翻车的五个点5.1 类找不到NoClassDefFoundError与平台移除现象老App在Android 6.0以下正常运行换到新手机后一进页面就崩Logcat里是java.lang.NoClassDefFoundError: org/apache/http/impl/client/DefaultHttpClient。原因Android 6.0移除了系统内置的Apache HttpClient运行时但SDK里还留着编译用的stub类导致编译没问题、运行才炸。这是“系统更新引发的存量代码事故”里最常见的一类。解决在依赖里显式引入完整版HttpClient让APK自己携带实现。若引入依赖后仍然报错检查混淆配置有没有把org.apache.http包剪掉按3.1的keep规则保留。我接手这类工程时第一件事就是查依赖和混淆这两个位置最少占了八成的“类找不到”根因。5.2 中文乱码与JSON解析失败现象微站点返回的JSON在浏览器里正常App解析出来中文全是问号或者直接报字符编码错误。原因服务端没有在Content-Type里声明charsetHttpClient按默认ISO-8859-1解析。手机端拿到的字节流本身就是对的错在解析时用了错误的字符集。解决统一走3.3模板里的EntityUtils.toString(entity, UTF-8)。如果设备固定返回GBK把第二个参数改成GBK。更通用的做法是先读Content-Type头Header contentType resp.getFirstHeader(Content-Type); String charset UTF-8; if (contentType ! null contentType.getValue().contains(charset)) { // 从头里解析出真实charset覆盖默认值 }记住一个原则跟微站点打交道时永远不要相信不带字符集的解析方法。字符集不明确就直接显式指定宁可用错编码也不要用默认值。5.3 连接复用反而卡死HTTP/1.0设备与Keep-Alive冲突现象第一次请求微站点成功了第二次、第三次在同一个连接上卡住直到socketTimeout才报错。浏览器访问却一直正常。原因设备只实现了HTTP/1.0或者实现很粗糙不维护Keep-Alive状态。客户端用HTTP/1.1的默认行为想复用连接设备却已经把连接关了或者根本不认识请求头里的长连接标记。解决先抓包确认请求行里的协议版本和响应头里的Connection字段。确认设备不支持Keep-Alive后单独为这台设备关掉连接复用HttpGet httpGet new HttpGet(url); // 强制降到HTTP/1.0并明确要求响应后关闭连接 httpGet.setProtocolVersion(HttpVersion.HTTP_1_0); httpGet.setHeader(Connection, close);这样每次请求都新建TCP连接虽然握手开销大一点但胜在稳定。这个配置不能放在全局否则会影响其它正常服务只针对确认过协议的设备单独设置。5.4 越跑越慢连接泄漏才是幕后黑手现象App刚启动时请求只要200ms运行十分钟后变成几秒重启App又恢复正常。原因响应体没有完全消费或响应没有关闭连接池里的连接被占着不放最终耗尽。所有请求都在排队等connectionRequestTimeout在调用方看来就是“越跑越慢”。解决凡是手动调用entity.getContent()的地方必须保证流被读完并且关闭凡是直接执行请求的地方用try-with-resources包住响应像3.3模板那样。还有一个排查技巧给连接池加日志或者统计正常运行时空闲连接数应该长期大于0如果一直是0说明有连接在泄漏。5.5 登录态说丢就丢CookieStore没有全局化现象微站点登录成功过了几分钟再请求设备返回未登录。重新登录又可以了但过一阵又掉。原因登录时用的HttpClient实例和后续请求用的不是同一个CookieStore各自独立登录存进去的SessionID在下一个实例里根本不存在。解决全局只维护一份CookieStore并且所有请求都走同一个HttpClient实例也就是3.2单例里的做法。如果同一台设备上切换了多个账号CookieStore会被互相覆盖这种时候要么把登录逻辑串行化要么按账号维护多个单例。我一般倾向串行化微站点并发本来就不高强行并发登录反而制造问题。6. 验证与收尾抓包对比顺手留一条迁移退路6.1 抓包三件事协议版本、请求头、连接复用改完HttpClient配置验证手段不能只看“通没通”。我会用抓包工具把一次完整请求抓下来重点看三张表检查项正常表现异常含义协议版本请求行与设备支持版本一致设备收HTTP/1.1后不响应需降级请求头Accept-Encoding是identity出现gzip说明配置没生效连接复用第二次请求用同一本地端口new连接说明Keep-Alive失败或强制close这三项全部通过才算真正调通了。只靠“接口能返回数据”判断很容易漏掉协议版本不一致这类潜在问题等用户量上来才爆发。6.2 用接口把HttpClient包一层想换随时换HttpClient已经不再更新新特性但它不会从存量代码里消失。我的习惯是趁这次调通顺手在网络层外面封一个薄接口public interface WebClient { String get(String url, MapString, String headers) throws IOException; String post(String url, MapString, String headers, MapString, String params) throws IOException; }业务层只看WebClient不再直接触碰HttpGet、HttpPost。以后要迁移到其它HTTP客户端库时只需要替换实现类业务代码一行不用动。这是给未来买的后悔药成本低收益高。6.3 一个好习惯先看“不工作”的再看“能工作”的我调整这类老代码时有个固定顺序先把设备断网或者把连接数调到1制造一个明确的失败路径观察抛出的异常类型和重试行为。然后再恢复正常网络验证成功路径。失败路径能准确暴露超时参数设得对不对、重试策略拦住了哪些请求、连接是否归还到了池里。吃过“重试把设备状态机打坏”的亏之后我对POST这类副作用操作一律不重试这条规则一直保留到现在。希望帮到你。本文还有配套的精品资源点击获取