記一次Fresco載入圖片失敗的分析

newtonker發表於2018-10-26

問題描述

最近在開發過程中,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的時候要特別留意這一點。

參考資料

  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原始碼分析

相關文章