Okhttp 使用詳解

發表於2016-07-14

在Android開發中,傳送HTTP請求是很常見的。SDK中自帶的HttpURLConnection雖然能基本滿足需求,但是在使用上有諸多不便,為此,square公司實現了一個HTTP客戶端的類庫——Okhttp 。

Okhttp是一個支援HTTP 和 HTTP/2 的客戶端,可以在Android和Java應用程式中使用,其具有以下特點:

1. API設計輕巧,基本上通過幾行程式碼的鏈式呼叫即可獲取結果。
2. 既支援同步請求,也支援非同步請求。同步請求會阻塞當前執行緒,非同步請求不會阻塞當前執行緒,非同步執行完成後執行相應的回撥方法。
3. 其支援HTTP/2協議,通過HTTP/2,可以讓客戶端中到同一伺服器的所有請求共用同一個Socket連線。
4. 如果請求不支援HTTP/2協議,那麼Okhttp會在內部維護一個連線池, 通過該連線池,可以對HTTP/1.x的連線進行重用,減少了延遲。
5. 透明的GZIP處理降低了下載資料的大小。
6. 請求的資料會進行相應的快取處理,下次再進行請求時,如果伺服器告知304(表明資料沒有發生變化),那麼就直接從快取中讀取資料,降低了重複請求的數量。

當前Okhttp最新的版本是3.x,支援Android 2.3+。要想在Java應用程式中使用Okhttp,JRE的最低版本要求是1.7。

Okhttp API: http://square.github.io/okhttp/3.x/okhttp/

本文中的程式碼來自於Okhttp的官方Wiki中的示例程式碼。


下載Okhttp

可以點此下載最新的Okhttp的jar包,也可以通過Maven獲取:

也可以在Gradle中進行如下配置以便在Android Studio中使用:


核心類

我們在使用Okhttp進行開發的時候,主要牽扯到以下幾個核心類:OkHttpClient、Request、Call 和 Response。

  • OkHttpClient
    OkHttpClient表示了HTTP請求的客戶端類,在絕大多數的App中,我們只應該執行一次new OkHttpClient(),將其作為全域性的例項進行儲存,從而在App的各處都只使用這一個例項物件,這樣所有的HTTP請求都可以共用Response快取、共用執行緒池以及共用連線池。
    • 預設情況下,直接執行OkHttpClient client = new OkHttpClient()就可以例項化一個OkHttpClient物件。
    • 可以配置OkHttpClient的一些引數,比如超時時間、快取目錄、代理、Authenticator等,那麼就需要用到內部類OkHttpClient.Builder,設定如下所示:

  • OkHttpClient本身不能設定引數,需要藉助於其內部類Builder設定引數,引數設定完成後,呼叫Builder的build方法得到一個配置好引數的OkHttpClient物件。這些配置的引數會對該OkHttpClient物件所生成的所有HTTP請求都有影響。
  • 有時候我們想單獨給某個網路請求設定特別的幾個引數,比如只想讓某個請求的超時時間設定為一分鐘,但是還想保持OkHttpClient物件中的其他的引數設定,那麼可以呼叫OkHttpClient物件的newBuilder()方法,程式碼如下所示:

clientWith60sTimeout中的引數來自於client中的配置引數,只不過它覆蓋了讀取超時時間這一個引數,其餘引數與client中的一致。

  • Request
    Request類封裝了請求報文資訊:請求的Url地址、請求的方法(如GET、POST等)、各種請求頭(如Content-Type、Cookie)以及可選的請求體。一般通過內部類Request.Builder的鏈式呼叫生成Request物件。
  • Call
    Call代表了一個實際的HTTP請求,它是連線Request和Response的橋樑,通過Request物件的newCall()方法可以得到一個Call物件。Call物件既支援同步獲取資料,也可以非同步獲取資料。
    • 執行Call物件的execute()方法,會阻塞當前執行緒去獲取資料,該方法返回一個Response物件。
    • 執行Call物件的enqueue()方法,不會阻塞當前執行緒,該方法接收一個Callback物件,當非同步獲取到資料之後,會回撥執行Callback物件的相應方法。如果請求成功,則執行Callback物件的onResponse方法,並將Response物件傳入該方法中;如果請求失敗,則執行Callback物件的onFailure方法。
  • Response
    Response類封裝了響應報文資訊:狀態嗎(200、404等)、響應頭(Content-Type、Server等)以及可選的響應體。可以通過Call物件的execute()方法獲得Response物件,非同步回撥執行Callback物件的onResponse方法時也可以獲取Response物件。

