想必大家都用過或接觸過 OkHttp,我最近在使用 Okhttp 時,就踩到一個坑,在這兒分享出來,以後大家遇到類似問題時就可以繞過去。
只是解決問題是不夠的,本文將 側重從原始碼角度分析下問題的根本,乾貨滿滿。
1.發現問題
在開發時,我通過構造 OkHttpClient
物件發起一次請求並加入佇列,待服務端響應後,回撥 Callback
介面觸發 onResponse()
方法,然後在該方法中通過 Response
物件處理返回結果、實現業務邏輯。程式碼大致如下:
//注:為聚焦問題,刪除了無關程式碼
getHttpClient().newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {}
@Override
public void onResponse(Call call, Response response) throws IOException {
if (BuildConfig.DEBUG) {
Log.d(TAG, "onResponse: " + response.body().toString());
}
//解析請求體
parseResponseStr(response.body().string());
}
});
複製程式碼
在 onResponse()
中,為便於除錯,我列印了返回體,然後通過 parseResponseStr()
方法解析返回體(注意:這兒兩次呼叫了 response.body().string()
)。
這段看起來沒有任何問題的程式碼,實際執行後卻出了問題:通過控制檯看到成功列印了返回體資料(json),但緊接著丟擲了異常:
java.lang.IllegalStateException: closed
複製程式碼
2.解決問題
檢查程式碼後,發現問題出在呼叫 parseResponseStr()
時,再次使用了 response.body().string()
作為引數。由於當時趕時間,上網查閱後發現 response.body().string()
只能呼叫一次,於是修改 onResponse()
方法中的邏輯後解決了問題:
getHttpClient().newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {}
@Override
public void onResponse(Call call, Response response) throws IOException {
//此處,先將響應體儲存到記憶體中
String responseStr = response.body().string();
if (BuildConfig.DEBUG) {
Log.d(TAG, "onResponse: " + responseStr);
}
//解析請求體
parseReponseStr(responseStr);
}
});
複製程式碼
3.結合原始碼分析問題
問題解決了,事後還是要分析的。由於之前對 OkHttp
的瞭解僅限於使用,沒有仔細分析過其內部實現的細節,週末抽時間往下看了看,算是弄明白了問題發生的原因。
先分析最直觀的問題:為何 response.body().string()
只能呼叫一次?
拆解來看,先通過 response.body()
得到 ResponseBody
物件(其是一個抽象類,在此我們不需要關心具體的實現類),然後呼叫 ResponseBody
的 string()
方法得到響應體的內容。
分析後 body()
方法沒有問題,我們往下看 string()
方法:
public final String string() throws IOException {
return new String(bytes(), charset().name());
}
複製程式碼
很簡單,通過指定字符集(charset)將 byte()
方法返回的 byte[]
陣列轉為 String
物件,構造沒有問題,繼續往下看 byte()
方法:
public final byte[] bytes() throws IOException {
//...
BufferedSource source = source();
byte[] bytes;
try {
bytes = source.readByteArray();
} finally {
Util.closeQuietly(source);
}
//...
return bytes;
}
複製程式碼
//...
表示刪減了無關程式碼,下同。
在 byte()
方法中,通過 BufferedSource
介面物件讀取 byte[]
陣列並返回。結合上面提到的異常,我注意到 finally
程式碼塊中的 Util.closeQuietly()
方法。excuse me?默默地關閉???
public static void closeQuietly(Closeable closeable) {
if (closeable != null) {
try {
closeable.close();
} catch (RuntimeException rethrown) {
throw rethrown;
} catch (Exception ignored) {
}
}
}
複製程式碼
原來,上面提到的 BufferedSource
介面,根據程式碼文件註釋,可以理解為 資源緩衝區,其實現了 Closeable
介面,通過複寫 close()
方法來 關閉並釋放資源。接著往下看 close()
方法做了什麼(在當前場景下,BufferedSource
實現類為 RealBufferedSource
):
//持有的 Source 物件
public final Source source;
@Override
public void close() throws IOException {
if (closed) return;
closed = true;
source.close();
buffer.clear();
}
複製程式碼
很明顯,通過 source.close()
關閉並釋放資源。說到這兒, closeQuietly()
方法的作用就不言而喻了,就是關閉 ResponseBody
子類所持有的 BufferedSource
介面物件。
分析至此,我們恍然大悟:當我們第一次呼叫 response.body().string()
時,OkHttp 將響應體的緩衝資源返回的同時,呼叫 closeQuietly()
方法默默釋放了資源。
如此一來,當我們再次呼叫 string()
方法時,依然回到上面的 byte()
方法,這一次問題就出在了 bytes = source.readByteArray()
這行程式碼。一起來看看 RealBufferedSource
的 readByteArray()
方法:
@Override
public byte[] readByteArray() throws IOException {
buffer.writeAll(source);
return buffer.readByteArray();
}
複製程式碼
繼續往下看 writeAll()
方法:
@Override
public long writeAll(Source source) throws IOException {
//...
long totalBytesRead = 0;
for (long readCount; (readCount = source.read(this, Segment.SIZE)) != -1; ) {
totalBytesRead += readCount;
}
return totalBytesRead;
}
複製程式碼
問題出在 for
迴圈的 source.read()
這兒。還記得在上面分析 close()
方法時,其呼叫了 source.close()
來關閉並釋放資源。那麼,再次呼叫 read()
方法會發生什麼呢:
@Override
public long read(Buffer sink, long byteCount) throws IOException {
//...
if (closed) throw new IllegalStateException("closed");
//...
return buffer.read(sink, toRead);
}
複製程式碼
至此,與我在前面遇到的崩潰對上了:
java.lang.IllegalStateException: closed
複製程式碼
4.OkHttp 為什麼要這麼設計?
通過 fuc*ing the source code
,我們找到了問題的根本,但我還有一個疑問:OkHttp 為什麼要這麼設計?
其實,理解這個問題最好的方式就是檢視 ResponseBody
的註釋文件,正如 JakeWharton
在 issues
中給出的回覆:
reply of JakeWharton in okhttp issues
就簡單的一句話:It's documented on ResponseBody.
於是我跑去看類註釋文件,最後梳理如下:
在實際開發中,響應主體
RessponseBody
持有的資源可能會很大,所以 OkHttp 並不會將其直接儲存到記憶體中,只是持有資料流連線。只有當我們需要時,才會從伺服器獲取資料並返回。同時,考慮到應用重複讀取資料的可能性很小,所以將其設計為一次性流(one-shot)
,讀取後即 '關閉並釋放資源'。
5.總結
最後,總結以下幾點注意事項,劃重點了:
- 響應體只能被使用一次;
- 響應體必須關閉:值得注意的是,在下載檔案等場景下,當你以
response.body().byteStream()
形式獲取輸入流時,務必通過Response.close()
來手動關閉響應體。 - 獲取響應體資料的方法:使用
bytes()
或string()
將整個響應讀入記憶體;或者使用source()
,byteStream()
,charStream()
方法以流的形式傳輸資料。 - 以下方法會觸發關閉響應體:
Response.close()
Response.body().close()
Response.body().source().close()
Response.body().charStream().close()
Response.body().byteString().close()
Response.body().bytes()
Response.body().string()
複製程式碼
就醬,又是新的一週,加油!
最後,歡迎關注我的公眾號「伯特說」