OKHTTP學習之高階特性

前言

上一篇我已經將OKHTTP的基礎知識介紹了一番<<
OKHTTP學習之基礎知識及運用 >>
。這一篇我們一起探索一些複雜的功能。
在這之前我們將基礎知識再回顧一下。

  • Call
  • 同步請求 execute
  • 非同步請求 enqueue
  • 非同步請求時的回撥 Callback
  • 伺服器的回覆 Response
  • 服務的訊息體 ResponseBody
  • 網路訪問的請求 Request
  • Header
  • 請求的訊息體 RequestBody
  • 訊息體的資料型別MediaType

不熟悉的話大家也可以返回點選這裡

基礎功能

我們已經知道了

同步請求

execute()

非同步請求

call()方法和Callback回撥。

get和post

複雜功能

這一部分,我講解Okhttp能夠幫助我們做的一些工作。

下載檔案

我之前講ResponseBody的時候講了它的

  • byte()
  • string()

  • bytesStream()

  • charStream()

其中byte()和string()是一次讀取,用來獲取體積比較小的內容。但如果遇到大檔案的話,就應該用流的方式。
所謂下載也就是將伺服器返回的資料儲存在本地。

  • 當體積體積較小時,用byte()或者string()獲取內容。
  • 當體積很大時(超過1m),就應該用流的方式,用byteStream()或者charStream().

這裡我用流的方式演示從網路上下載一張圖片,然後儲存在本地,然後顯示出來。我是用bytesStream()方法。

private void testDownload(){
//網路上的一張圖
String url = "http://img4.cache.netease.com/photo/0026/2015-05-19/APVC513454A40026.jpg";
//圖片下載時儲存的地址
final File filePath = new File(getExternalCacheDir().toString(),"tmp.jpg");
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(url).build();
Call call = client.newCall(request);
call.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
}
@Override
public void onResponse(Call call, Response response) throws IOException {
if(response.isSuccessful()){
//獲取inputstream物件
InputStream is = response.body().byteStream();
FileOutputStream fos = new FileOutputStream(filePath);
int  b = 0;
while((b = is.read()) != -1){
fos.write(b);
}
fos.close();
//下載成功後載入圖片
final Bitmap bitmap = BitmapFactory.decodeFile(filePath.getAbsolutePath());
runOnUiThread(new Runnable() {
@Override
public void run() {
mImg.setImageBitmap(bitmap);
}
});
}
//關掉response.body
response.body().close();
}
});
}

效果如下圖:

這裡寫圖片描述

上傳檔案

說到下載功能就得說到上傳功能,這樣http訪問才完整。
因為沒有找到網路上現在的可以上傳呼叫的API,所以這部分還是要在我自己的電腦上編寫Php服務來驗證。

php程式碼編寫

php程式碼的上傳功能分為兩個部分。
1. 客戶端html傳送表單資料。
2. 服務端php程式通過$_FILES這個域變數來接收傳過來的檔案,然後移動檔案到指定目錄,整個過程就完成了。

html程式碼

檔名testupload.html

<html>
<head>
<title>test upload</title>
</head>
<body>
<form action="upload_file.php" method="post" enctype="multipart/form-data">
<label>filename:</label>
<input type="file" name="file" id="file" />
<br/>
<input type="submit" name="submit" value="submit">
</form>
</body>
</html>

重點在於這個標籤。

action定義到表單傳送的位置,這裡是upload_file.php,說明表單將會傳送到主機上的upload_file.php上。

method 中的方法是post。這個一定要寫對,檔案上傳的內容必須放在實體中,不能新增在header中,所以不能用get,要用post.

enctype,這個定義內容是 multipart/form-data.
enctype有兩個值的範圍。
* 一個是application/x-www-form-urlencoded(預設值),傳輸文字資訊
* 一個是multipart/form-data.傳輸二進位制資訊

標籤的 type=”file” 屬性規定了應該把輸入作為檔案來處理。舉例來說,當在瀏覽器中預覽時,會看到輸入框旁邊有一個瀏覽按鈕。

php程式碼