同步GET

以下示例演示瞭如何同步傳送GET請求,輸出響應頭以及將響應體轉換為字串。

下面對以上程式碼進行簡單說明:

  • client執行newCall方法會得到一個Call物件,表示一個新的網路請求。
  • Call物件的execute方法是同步方法,會阻塞當前執行緒,其返回Response物件。
  • 通過Response物件的isSuccessful()方法可以判斷請求是否成功。
  • 通過Response的headers()方法可以得到響應頭Headers物件,可以通過for迴圈索引遍歷所有的響應頭的名稱和值。可以通過Headers.name(index)方法獲取響應頭的名稱,通過Headers.value(index)方法獲取響應頭的值。
  • 除了索引遍歷,通過Headers.get(headerName)方法也可以獲取某個響應頭的值,比如通過headers.get(“Content-Type”)獲得伺服器返回給客戶端的資料型別。但是伺服器返回給客戶端的響應頭中有可能有多個重複名稱的響應頭,比如在某個請求中,伺服器要向客戶端設定多個Cookie,那麼會寫入多個Set-Cookie響應頭,且這些Set-Cookie響應頭的值是不同的,訪問百度首頁,可以看到有7個Set-Cookie的響應頭,如下圖所示:

20160614225714171

  • 為了解決同時獲取多個name相同的響應頭的值,Headers中提供了一個public List<String> values(String name)方法,該方法會返回一個List<String>物件,所以此處通過Headers物件的values(‘Set-Cookie’)可以獲取全部的Cookie資訊,如果呼叫Headers物件的get(‘Set-Cookie’)方法,那麼只會獲取最後一條Cookie資訊。
  • 通過Response物件的body()方法可以得到響應體ResponseBody物件,呼叫其string()方法可以很方便地將響應體中的資料轉換為字串,該方法會將所有的資料放入到記憶體之中,所以如果資料超過1M,最好不要呼叫string()方法以避免佔用過多記憶體,這種情況下可以考慮將資料當做Stream流處理。

非同步GET

以下示例演示瞭如何非同步傳送GET網路請求,程式碼如下所示:

下面對以上程式碼進行一下說明:

  • 要想非同步執行網路請求,需要執行Call物件的enqueue方法,該方法接收一個okhttp3.Callback物件,enqueue方法不會阻塞當前執行緒,會新開一個工作執行緒,讓實際的網路請求在工作執行緒中執行。一般情況下這個工作執行緒的名字以“Okhttp”開頭,幷包含連線的host資訊,比如上面例子中的工作執行緒的name是"Okhttp http://publicobject.com/..."
  • 當非同步請求成功後,會回撥Callback物件的onResponse方法,在該方法中可以獲取Response物件。當非同步請求失敗或者呼叫了Call物件的cancel方法時,會回撥Callback物件的onFailure方法。onResponse和onFailure這兩個方法都是在工作執行緒中執行的。

請求頭和響應頭

典型的HTTP請求頭、響應頭都是類似於Map<String, String>,每個name對應一個value值。不過像我們之前提到的,也會存在多個name重複的情況,比如相應結果中就有可能存在多個Set-Cookie響應頭,同樣的,也可能同時存在多個名稱一樣的請求頭。響應頭的讀取我們在上文已經說過了,在此不再贅述。一般情況下,我們只需要呼叫header(name, value)方法就可以設定請求頭的name和value,呼叫該方法會確保整個請求頭中不會存在多個名稱一樣的name。如果想新增多個name相同的請求頭,應該呼叫addHeader(name, value)方法,這樣可以新增重複name的請求頭,其value可以不同,例如如下所示:

上面的程式碼通過addHeader方法新增了兩個Accept請求頭,且二者的值不同,這樣伺服器收到客戶端發來的請求後,就知道客戶端既支援application/json型別的資料,也支援application/vnd.github.v3+json型別的資料。


