简介:该压缩包为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.0(API 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, Map<String, String> params) throws IOException { HttpPost httpPost = new HttpPost(url); List<NameValuePair> pairs = new ArrayList<>(); for (Map.Entry<String, 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里三个超时各管一段,缺一个都会在特定阶段卡死。
| 参数 | 控制阶段 | 失败表现 | 微站点建议值 |
|---|---|---|---|
| connectTimeout | TCP握手 | 设备离线时长时间等待 | 3000-5000 ms |
| socketTimeout | 连接建立后读取响应 | 设备处理慢导致读取超时 | 5000-8000 ms |
| connectionRequestTimeout | 从连接池取连接 | 池被占满时排队超时 | 1000-2000 ms |
connectTimeout设太短的后果很直接:微站点在弱网下握手本来就慢,可能还没握完就被判失败。socketTimeout是三个里最容易误伤的一个,因为微站点的服务常常是同步处理,收到请求后要计算几秒才返回第一字节,如果按普通API的2秒标准来设,大概率把正常请求当成超时。connectionRequestTimeout则负责保护调用方,池子里没有空闲连接时,不让调用方无限排队。
我一般在调试阶段先抓包看一次完整请求耗时,再反向调整这三个值。设备离线和设备在线但处理慢,在抓包数据里是两种完全不同的现象,不要凭感觉拍脑袋改参数。
4.2 连接池的两个数字:协议栈的复用和微站点的承受力
连接池的核心配置只有两个数字:maxTotal和defaultMaxPerRoute。maxTotal是全局连接数上限,defaultMaxPerRoute是针对同一个目标主机(协议+IP+端口)的上限。访问微站点时,实际生效的基本就是defaultMaxPerRoute,因为通常只访问一个设备。
微站点的承受力是这类场景下最容易误判的点。普通服务器可以轻松扛住几百并发,但低功耗设备往往只有一个网络线程,连接超过五六个就开始丢包甚至无响应。客户端设了8个并发连接,设备实际只能处理2个,剩下的连接全部堆积在设备协议栈里,最后把设备直接打死。所以我对微站点的默认值很保守:maxTotal设10,defaultMaxPerRoute设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和HEAD,POST等有副作用的操作一律不重试 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里声明charset,HttpClient按默认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, Map<String, String> headers) throws IOException; String post(String url, Map<String, String> headers, Map<String, String> params) throws IOException; }业务层只看WebClient,不再直接触碰HttpGet、HttpPost。以后要迁移到其它HTTP客户端库时,只需要替换实现类,业务代码一行不用动。这是给未来买的后悔药,成本低收益高。
6.3 一个好习惯:先看“不工作”的,再看“能工作”的
我调整这类老代码时有个固定顺序:先把设备断网或者把连接数调到1,制造一个明确的失败路径,观察抛出的异常类型和重试行为。然后再恢复正常网络验证成功路径。失败路径能准确暴露超时参数设得对不对、重试策略拦住了哪些请求、连接是否归还到了池里。吃过“重试把设备状态机打坏”的亏之后,我对POST这类副作用操作一律不重试,这条规则一直保留到现在。希望帮到你。
本文还有配套的精品资源,点击获取