問題描述
最近在開發過程中,QA同學反饋了一個bug:在華為榮耀6(Android 4.4.2)上,有些頁面的圖片載入不出來,只能展示預設的佔點陣圖,效果如下所示:
在專案中,圖片展示用的是Fresco
的SimpleDraweeView
元件。第一次看到這個問題時,以為是Fresco
的快取出了問題,於是首先在手機的應用管理裡,找到了對應的APP並清空了快取。然而,重新啟動APP後發現問題依然存在。於是深入分析了一下這個問題,發現了一個值得探討的技術點,在此記錄一下。
問題定位
在清空快取不解決問題的情況下,接下來做了以下幾方面的驗證:
圖片形狀導致不相容?
難道Fresco
載入圓形圖片有相容性問題?於是又去檢查了一下其他頁面,發現有些普通的方形圖片也顯示不出來。
圖片的URL有問題?
通過除錯,拿到了圖片的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
。
解決方案
至此,問題的原因已經清晰明瞭了,解決方案可以有兩種方案:
統一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
的時候要特別留意這一點。