说起APP开发,除了单机版APP外,我们总少不了要与网络打交道,从刚刚接触Android开发自己用了HttpClient(现已被废弃)进行最基本的封装,到后来用了第三方框架AsyncHttpClient、Volley、OkHttp,这些都在生产环境玩过,早些年有空的时候也用过Retrofit1.0版本,但也只是用在demo玩玩而已,而现在Retrofit+RxJava这组合已经火了很久很久,最近刚好有空,便对公司现有的APP进行网络框架的重构,决定用这一组合进行试水踩坑,特此记录!
先记录一下所需引入的依赖
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.5.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.0'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.9.0'
1.Http 400 bad request
为了让封装的框架更具有通用性,所以请求的接口地址url一般都是配置成动态的,如下:
@GET("{url}")
Observable<ResponseBody> executeGet(
@Path("url") String url,
@QueryMap Map<String, String> maps
);
然而当你这样封装调用时,你会得到一个错误:“Http 400 bad request”,然后你仔细一看,原来自己的接口url被转义了,接口路径中的“/”被转义为“%2F”,如何解决这个问题呢?只需改为如下即可,使用注解@Url:
@GET
Observable<ResponseBody> executeGet(
@Url String url,
@QueryMap Map<String, String> maps
);
2.retrofit2.adapter.rxjava2.HttpException: HTTP 400
当你用POST请求时,如果你所带的参数比较常规时,一般以如下配置即可。然而当你POST的参数带有特殊字符时,你会得到一个错误:“retrofit2.adapter.rxjava2.HttpException: HTTP 400”,比如,当你将一张图片的Base64编码作为参数进行POST。
@POST
Observable<ResponseBody> executePost(
@Url String url,
@QueryMap Map<String, String> maps
);
如何解决呢?也不复杂,即用@FormUrlEncoded+@FieldMap代替原来的@QueryMap,其实这就是一个编码的问题。
@POST
@FormUrlEncoded
Observable<ResponseBody> executePost(
@Url String url,
@FieldMap Map<String, String> maps
);
3.IllegalArgumentException
这个错误就有点搞了,错误是发生在进行图片上传时,报错的是在请求头Headers的校验中,代码如下:
static void checkValue(String value, String name) {
if (value == null) throw new NullPointerException("value for name " + name + " == null");
for (int i = 0, length = value.length(); i < length; i++) {
char c = value.charAt(i);
if ((c <= '\u001f' && c != '\t') || c >= '\u007f') {
throw new IllegalArgumentException(Util.format(
"Unexpected char %#04x at %d in %s value: %s", (int) c, i, name, value));
}
}
}
其实这个报错也不复杂,无非就是请求头中有非法字符,看了一下,发现上传的图片名字中带有中文,所以报错,如果不含中文则不会报错。
解决的办法就更简单了,去掉图片名字中的中文即可,最简单的方案就是以时间戳进行文件命名,或将所有文件名进行一下MD5即可。
这个bug其实很简单,但让我比较纠结的地方在于,之前我的APP中也是用OkHttp进行图片上传的,而现在报错的Headers也属于OkHttp,那么问题就来了,我之前用OkHttp上传图片也有很多文件名包含中文的,一直都用得好好的啊,也没报错呀,为什么现在就报错了呢?
经过仔细的比较,发现原来是OkHttp版本的问题,原来我用的版本是3.9.0,而现在报错的版本却是3.12.0,在高版本中,增加了请求头校验,故此有这个错误!
通过这个错误,我们也可从侧面知道Retrofit底层其实用的就是OkHttp,是对其进行了封装!
4.获取Response
这个需求是出现在下载文件时,因为后台的接口采用了文件流的形式返回,并将文件的类型及名字放在了请求头中,而我们如果直接返回的是ResponseBody是无法获取到请求头的,能够获取到请求头就必须用Response,所以,接口最后是这样定义的:
@Streaming
@GET
Observable<Response<ResponseBody>> downloadFile(@Url String fileUrl);
其中,需要注意的是,这里的Response是retrofit2.Response,而并非okhttp3.Response,导包的时候必须得注意一下,否则会报错的!
5.https请求报错
这个可是个终极的bug啊,这个问题花费了我大半天的时间,翻看了无数的资料,不容易啊!
首先说一下,之前APP用的是Volley,并不存在这个问题,而现在用了Retrofit+RxJava,却发现只要是https的请求就报错,错误日志如下:
javax.net.ssl.SSLHandshakeException: javax.net.ssl.SSLProtocolException: SSL handshake aborted: ssl=0xb7d669f0: Failure in SSL library, usually a protocol error
error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure (external/openssl/ssl/s23_clnt.c:741 0x84d6a770:0x00000000)
这么说吧,我现在看到这个日志就头疼,因为实在是看了太多太多遍了,尝试了N种办法,至始至终都报上面这个错误,简直是无语!
通过翻看资料及自己的实践,其实这个bug也就出现在Android5.0以下,我特意用了Android8.0的模拟器试了,不用做任何处理,都可正常的返回数据。
其实这个问题网上也有不少人遇到,原因就是在 Android 5.0 之前,最高支持的 SSL/TLS 版本为 TLSv1,而当我们访问一些不再支持 TLSv1 及之前 SSL/TLS 版本的网站的时候,就会出现该问题。
原因找了,但解决的方案呢?我在网上搜了个遍,大家都是通过定制SSLSocketFactory来解决问题的,什么强制采用TLSv1.2协议的Tls12SocketFactory,什么过滤掉SSLv3协议的NoSSLv3SocketFactory等等,也尝试了N种忽略https证书的办法······
然而终不得解,错误一直如上,这可愁死我了!
网上也有人说了这些所谓忽略https证书的方案无法解决这个问题,而他们最后不得不限制APP最低兼容版本为Android5.0以上,这也太无奈了吧!
如果说换了个网络请求框架,却要牺牲掉APP的兼容性,这也太那个啥了吧,虽然现在大部分人的手机都是Android5.0以上了,但作为一个做技术的人,我觉得我不能就此放弃,于是我继续翻看各种资料,终于功夫
不负有心人,我在StackOverFlow上找到了这个问题的解决办法:
链接:https://stackoverflow.com/questions/49980508/okhttp-sslhandshakeexception-ssl-handshake-aborted-failure-in-ssl-library-a-pro
ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites(
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA)
.build();
httpClient.connectionSpecs(Collections.singletonList(spec));
确实,按上面这么设置后,https请求终于正常返回结果了,我总算松了一口气,然而当我多点了几下,发现又报错了,错误日志如下:
error:java.net.UnknownServiceException: CLEARTEXT communication not enabled for client
错误是发生在http请求时的,这可纠结了,好不容易搞定了https请求,现在http请求却又报错了,真是顾头不顾尾,修完一个bug却写出另一个bug,简直了!
幸好这个bug不难解决,还是在StackOverFlow上找到了解决方案:
链接:https://stackoverflow.com/questions/41551251/picasso-unknownserviceexception-cleartext-communication-not-enabled-for-client
关键代码如下:
ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites(
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA)
.build();//解决在Android5.0版本以下https无法访问
ConnectionSpec spec1 = new ConnectionSpec.Builder(ConnectionSpec.CLEARTEXT).build();//兼容http接口
httpClient.connectionSpecs(Arrays.asList(spec,spec1));
这样就行了,http和https接口都能正常访问了,我又想松一口气,然而我又多点了几下,结果问题又来了,我发现我们公司自己的https接口可正常访问,而部分第三方的https接口却不行,这······
淡定!淡定!一定要淡定!
直觉告诉我,问题应该出在上面配置很多的CipherSuite上,目测应该是少了一些,我看了一下ConnectionSpec.COMPATIBLE_TLS中配置的CipherSuite,哇塞,好多啊,如下:
private static final CipherSuite[] APPROVED_CIPHER_SUITES = new CipherSuite[] {
// TLSv1.3
CipherSuite.TLS_AES_128_GCM_SHA256,
CipherSuite.TLS_AES_256_GCM_SHA384,
CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
CipherSuite.TLS_AES_128_CCM_SHA256,
CipherSuite.TLS_AES_256_CCM_8_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
// Note that the following cipher suites are all on HTTP/2's bad cipher suites list. We'll
// continue to include them until better suites are commonly available. For example, none
// of the better cipher suites listed above shipped with Android 4.4 or Java 7.
CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384,
CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
CipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
};
Ctrl+C,Ctrl+V,我抱着试试的心态将以上的CipherSuite全部加上,结果果然没问题了!
但是,这样就算完全解决了吗?No,no,no···
看到上面这么多的CipherSuite我就头疼,这也太丑了吧,而且我转念一想,目前我用到的https请求都能正常返回结果,但谁又能保证哪天又冒出一个新的https请求需要别的CipherSuite呢?
直觉告诉我,这不是最终的靠谱的解决方案,于是我继续耐心地查看了ConnectionSpec这个类的源码,我想解决方案应该就在这个类中,果然,我在这个类中找到了如下的API:
public Builder allEnabledCipherSuites() {
if (!tls) throw new IllegalStateException("no cipher suites for cleartext connections");
this.cipherSuites = null;
return this;
}
从这个方法的命名,我们就知道这就是终极解决方案了,于是乎,最终的代码更改为:
ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.allEnabledCipherSuites()
.build();//解决在Android5.0版本以下https无法访问
ConnectionSpec spec1 = new ConnectionSpec.Builder(ConnectionSpec.CLEARTEXT).build();//兼容http接口
httpClient.connectionSpecs(Arrays.asList(spec,spec1));
至此,https请求报错的问题总算解决了,这个问题花费了我不少时间,但个人觉得还是很值得的!
回顾一下上面填坑的过程,不得不承认前辈们的总结是很有道理的——遇到难题的三个法宝:第一谷歌,第二GitHub,第三StackOverFlow。
另外,查看源码也是解决问题的好办法!