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

问题描述

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

在项目中,图片展示用的是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去访问服务器。这点可以从源码中分析得出。

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

参考资料

  1. Android Fresco源码解析(4)-setImageUri
  2. Using Other Network Layers
  3. 怎么进行:URLEncode编码 与 URLDecode解码
  4. HttpHandler.java
  5. OkUrlFactory.java
  6. Http(s)URLConnection背后隐藏的惊人真相
  7. okhttp
  8. Android HttpURLConnection源码分析

猜你喜欢

转载自juejin.im/post/5bd313fde51d455ed7391810