最近在开发过程中,QA同学反馈了一个bug:在华为荣耀6(Android 4.4.2)上,有些页面的图片加载不出来,只能展示默认的占位图,效果如下所示:
在项目中,图片展示用的是 Fresco
的 SimpleDraweeView
组件。第一次看到这个问题时,以为是 Fresco
的缓存出了问题,于是首先在手机的应用管理里,找到了对应的APP并清空了缓存。然而,重新启动APP后发现问题依然存在。于是深入分析了一下这个问题,发现了一个值得探讨的技术点,在此记录一下。
在清空缓存不解决问题的情况下,接下来做了以下几方面的验证:
难道 Fresco
加载圆形图片有兼容性问题?于是又去检查了一下其他页面,发现有些普通的方形图片也显示不出来。
通过调试,拿到了图片的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
。
至此,问题的原因已经清晰明了了,解决方案可以有两种方案:
对于项目中所有的图片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.2
中, HttpURLConnection
在做网络请求前没有自动做 URLEncode
的原因是引用的 okhttp
较低,还不支持这一功能。这也是导致开篇提到的图片加载失败的根本原因了。
PS: 看到 Android 7.1.0
还在使用 okhttp 2.6.0
的时候,还是很惊讶的, Android
版本中几乎可以肯定是没有跟上主流的 okhttp
版本,所以我们在使用 HttpURLConnection
的时候要特别留意这一点。