我直接就張貼程式碼了。我用的是Phpnow套件。解壓後就放在E盤。
然後把Php程式碼放在解壓的的目錄的package\hotdoc目錄下。
比如:E:\PHPnow-1.5.6.1428396605\Package\htdocs。前面的testupload.html也是放在這個目錄。不熟悉php的朋友可以直接copy我的程式碼,然後放在裡面。
如果熟悉Php或者j2ee的同學則自己進行模擬。

upload_file.php

<?php
//如果檔案上傳失敗。
if($_FILES['file']['error'] > 0){
echo "Return code: ".$_FILES['franktest']['error']."<br/>";
}else{
echo "Upload: ".$_FILES['file']['name']."<br/>";
//如果檔案已經存在伺服器
if(file_exists($_FILES['file']['name'])){
echo $_FILES['file']['name']." already exists.";
}else{
//移動臨時檔案到指定的路徑,上傳成功
move_uploaded_file($_FILES['file']['tmp_name'],$_FILES['file']['name']);
echo "upload successed";
}
}
?>

為了更清楚的說明上傳的原理,我用fiddler來抓包。不熟悉fiddler的同學可以自行上網查閱相關知識。
如下圖所演示:
這裡寫圖片描述

用fiddler抓取剛才的包可以得到下面訊息:

POST http://localhost/upload_file.php HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0
Accept: text/html,application/xhtml xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-cn,zh;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: http://localhost/upload.html
Connection: keep-alive
Content-Type: multipart/form-data; boundary=---------------------------178612565028255
Content-Length: 11185
-----------------------------178612565028255
Content-Disposition: form-data; name="file"; filename="test.png"
Content-Type: image/png
PNG
IHDR        )      sRGB       gAMA    
a     pHYs       o d  *"IDATx^  
a'p kf? Y#AJT 8   P  V  nd  #   Gl p D 
B^  Z   W $.-     *  B AG   KI  C; ~     [  G 7   t     
`z `z `z `z `z `z `z `z w E t s   P    IEND B` 
-----------------------------178612565028255
Content-Disposition: form-data; name="submit"
submit
-----------------------------178612565028255--

提取我們關心的內容則是:

Content-Type: multipart/form-data; boundary=---------------------------178612565028255
Content-Length: 11185
-----------------------------178612565028255
Content-Disposition: form-data; name="file"; filename="test.png"
Content-Type: image/png
PNG
IHDR        )      sRGB       gAMA    
a     pHYs       o d  *"IDATx^  
a'p kf? Y#AJT 8   P  V  nd  #   Gl p D 
B^  Z   W $.-     *  B AG   KI  C; ~     [  G 7   t     
`z `z `z `z `z `z `z `z w E t s   P    IEND B` 

Content-Type: multipart/form-data;

在這裡可以看到Content-Type果真是multipart/form-data,而後面的boundary=—————————178612565028255,boundary是分界線的意思,後面的數字是隨機數,用來分割實體。比如我上傳了一個文字一張圖片,它們之間就用這個來分割區別開來。

Content-Disposition

disposition的英文單詞是配置的意思,在這裡用來區分表單的內容,因為一個表單中有許多項,這裡為了說明這一段屬於哪一項。name=”file”是因為之前我的標籤中定義了name=”file”。filename=”test.png”代表我此次上傳的檔名字為test.png.
Content-Type:在Content-Disposition:後面是為了說明表單中某一項傳輸的內容格式。比如,我此次上傳的是一個圖片檔案test.png。所以它的值是image/png.如果我上傳的是一個文字檔案text.txt.則它的值是text/plain。
常見的Content-type值如下:
* .*(二進位制檔案,不知道格式的檔案) application/octet-stream
* .txt -> text/plain
* .png -> image/png
* .jpg -> image/jpeg
* .html -> text/html
* .mp4 -> video/mpeg
* .apk -> application/vnd.android.package-archive

更多的情況請訪問這個連結:
常用的html content-type對照表

Android用Okhttp上傳檔案程式碼

前面用一大段介紹了html上傳檔案流程。接下來就要編寫如何在Android上編寫上傳程式碼。

清楚了上傳的原理與流程,我們就可以用okhttp來模擬表單傳送訊息,從而達到上傳檔案的目的。

我們再把思路捋一捋。
1. 用http協議。
2. 新增相應的header.這裡指Content-type:multipart/form-data
3. 在表單項的實體中新增對應的內容描述。Content-Disposition:form-data; name=”file”; filename=”test.png”和Content-Type: image/png在這裡Content-Type:application/octet-stream的話可以傳輸任何檔案。
4. 新增具體的實體資料。

好了,現在假設我們要用Android手機上傳一張圖片到伺服器。程式碼如下:

private void testUpload(){
//這個地址是我PC機上的IP地址,我用的是genimotion模擬器,如果用手機要保證手機和pc在同一個區域網內
String url = "http://172.26.133.50//upload_file.php";
File file = new File(Environment.getExternalStorageDirectory().toString(),"test.png");
//建立Okhttp客戶端
OkHttpClient  client = new OkHttpClient();
//定義MIME型別
MediaType mediaType = MediaType.parse("application/octet-stream");
//建立代表檔案的實體
RequestBody fileBody = RequestBody.create(mediaType,file);
//建立表單實體,並把檔案實體新增到表單實體當中
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addPart(Headers.of("Content-Disposition","form-data;name=\"file\";"  
"filename=\"test.png\""),fileBody)
.build();
//建立request物件,並把實體以Post方式傳送給伺服器
Request request = new Request.Builder()
.url(url)
.post(requestBody)
.build();
//建立Call物件。
final Call call = client.newCall(request);
new Thread(new Runnable() {
@Override
public void run() {
try {
Response response = call.execute();
if(response.isSuccessful()){
Log.d(TAG, "run: upload is successed.");
}else{
Log.d(TAG, "run: upload is failed ");
}
response.close();
} catch (IOException e) {
e.printStackTrace();
Log.e(TAG, "run: " e.getLocalizedMessage() );
}
}
}).start();
}

大家對照我前面所講的,細細體會一下。

這裡運用了一個知識點MultiPartBody.

MultipartBody.Builder can build sophisticated request bodies compatible with HTML file upload forms. Each part of a multipart request body is itself a request body, and can define its own headers. If present, these headers should describe the part body, such as its Content-Disposition. The Content-Length and Content-Type headers are added automatically if they’re available.

這是官網上的說明。
MuiltipartBody.Builder可以構建一個html的檔案上傳表單這樣的複雜的網路請求訊息實體(request body).
注意它能夠構造複雜的訊息實體。複雜在於它包含的內容也可以由RequestBody構成,在Okhttp中稱為Part.
如我可以同時傳送一段文字、一張圖片、一個Mp4檔案給伺服器,它們被MultipartBody封裝在同一個表單,然後進行post請求。
每一個part,也就是每一個實體都可以定義自己的headers。目前,這些被包含在form-data中的訊息實體應該有描述了Content-Disosition的header。當然,Okhttp會自動新增它的Content-Length屬性。

下面是精簡的MultipartBody原始碼

public final class MultipartBody extends RequestBody {
public static final MediaType MIXED = MediaType.parse("multipart/mixed");
public static final MediaType ALTERNATIVE = MediaType.parse("multipart/alternative");
public static final MediaType DIGEST = MediaType.parse("multipart/digest");
public static final MediaType PARALLEL = MediaType.parse("multipart/parallel");
/**
* The media-type multipart/form-data follows the rules of all multipart MIME data streams as
* outlined in RFC 2046. In forms, there are a series of fields to be supplied by the user who
* fills out the form. Each field has a name. Within a given form, the names are unique.
*/
public static final MediaType FORM = MediaType.parse("multipart/form-data");
private final ByteString boundary;
private final MediaType originalType;
private final MediaType contentType;
private final List<Part> parts;
private long contentLength = -1L;
MultipartBody(ByteString boundary, MediaType type, List<Part> parts) {
this.boundary = boundary;
this.originalType = type;
this.contentType = MediaType.parse(type   "; boundary="   boundary.utf8());
this.parts = Util.immutableList(parts);
}
public List<Part> parts() {
return parts;
}
public Part part(int index) {
return parts.get(index);
}
/** A combination of {@link #type()} and {@link #boundary()}. */
@Override public MediaType contentType() {
return contentType;
}
@Override public long contentLength() throws IOException {
long result = contentLength;
if (result != -1L) return result;
return contentLength = writeOrCountBytes(null, true);
}
//Part是它的內部靜態類
public static final class Part {
public static Part create(RequestBody body) {
return create(null, body);
}
private final Headers headers;
private final RequestBody body;
private Part(Headers headers, RequestBody body) {
this.headers = headers;
this.body = body;
}
}
//MultiPartBody通過內部的Builder物件構建
public static final class Builder {
private final ByteString boundary;
private MediaType type = MIXED;
private final List<Part> parts = new ArrayList<>();
public Builder() {
this(UUID.randomUUID().toString());
}
public Builder(String boundary) {
this.boundary = ByteString.encodeUtf8(boundary);
}
/**
* Set the MIME type. Expected values for {@code type} are {@link #MIXED} (the default), {@link
* #ALTERNATIVE}, {@link #DIGEST}, {@link #PARALLEL} and {@link #FORM}.
*/
public Builder setType(MediaType type) {
if (type == null) {
throw new NullPointerException("type == null");
}
if (!type.type().equals("multipart")) {
throw new IllegalArgumentException("multipart != "   type);
}
this.type = type;
return this;
}
/** Add a part to the body. */
public Builder addPart(RequestBody body) {
return addPart(Part.create(body));
}
/** Add a part to the body. */
public Builder addPart(Headers headers, RequestBody body) {
return addPart(Part.create(headers, body));
}
/** Add a form data part to the body. */
public Builder addFormDataPart(String name, String value) {
return addPart(Part.createFormData(name, value));
}
/** Add a form data part to the body. */
public Builder addFormDataPart(String name, String filename, RequestBody body) {
return addPart(Part.createFormData(name, filename, body));
}
/** Add a part to the body. */
public Builder addPart(Part part) {
if (part == null) throw new NullPointerException("part == null");
parts.add(part);
return this;
}
/** Assemble the specified parts into a request body. */
public MultipartBody build() {
if (parts.isEmpty()) {
throw new IllegalStateException("Multipart body must have at least one part.");
}
return new MultipartBody(boundary, type, parts);
}
}
}

程式碼不多。主要使用步驟。
1. new MultiPartBody.Builder()建立Builder物件。
2. addPart()或者addFormDataPart()新增檔案或者是表單資料。
3. 然後呼叫build()方法生成MultiPartBody物件。
4. 呼叫Requst物件的post()方法,訪問遠端服務。

攔截器(Interceptors)

攔截器是一個強大的機制,它能對Call進行監測、改寫、重試連線。它能夠對請求和回覆進行二次加工。

OKHTTP中的攔截器是鏈式的這個跟MINA框架中的攔截器類似。

攔截器的作用之log

下面是官網的一個例子。

class LoggingInterceptor implements Interceptor {
@Override public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
long t1 = System.nanoTime();
Log.i(TAG, "intercept: " String.format("Sending request %s on %s%n%s",
request.url(), chain.connection(), request.headers()));
Response response = chain.proceed(request);
long t2 = System.nanoTime();
Log.i(TAG, "intercept: " String.format("Received response for %s in %.1fms%n%s",
response.request().url(), (t2 - t1) / 1e6d, response.headers()));
return response;
}
}

