記一次Fresco載入圖片失敗的分析
最近在開發過程中,QA同學反饋了一個bug:在華為榮耀6(Android 4.4.2)上,有些頁面的圖片載入不出來,只能展示預設的佔位圖,效果如下所示:

在專案中,圖片展示用的是 Fresco
的 SimpleDraweeView
元件。第一次看到這個問題時,以為是 Fresco
的快取出了問題,於是首先在手機的應用管理裡,找到了對應的APP並清空了快取。然而,重新啟動APP後發現問題依然存在。於是深入分析了一下這個問題,發現了一個值得探討的技術點,在此記錄一下。
問題定位
在清空快取不解決問題的情況下,接下來做了以下幾方面的驗證:
圖片形狀導致不相容?
難道 Fresco
載入圓形圖片有相容性問題?於是又去檢查了一下其他頁面,發現有些普通的方形圖片也顯示不出來。
圖片的URL有問題?
通過除錯,拿到了圖片的URL(注:為避免敏感資訊,這裡連線用的是自己測試的圖片,效果都一樣): ofollow,noindex">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
的時候要特別留意這一點。