记一次Fresco加载图片失败的分析

最近在开发过程中,QA同学反馈了一个bug:在华为荣耀6(Android 4.4.2)上,有些页面的图片加载不出来,只能展示默认的占位图,效果如下所示:

记一次Fresco加载图片失败的分析

在项目中,图片展示用的是 FrescoSimpleDraweeView 组件。第一次看到这个问题时,以为是 Fresco缓存出了问题,于是首先在手机的应用管理里,找到了对应的APP并清空了缓存。然而,重新启动APP后发现问题依然存在。于是深入分析了一下这个问题,发现了一个值得探讨的技术点,在此记录一下。

问题定位

在清空缓存不解决问题的情况下,接下来做了以下几方面的验证:

图片形状导致不兼容?

难道 Fresco 加载圆形图片有兼容性问题?于是又去检查了一下其他页面,发现有些普通的方形图片也显示不出来。

图片的URL有问题?

通过调试,拿到了图片的URL(注:为避免敏感信息,这里连接用的是自己测试的图片,效果都一样): oq54hiwcu.bkt.clouddn.com/2018-10-26-… 。把整个图片链接放到浏览器中,发现可以正常打开图片。

如果拿另外一个可以加载成功的图片的URL,通过 SimpleDraweeViewsetImageURI(String uriString) 方法,设置给这个显示异常的组件,发现可以正常加载出来!

认真对比了一下两个链接,发现加载失败的链接中除了有中文外,没有其他的差别。把上面图片链接中 春雷 两个字做 URLEncode 之后,得到的链接是: oq54hiwcu.bkt.clouddn.com/2018-10-26-… 。当把经过 URLEncode 之后的图片链接重新设置给 SimpleDraweeView 的时候,发现图片可以正常显示了!

于是问题初步定位: 带特殊字符的URL(如中文,空格等),在这款手机上加载不出来!

虽然问题定位到了,但是为什么同样的URL在其他手机(手头有Android 8.0等高版本手机)上可以正常加载图片,在这款手机上就无法加载成功呢?难道 Fresco 存在兼容性问题?

问题原因

在项目中,图片的 URL 是通过调用 SimpleDraweeViewsetImageURI(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,在调用 SimpleDraweeViewsetImageURI(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 ,虽然有可参考的方法(如 okhttpHttpUrl.parse() 方法),但是总归有些繁琐。

Fresco 定制网络引擎

因为 Fresco 允许定制网络引擎,所以我们也可以通过给 Fresco 定制网络引擎的方式来解决这个问题。比如,当指定网络加载引擎为 okhttpFresco 的官方文档上给出了示例代码,参考如下:

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 的创建都是通过 URLopenConnection() 方法来实现,简化代码如下:

// 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 方法最终会调用 handleropenConnection() 方法。如果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 中最终是调用了 OkUrlFactoryopen() 方法。接着看下 OkUrlFactoryopen() 方法的实现:

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);
		...
	}
}
复制代码

从上面的代码中可以看到, OkUrlFactoryopen() 方法最终创建并返回了一个 OkHttpURLConnection 对象。而 OkHttpURLConnection 继承了 HttpURLConnection ,也就意味着 URLopenConnection() 的返回值实际上是一个 OkHttpURLConnection实例。当 URLConnection 连接网络时,需要调用 connect() 方法,所以我们需要分析下 OkHttpURLConnectionconnect() 方法的执行内容:

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();
		...
	}
}
复制代码

我们可以看到,当 OkHttpURLConnectionconnect() 方法被调用时,会按照 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 是在这个方法中,下面看一下 HttpUrlparse() 方法的实现:

// 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.4HttpURLConnection 在底层实现上已经采用了 okhttp ,那为什么有特殊字符的时候,并不能访问成功呢?

首先需要明确的一点是, okhttp 对传入的URL做 URLEncode 是从 2.4.0-RC 版本才开始的。也就是说,这以前的版本,并不会对URL的参数部分做 URLEncode ,都是直接用URL去访问服务器。这点可以从源码中分析得出。

记一次Fresco加载图片失败的分析
记一次Fresco加载图片失败的分析

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 的时候要特别留意这一点。

原文 

https://juejin.im/post/5bd313fde51d455ed7391810

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 记一次Fresco加载图片失败的分析

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址