這段程式碼的主要功能是列印Request和Response的相關資訊,比如url地址,比如路由,比如headers.

下面我們來編寫程式碼測試一下。

在編寫程式碼之前,先介紹兩個概念就是應用攔截器(Application Interceptors)和網路攔截器(Network Interceptors).
Interceptors要麼被當成Application Interceptors註冊,要麼被當成Network Interceptors註冊。

應用攔截器(Application Interceptors)

如果當成應用攔截器新增的話,那麼要在OkHttpClient.Builder中的addInterceptors()方法中新增。

private void testInterceptors(){
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(new LoggingInterceptor())
.build();
String url = "http://blog.csdn.net/briblue";
Request request = new Request.Builder()
.url(url)
.addHeader("name","frank")
.build();
final Call call = client.newCall(request);
new Thread(new Runnable() {
@Override
public void run() {
try {
Response response = call.execute();
response.body().close();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}

我在請求中人為中新增了一個頭資訊,這個資訊只用來演示,沒有實際意義。結果Log資訊如下:

I/SeniorActivity: intercept: Sending request http://blog.csdn.net/briblue on null
name: frank
I/SeniorActivity: intercept: Received response for http://blog.csdn.net/briblue in 164.0ms
Server: openresty
Date: Mon, 24 Oct 2016 04:03:57 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Keep-Alive: timeout=20
Vary: Accept-Encoding
Cache-Control: private
Set-Cookie: uuid=1ffd7e7e-6bed-41c3-b48c-f61f915f02f8;
expires=Tue, 25-Oct-2016 04:03:57 GMT; path=/
X-Powered-By: PHP 5.4.28

可以看到它詳細列印了Request和Response的的一些資訊。

那麼我們再看NetworkInterceptors

網路攔截器(Network Interceptors)

上面講了一個攔截器被當成Application Interceptors註冊到了Okhttpclient.同時,一個攔截器也可以當成Netowork Interceptors註冊到Okhttpclient.呼叫的是它的Builder物件的addNetworkInterceptor.程式碼區別不大。

private void testNetworkInterceptors(){
OkHttpClient client = new OkHttpClient.Builder()
//不同於Application Interceptor中addInterceptor()
.addNetworkInterceptor(new LoggingInterceptor())
.build();
String url = "http://blog.csdn.net/briblue";
Request request = new Request.Builder()
.url(url)
.addHeader("name","frank")
.build();
final Call call = client.newCall(request);
new Thread(new Runnable() {
@Override
public void run() {
try {
Response response = call.execute();
response.body().close();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}

而log資訊,也是完全一樣的。說到這裡大家可能有些迷惑。其實是這樣的,NetworkInterceptor比Application列印更詳盡的資訊。我舉的例子中http://blog.csdn.net/briblue沒有進行重定向。如果我把上面例子中的url換成是http:www.github.com來進行測試的話。情況大有不同。


I/SeniorActivity: intercept: Sending request http://www.github.com/ on Connection{www.github.com:80, proxy=DIRECT hostAddress=www.github.com/192.30.253.113:80 cipherSuite=none protocol=http/1.1}
name: frank
host: www.github.com
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.4.1
I/SeniorActivity: intercept: Received response for http://www.github.com/ in 1040.4ms
Content-length: 0
Location: https://www.github.com/
Connection: close
I/SeniorActivity: intercept: Sending request https://www.github.com/ on Connection{www.github.com:443, proxy=DIRECT hostAddress=www.github.com/192.30.253.113:443 cipherSuite=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 protocol=http/1.1}
name: frank
Host: www.github.com
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.4.1
I/SeniorActivity: intercept: Received response for https://www.github.com/ in 703.6ms
Content-length: 0
Location: https://github.com/
Connection: close
I/SeniorActivity: intercept: Sending request https://github.com/ on Connection{github.com:443, proxy=DIRECT hostAddress=github.com/192.30.253.113:443 cipherSuite=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 protocol=http/1.1}
name: frank
Host: github.com
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.4.1
I/SeniorActivity: intercept: Received response for https://github.com/ in 524.6ms
Server: GitHub.com
Date: Mon, 24 Oct 2016 06:17:32 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Status: 200 OK
Cache-Control: no-cache
Vary: X-PJAX
X-UA-Compatible: IE=Edge,chrome=1
Set-Cookie: logged_in=no; domain=.github.com; path=/; expires=Fri, 24 Oct 2036 06:17:32 -0000; secure; HttpOnly
Set-Cookie: _gh_sess=eyJzZXNzaW9uX2lkIjoiNWJhMWZkYTUyYjFhMmVmZWM1OTc5ZTUzNGE0ZTQ4OTMiLCJfY3NyZl90b2tlbiI6IlZXNGVTTVhSRHhiOVlWL2E5anEwSHFxYXBOcFd3OHFvcHJtZjI5c05FOVk9In0%3D--64b03a16a3f1bf8b15208b101702a02a0419d9ec; path=/; secure; HttpOnly
X-Request-Id: 9eaae01ca7d4e264de21da3d1a1c36e7
X-Runtime: 0.010049
Content-Security-Policy: default-src 'none'; base-uri 'self'; block-all-mixed-content; child-src render.githubusercontent.com; connect-src 'self' uploads.github.com status.github.com api.github.com www.google-analytics.com github-cloud.s3.amazonaws.com wss://live.github.com; font-src assets-cdn.github.com; form-action 'self' github.com gist.github.com; frame-ancestors 'none'; frame-src render.githubusercontent.com; img-src 'self' data: assets-cdn.github.com identicons.github.com collector.githubapp.com github-cloud.s3.amazonaws.com *.githubusercontent.com; media-src 'none'; script-src assets-cdn.github.com; style-src 'unsafe-inline' assets-cdn.github.com
Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
Public-Key-Pins: max-age=5184000; 
X-Content-Type-Options: nosniff
X-Served-By: a22dbcbd09a98eacdd14ac7804a635dd
Content-Encoding: gzip
X-GitHub-Request-Id: 0E17AF3E:6C7B:67BF0A4:580DA77B

可以看到我們原本是訪問http://www.github.com,但是它內部重定向到https://www.github.com.
但是新增NetworkInterceptor追蹤到了它的狀態。並且NetworkInterceptror能夠列印的資訊更多。比如Content-Encoding:gzip。

Application Interceptor還是NetworkInterceptor?

它們各有優點。

Application interceptor特點
  • 不必關心url的重定向和重連。
  • 只執行一次,即使Resopnse是來自於快取。
  • 只關心request的原始意圖,而不用關心額外新增的Header資訊如If-None-Match
NetworkInterceptor的特點
  • 能夠詳盡地追蹤訪問連結的重定向。
  • 短時間內的網路訪問,它將不執行快取過來的迴應。
  • 監測整個網路訪問過程中的資料流向。

實際開發中,大家可以根據自己的需求新增相應的Interceptor.

攔截器作用之壓縮資料

因為攔截器可以拿到請求的資料,和迴應的資料,所以基本上它能做任何事。比如我們可以在這裡攔截一些不符合特定場景的請求。比如我們可以在迴應中校驗資料的完整性。比如為了節省頻寬,我們可以將資料進行gzip壓縮排行資料傳送,然後在Response中解壓,一切都神不知鬼不覺的。下面的例子來自官網,講得是一個如何定義一個壓縮資料功能的攔截器。

/** This interceptor compresses the HTTP request body. Many webservers can't handle this! */
final class GzipRequestInterceptor implements Interceptor {
@Override public Response intercept(Interceptor.Chain chain) throws IOException {
Request originalRequest = chain.request();
if (originalRequest.body() == null || originalRequest.header("Content-Encoding") != null) {
return chain.proceed(originalRequest);
}
Request compressedRequest = originalRequest.newBuilder()
.header("Content-Encoding", "gzip")
.method(originalRequest.method(), gzip(originalRequest.body()))
.build();
return chain.proceed(compressedRequest);
}
private RequestBody gzip(final RequestBody body) {
return new RequestBody() {
@Override public MediaType contentType() {
return body.contentType();
}
@Override public long contentLength() {
return -1; // We don't know the compressed length in advance!
}
@Override public void writeTo(BufferedSink sink) throws IOException {
BufferedSink gzipSink = Okio.buffer(new GzipSink(sink));
body.writeTo(gzipSink);
gzipSink.close();
}
};
}
}

接下來,我們要講OKHttp開發中一個很重要和實用的功能,快取。但因為篇幅有限,我新開一篇文章來講解。