用POST傳送String

可以使用POST方法傳送請求體。下面的示例演示瞭如何將markdown文字作為請求體傳送到伺服器,伺服器會將其轉換成html文件,併傳送給客戶端。

下面對以上程式碼進行說明:

  • Request.Builderpost方法接收一個RequestBody物件。
  • RequestBody就是請求體,一般可通過呼叫該類的5個過載的static的create()方法得到RequestBody物件。create()方法第一個引數都是MediaType型別,create()方法的第二個引數可以是String、File、byte[]或okio.ByteString。除了呼叫create()方法,還可以呼叫RequestBody的writeTo()方法向其寫入資料,writeTo()方法一般在用POST傳送Stream流的時候使用。
  • MediaType指的是要傳遞的資料的MIME型別,MediaType物件包含了三種資訊:type、subtype以及CharSet,一般將這些資訊傳入parse()方法中,這樣就能解析出MediaType物件,比如在上例中text/x-markdown; charset=utf-8,type值是text,表示是文字這一大類;/後面的x-markdown是subtype,表示是文字這一大類下的markdown這一小類;charset=utf-8則表示採用UTF-8編碼。如果不知道某種型別資料的MIME型別,可以參見連線Media TypesMIME 參考手冊,較詳細地列出了所有的資料的MIME型別。以下是幾種常見資料的MIME型別值:
    • json :application/json
    • xml:application/xml
    • png:image/png
    • jpg: image/jpeg
    • gif:image/gif
  • 在該例中,請求體會放置在記憶體中,所以應該避免用該API傳送超過1M的資料。

用POST傳送Stream流

下面的示例演示瞭如何使用POST傳送Stream流。

下面對以上程式碼進行說明:

  • 以上程式碼在例項化RequestBody物件的時候重寫了contentType()writeTo()方法。
  • 覆寫contentType()方法,返回markdown型別的MediaType。
  • 覆寫writeTo()方法,該方法會傳入一個OkiaBufferedSink型別的物件,可以通過BufferedSink的各種write方法向其寫入各種型別的資料,此例中用其writeUtf8方法向其中寫入UTF-8的文字資料。也可以通過它的outputStream()方法,得到輸出流OutputStream,從而通過OutputSteram向BufferedSink寫入資料。

用POST傳送File

下面的程式碼演示瞭如何用POST傳送檔案。

我們之前提到,RequestBody.create()靜態方法可以接收File引數,將File轉換成請求體,將其傳遞給post()方法。


用POST傳送Form表單中的鍵值對

如果想用POST傳送鍵值對字串,可以使用在post()方法中傳入FormBody物件,FormBody繼承自RequestBody,類似於Web前端中的Form表單。可以通過FormBody.Builder構建FormBody

示例程式碼如下所示:

需要注意的是,在傳送資料之前,Android會對非ASCII碼字元呼叫encodeURIComponent方法進行編碼,例如”Jurassic Park”會編碼成”Jurassic%20Park”,其中的空格符被編碼成%20了,伺服器端會其自動解碼。


用POST傳送multipart資料

我們可以通過Web前端的Form表單上傳一個或多個檔案,Okhttp也提供了對應的功能,如果我們想同時傳送多個Form表單形式的檔案,就可以使用在post()方法中傳入MultipartBody物件。MultipartBody繼承自RequestBody,也表示請求體。只不過MultipartBody的內部是由多個part組成的,每個part就單獨包含了一個RequestBody請求體,所以可以把MultipartBody看成是一個RequestBody的陣列,而且可以分別給每個RequestBody單獨設定請求頭。

示例程式碼如下所示:

下面對以上程式碼進行說明:


用Gson處理JSON響應

Gson是Google開源的一個用於進行JSON處理的Java庫,通過Gson可以很方面地在JSON和Java物件之間進行轉換。我們可以將Okhttp和Gson一起使用,用Gson解析返回的JSON結果。

下面的示例程式碼演示瞭如何使用Gson解析GitHub API的返回結果。

下面對以上程式碼進行說明:

20160619164617446

  • Gist類對應著整個JSON的返回結果,Gist中的Map<String, GistFile> files對應著JSON中的files
  • files中的每一個元素都是一個key-value的鍵值對,key是String型別,value是GistFile型別,並且GistFile中必須包含一個String contentOkHttp.txt就對應著一個GistFile物件,其content值就是GistFile中的content。
  • 通過程式碼Gist gist = gson.fromJson(response.body().charStream(), Gist.class),將JSON字串轉換成了Java物件。將ResponseBodycharStream方法返回的Reader傳給GsonfromJson方法,然後傳入要轉換的Java類的class。

快取響應結果

如果想快取響應結果,我們就需要為Okhttp配置快取目錄,Okhttp可以寫入和讀取該快取目錄,並且我們需要限制該快取目錄的大小。Okhttp的快取目錄應該是私有的,不能被其他應用訪問。

Okhttp中,多個快取例項同時訪問同一個快取目錄會出錯,大部分的應用只應該呼叫一次new OkHttpClient(),然後為其配置快取目錄,然後在App的各個地方都使用這一個OkHttpClient例項物件,否則兩個快取例項會互相影響,導致App崩潰。

快取示例程式碼如下所示:

下面對以上程式碼進行說明:

  • 我們在App的cache目錄下建立了一個子目錄okhttp,將其作為Okhttp專門用於快取的目錄,並設定其上限為10M,Okhttp需要能夠讀寫該目錄。
  • 不要讓多個快取例項同時訪問同一個快取目錄,因為多個快取例項會相互影響,導致出錯,甚至系統崩潰。在絕大多數的App中,我們只應該執行一次new OkHttpClient(),將其作為全域性的例項進行儲存,從而在App的各處都只使用這一個例項物件,這樣所有的HTTP請求都可以共用Response快取。
  • 上面程式碼,我們對於同一個URL,我們先後傳送了兩個HTTP請求。第一次請求完成後,Okhttp將請求到的結果寫入到了快取目錄中,進行了快取。response1.networkResponse()返回了實際的資料,response1.cacheResponse()返回了null,這說明第一次HTTP請求的得到的響應是通過傳送實際的網路請求,而不是來自於快取。然後對同一個URL進行了第二次HTTP請求,response2.networkResponse()返回了null,response2.cacheResponse()返回了快取資料,這說明第二次HTTP請求得到的響應來自於快取,而不是網路請求。
  • 如果想讓某次請求禁用快取,可以呼叫request.cacheControl(CacheControl.FORCE_NETWORK)方法,這樣即便快取目錄有對應的快取,也會忽略快取,強制傳送網路請求,這對於獲取最新的響應結果很有用。如果想強制某次請求使用快取的結果,可以呼叫request.cacheControl(CacheControl.FORCE_CACHE),這樣不會傳送實際的網路請求,而是讀取快取,即便快取資料過期了,也會強制使用該快取作為響應資料,如果快取不存在,那麼就返回504 Unsatisfiable Request錯誤。也可以向請求中中加入類似於Cache-Control: max-stale=3600之類的請求頭,Okhttp也會使用該配置對快取進行處理。

取消請求

當請求不再需要的時候,我們應該中止請求,比如退出當前的Activity了,那麼在Activity中發出的請求應該被中止。可以通過呼叫Call的cancel方法立即中止請求,如果執行緒正在寫入Request或讀取Response,那麼會丟擲IOException異常。同步請求和非同步請求都可以被取消。

示例程式碼如下所示:

上述請求,伺服器端會有兩秒的延時,在客戶端發出請求1秒之後,請求還未完成,這時候通過cancel方法中止了Call,請求中斷,並觸發IOException異常。


設定超時

一次HTTP請求實際上可以分為三步:

  1. 客戶端與伺服器建立連線
  2. 客戶端傳送請求資料到伺服器,即資料上傳
  3. 伺服器將響應資料傳送給客戶端,即資料下載

由於網路、伺服器等各種原因,這三步中的每一步都有可能耗費很長時間,導致整個HTTP請求花費很長時間或不能完成。

為此,可以通過OkHttpClient.BuilderconnectTimeout()方法設定客戶端和伺服器建立連線的超時時間,通過writeTimeout()方法設定客戶端上傳資料到伺服器的超時時間,通過readTimeout()方法設定客戶端從伺服器下載響應資料的超時時間。

