问题描述
最近在开发过程中,QA同学反馈了一个bug:在华为荣耀6(Android 4.4.2)上,有些页面的图片加载不出来,只能展示默认的占位图,效果如下所示:
在项目中,图片展示用的是Fresco
的SimpleDraweeView
组件。第一次看到这个问题时,以为是Fresco
的缓存出了问题,于是首先在手机的应用管理里,找到了对应的APP并清空了缓存。然而,重新启动APP后发现问题依然存在。于是深入分析了一下这个问题,发现了一个值得探讨的技术点,在此记录一下。
问题定位
在清空缓存不解决问题的情况下,接下来做了以下几方面的验证:
图片形状导致不兼容?
难道Fresco
加载圆形图片有兼容性问题?于是又去检查了一下其他页面,发现有些普通的方形图片也显示不出来。
图片的URL有问题?
通过调试,拿到了图片的URL(注:为避免敏感信息,这里连接用的是自己测试的图片,效果都一样):oq54hiwcu.bkt.clouddn.com/2018-10-26-…。把整个图片链接放到浏览器中,发现可以正常打开图片。
如果拿另外一个可以加载成功的图片的URL,通过SimpleDraweeView
的setImageURI(String uriString)
方法,设置给这个显示异常的组件,发现可以正常加载出来!
认真对比了一下两个链接,发现加载失败的链接中除了有中文外,没有其他的差别。把上面图片链接中大发
两个字做URLEncode
之后,得到的链接是:oq54hiwcu.bkt.clouddn.com/2018-10-26-…。当把经过URLEncode
之后的图片链接重新设置给SimpleDraweeView
的时候,发现图片可以正常显示了!
于是问题初步定位:带特殊字符的URL(如中文,空格等),在这款手机上加载不出来!
虽然问题定位到了,但是为什么同样的URL在其他手机(手头有Android 8.0等高版本手机)上可以正常加载图片,在这款手机上就无法加载成功呢?难道Fresco
存在兼容性问题?
问题原因
在项目中,图片的URL
是通过调用SimpleDraweeView
的setImageURI(String uriString)
方法进行设置的。要解决弄明白上面的问题,就需要深入追踪了一下这里源码的实现。
众所周知,Fresco
设计是三级缓存:内存、文件、网络。针对我们当前遇到的问题,初步推断应该是图片在通过网络加载的时候出问题的。
如果在Fresco
初始化时没有自定义网络加载引擎,那Fresco
默认使用的是系统自带的HttpURLConnection
。通过阅读源码可知,Fresco
中通过网络加载图片,最终是通过HttpUrlConnectionNetworkFetcher
类中的downloadFrom(Uri uri, int maxRedirects)
方法来完成网络请求的。源码简化如下:
// HttpUrlConnectionNetworkFetcher.java
private HttpURLConnection downloadFrom(Uri uri, int maxRedirects) throws IOException {
HttpURLConnection connection = openConnectionTo(uri);
connection.setConnectTimeout(mHttpConnectionTimeout);
int responseCode = connection.getResponseCode();
...
}
复制代码
从上面的代码中可以看出,Fresco
默认使用HttpUrlConnection
做网络请求。经过调试发现,带特殊字符的URL在connection.getResponseCode()
执行时,每次返回的responseCode
都是403,即服务器不响应此次请求。当链接中的特殊字符经过URLEncode
之后,responseCode
正常返回200。也就是说这个版本的HttpURLConnection
在底层并不会自动对URL
的Params中的特殊字符做URLEncode
。
解决方案
至此,问题的原因已经清晰明了了,解决方案可以有两种方案:
统一URLEncode
对于项目中所有的图片URL,在调用SimpleDraweeView
的setImageURI(String uriString)
前,统一对参数做一次URLEncode
即可。
需要注意的是:对链接做URLEncode
不能像下面这样直接把整个链接作为参数传入,因为这样会把一些并不需要转换的特殊字符也直接转换掉。
String query = java.net.URLEncoder.encode("pg=q&kl=XX&stype=stext");
// query: pg%3Dq%26kl%3DXX%26stype%3Dstext
复制代码
比如:当我们要对pg=q&kl=XX&stype=stext
的链接做URLEncode
时,如果采用上述方法,最终得到的结果是:pg%3Dq%26kl%3DXX%26stype%3Dstext
,这并不符合我们的预期。因为我们只希望把Params的部分做URLEncode
。这就需要对URL的Params解析后再做URLEncode
,虽然有可参考的方法(如okhttp
的HttpUrl.parse()
方法),但是总归有些繁琐。
为Fresco
定制网络引擎
因为Fresco
允许定制网络引擎,所以我们也可以通过给Fresco
定制网络引擎的方式来解决这个问题。比如,当指定网络加载引擎为okhttp
,Fresco
的官方文档上给出了示例代码,参考如下:
dependencies {
// your project's other dependencies
implementation "com.facebook.fresco:imagepipeline-okhttp3:1.11.0"
}
Context context;
OkHttpClient okHttpClient; // build on your own
ImagePipelineConfig config = OkHttpImagePipelineConfigFactory
.newBuilder(context, okHttpClient)
. // other setters
. // setNetworkFetcher is already called for you
.build();
Fresco.initialize(context, config);
复制代码
相比第一种方案,通过给Fresco
定制网络加载引擎的方式,实现起来更加简单。笔者也是采用了这个方案来解决开头提出的bug。
虽然开头描述的问题已经解决了,但还有一些疑问没有解答,比如:为什么这个版本的HttpURLConnection
在底层不会自动对URL中Params中的特殊字符做URLEncode
?是手机问题还是Android
版本的问题(手边有另一台华为畅玩4,Android 4.4.2
也是同样的问题,基本判断是Android
版本的问题)?众所周知,Android从4.4
版本开始,HttpURLConnection
的底层实现也是使用okhttp
,那为什么直接用okhttp
网络框架可以打开这个链接,而HttpURLConnection
却不会打不开呢?
进阶分析
要解决上面的疑问,就需要对HttpURLConnection
底层是如何使用okttp
做网络请求的做分析。
HttpURLConnection
底层实现
URLConnection
的创建都是通过URL
的openConnection()
方法来实现,简化代码如下:
// URL.java
public URLConnection openConnection() throws java.io.IOException {
return handler.openConnection(this);
}
static URLStreamHandler getURLStreamHandler(String protocol) {
...
if (protocol.equals("file")) {
handler = new sun.net.www.protocol.file.Handler();
} else if (protocol.equals("ftp")) {
handler = new sun.net.www.protocol.ftp.Handler();
} else if (protocol.equals("jar")) {
handler = new sun.net.www.protocol.jar.Handler();
} else if (protocol.equals("http")) {
handler = (URLStreamHandler)Class.
forName("com.android.okhttp.HttpHandler").newInstance();
} else if (protocol.equals("https")) {
handler = (URLStreamHandler)Class.
forName("com.android.okhttp.HttpsHandler").newInstance();
}
...
}
复制代码
从上面的程序中可以看出,URL的openConnection
方法最终会调用handler
的openConnection()
方法。如果URL是http
协议,那么handler
的真正实现是com.android.okhttp.HttpHandler
这个类。接下来看一下这个类中对应方法的实现:
public class HttpHandler extends URLStreamHandler {
...
@Override protected URLConnection openConnection(URL url) throws IOException {
return newOkUrlFactory(null /* proxy */).open(url);
}
...
protected OkUrlFactory newOkUrlFactory(Proxy proxy) {
OkUrlFactory okUrlFactory = createHttpOkUrlFactory(proxy);
okUrlFactory.client().setConnectionPool(configAwareConnectionPool.get());
return okUrlFactory;
}
复制代码
从上面的代码中可以看出,HttpHandler
中最终是调用了OkUrlFactory
的open()
方法。接着看下OkUrlFactory
中open()
方法的实现:
public final class OkUrlFactory implements URLStreamHandlerFactory, Cloneable {
public HttpURLConnection open(URL url) {
return open(url, client.proxy());
}
HttpURLConnection open(URL url, Proxy proxy) {
String protocol = url.getProtocol();
OkHttpClient copy = client.newBuilder()
.proxy(proxy)
.build();
if (protocol.equals("http")) return new OkHttpURLConnection(url, copy, urlFilter);
...
}
}
复制代码
从上面的代码中可以看到,OkUrlFactory
的open()
方法最终创建并返回了一个OkHttpURLConnection
对象。而OkHttpURLConnection
继承了HttpURLConnection
,也就意味着URL
的openConnection()
的返回值实际上是一个OkHttpURLConnection
的实例。当URLConnection
连接网络时,需要调用connect()
方法,所以我们需要分析下OkHttpURLConnection
中connect()
方法的执行内容:
public final class OkHttpURLConnection extends HttpURLConnection implements Callback {
@Override public void connect() throws IOException {
...
Call call = buildCall();
executed = true;
call.enqueue(this);
...
}
private Call buildCall() throws IOException {
...
Request request = new Request.Builder()
.url(Internal.instance.getHttpUrlChecked(getURL().toString()))
.headers(requestHeaders.build())
.method(method, requestBody)
.build();
...
}
}
复制代码
我们可以看到,当OkHttpURLConnection
的connect()
方法被调用时,会按照okhttp
网络请求的步骤,首先通过buildCall()
方法先创建一个Call
,然后再调用call.enqueue()
方法执行真正的网络请求。而在buildCall()
方法中,会使用Request.Builder
方式创建一个Request。至此,我们分析完了HttpURLConnection
内部通过okhttp
实现网络请求的过程。
okhttp
何时对传入的链接做URLEncode
的呢?
既然最终回到了okhttp
的调用上,**那okhttp
何时对传入的链接做URLEncode
的呢?答案是在创建Request
的时候!**通过阅读okhttp
的源码可知,在创建Request
的时候,带特殊字符的URL是通过HttpUrl
中的parse()
方法做URLEncode
的。简化源码如下:
// Request.java
public Builder url(String url) {
...
HttpUrl parsed = HttpUrl.parse(url);
...
}
复制代码
在创建Request
时,通常是通过Request.Builder
来实现。上面的代码中,重点应注意HttpUrl.parse(url)
这个方法,因为对请求参数做URLEncode
是在这个方法中,下面看一下HttpUrl
中parse()
方法的实现:
// HttpUrl.java
public static @Nullable HttpUrl parse(String url) {
Builder builder = new Builder();
// 注意这里,实际上是通过HttpUrl.Builder的parse方法实现
Builder.ParseResult result = builder.parse(null, url);
return result == Builder.ParseResult.SUCCESS ? builder.build() : null;
}
// HttpUrl.Builder
ParseResult parse(@Nullable HttpUrl base, String input) {
...
// 真正的URLEncode就是这里
this.encodedQueryNamesAndValues = queryStringToNamesAndValues(canonicalize(
input, pos + 1, queryDelimiterOffset, QUERY_ENCODE_SET, true, false, true, true, null));
...
}
static void canonicalize(Buffer out, String input, int pos, int limit, String encodeSet,
boolean alreadyEncoded, boolean strict, boolean plusIsSpace, boolean asciiOnly,
Charset charset) {
Buffer encodedCharBuffer = null; // Lazily allocated.
int codePoint;
for (int i = pos; i < limit; i += Character.charCount(codePoint)) {
codePoint = input.codePointAt(i);
if (alreadyEncoded
&& (codePoint == '\t' || codePoint == '\n' || codePoint == '\f' || codePoint == '\r')) {
// Skip this character.
} else if (codePoint == '+' && plusIsSpace) {
// Encode '+' as '%2B' since we permit ' ' to be encoded as either '+' or '%20'.
out.writeUtf8(alreadyEncoded ? "+" : "%2B");
} else if (codePoint < 0x20
|| codePoint == 0x7f
|| codePoint >= 0x80 && asciiOnly
|| encodeSet.indexOf(codePoint) != -1
|| codePoint == '%' && (!alreadyEncoded || strict && !percentEncoded(input, i, limit))) {
// Percent encode this character.
if (encodedCharBuffer == null) {
encodedCharBuffer = new Buffer();
}
if (charset == null || charset.equals(Util.UTF_8)) {
encodedCharBuffer.writeUtf8CodePoint(codePoint);
} else {
encodedCharBuffer.writeString(input, i, i + Character.charCount(codePoint), charset);
}
while (!encodedCharBuffer.exhausted()) {
int b = encodedCharBuffer.readByte() & 0xff;
out.writeByte('%');
out.writeByte(HEX_DIGITS[(b >> 4) & 0xf]);
out.writeByte(HEX_DIGITS[b & 0xf]);
}
} else {
// This character doesn't need encoding. Just copy it over.
out.writeUtf8CodePoint(codePoint);
}
}
}
复制代码
如上所示,HttpUrl
中的parse()
方法最终调用了静态的canonicalize()
方法,实现了把URL参数中的特殊字符进行URLEncode
。
归因
在回到本章最开始提出的问题,既然Android 4.4
中HttpURLConnection
在底层实现上已经采用了okhttp
,那为什么有特殊字符的时候,并不能访问成功呢?
首先需要明确的一点是,okhttp
对传入的URL做URLEncode
是从2.4.0-RC
版本才开始的。也就是说,这以前的版本,并不会对URL的参数部分做URLEncode
,都是直接用URL去访问服务器。这点可以从源码中分析得出。
Android
的不同版本,也使用的是不同版本的okhttp
,目前可以查阅到对应版本如下:
- Android 4.4.4_r1: 1.1.2
- Android 4.0.1_41: 2.0.0
- Android 6.0.1_r1: 2.4.0
- Android 7.1.0_r1: 2.6.0
至此,我们彻底捋明白了前面遇到的问题,简单总结来说就是:在Android 4.4.2
中,HttpURLConnection
在做网络请求前没有自动做URLEncode
的原因是引用的okhttp
较低,还不支持这一功能。这也是导致开篇提到的图片加载失败的根本原因了。
PS: 看到Android 7.1.0
还在使用okhttp 2.6.0
的时候,还是很惊讶的,Android
版本中几乎可以肯定是没有跟上主流的okhttp
版本,所以我们在使用HttpURLConnection
的时候要特别留意这一点。
参考资料
- Android Fresco源码解析(4)-setImageUri
- Using Other Network Layers
- 怎么进行:URLEncode编码 与 URLDecode解码
- HttpHandler.java
- OkUrlFactory.java
- Http(s)URLConnection背后隐藏的惊人真相
- okhttp
- Android HttpURLConnection源码分析