在2.5.0版本之前,Okhttp預設不設定任何的超時時間,從2.5.0版本開始,Okhttp將連線超時、寫入超時(上傳資料)、讀取超時(下載資料)的超時時間都預設設定為10秒。如果HTTP請求需要更長時間,那麼需要我們手動設定超時時間。

示例程式碼如下所示:

如果HTTP請求的某一部分超時了,那麼就會觸發異常。


處理身份驗證

有些網路請求是需要使用者名稱密碼登入的,如果沒提供登入需要的資訊,那麼會得到401 Not Authorized未授權的錯誤,這時候Okhttp會自動查詢是否配置了Authenticator,如果配置過Authenticator,會用Authenticator中包含的登入相關的資訊構建一個新的Request,嘗試再次傳送HTTP請求。

示例程式碼如下所示:

上面對以上程式碼進行說明:

  • OkHttpClient.Builderauthenticator()方法接收一個Authenticator物件,我們需要實現Authenticator物件的authenticate()方法,該方法需要返回一個新的Request物件,該新的Request物件基於原始的Request物件進行拷貝,而且要通過header("Authorization", credential)方法對其設定登入授權相關的請求頭資訊。
  • 通過Response物件的challenges()方法可以得到第一次請求失敗的授權相關的資訊。如果響應碼是401 unauthorized,那麼會返回”WWW-Authenticate”相關資訊,這種情況下,要執行OkHttpClient.Builderauthenticator()方法,在Authenticator物件的authenticate()中 對新的Request物件呼叫header("Authorization", credential)方法,設定其Authorization請求頭;如果Response的響應碼是407 proxy unauthorized,那麼會返回”Proxy-Authenticate”相關資訊,表示不是最終的伺服器要求客戶端登入授權資訊,而是客戶端和伺服器之間的代理伺服器要求客戶端登入授權資訊,這時候要執行OkHttpClient.BuilderproxyAuthenticator()方法,在Authenticator物件的authenticate()中 對新的Request物件呼叫header("Proxy-Authorization", credential)方法,設定其Proxy-Authorization請求頭。
  • 如果使用者名稱密碼有問題,那麼Okhttp會一直用這個錯誤的登入資訊嘗試登入,我們應該判斷如果之前已經用該使用者名稱密碼登入失敗了,就不應該再次登入,這種情況下需要讓Authenticator物件的authenticate()方法返回null,這就避免了沒必要的重複嘗試,程式碼片段如下所示:


ResponseBody

通過Response的body()方法可以得到響應體ResponseBody,響應體必須最終要被關閉,否則會導致資源洩露、App執行變慢甚至崩潰。

ResponseBody和Response都實現了CloseableAutoCloseable介面,它們都有close()方法,Response的close()方法內部直接呼叫了ResponseBody的close()方法,無論是同步呼叫execute()還是非同步回撥onResponse(),最終都需要關閉響應體,可以通過如下方法關閉響應體:

  • Response.close()
  • Response.body().close()
  • Response.body().source().close()
  • Response.body().charStream().close()
  • Response.body().byteString().close()
  • Response.body().bytes()
  • Response.body().string()

對於同步呼叫,確保響應體被關閉的最簡單的方式是使用try程式碼塊,如下所示:

Response response = call.execute()放入到try()的括號之中,由於Response 實現了CloseableAutoCloseable介面,這樣對於編譯器來說,會隱式地插入finally程式碼塊,編譯器會在該隱式的finally程式碼塊中執行Response的close()方法。

也可以在非同步回撥方法onResponse()中,執行類似的try程式碼塊,try()程式碼塊括號中的ResponseBody也實現了CloseableAutoCloseable介面,這樣編譯器也會在隱式的finally程式碼塊中自動關閉響應體,程式碼如下所示:

響應體中的資料有可能很大,應該只讀取一次響應體的資料。呼叫ResponseBody的bytes()string()方法會將整個響應體資料寫入到記憶體中,可以通過source()byteStream()charStream()進行流式處理。

參考:
http://square.github.io/okhttp/3.x/okhttp/
https://github.com/square/okhttp/wiki/Recipes
https://github.com/square/okhttp/blob/master/CHANGELOG.md