Android 網絡(三) HttpURLConnection OkHttp

參考
Android網絡請求心路歷程
Android Http接地氣網絡請求(HttpURLConnection)

一、對比

參考okhttp,retrofit,android-async-http,volley應該選擇哪一個

  • HttpURLConnection
    HttpURLConnection是一種多用途、輕量極的HTTP客戶端,使用它來進行HTTP操作可以適用于大多數的應用程序。雖然HttpURLConnection的API提供的比較簡單,但是同時這也使得我們可以更加容易地去使用和擴展它。從Android4.4開始HttpURLConnection的底層實現采用的是okHttp。

  • HttpClient
    Apache HttpClient早就不推薦httpclient,5.0之后干脆廢棄,后續會刪除。6.0刪除了HttpClient。

  • OkHttp
    okhttp是高性能的http庫,支持同步、異步,而且實現了spdy、http2、websocket協議,api很簡潔易用,和volley一樣實現了http協議的緩存。picasso就是利用okhttp的緩存機制實現其文件緩存,實現的很優雅,很正確,反例就是UIL(universal image loader),自己做的文件緩存,而且不遵守http緩存機制。

  • volley
    volley是一個簡單的異步http庫,僅此而已。缺點是不支持同步,這點會限制開發模式。自帶緩存,支持自定義請求。不適合大文件上傳和下載。
    Volley在Android 2.3及以上版本,使用的是HttpURLConnection,而在Android 2.2及以下版本,使用的是HttpClient。
    Volley自己的定位是輕量級網絡交互,適合大量的,小數據傳輸。
    不過再怎么封裝Volley在功能拓展性上始終無法與OkHttp相比。Volley停止了更新,而OkHttp得到了官方的認可,并在不斷優化。

  • android-async-http。
    與volley一樣是異步網絡庫,但volley是封裝的httpUrlConnection,它是封裝的httpClient,而android平臺不推薦用HttpClient了,所以這個庫已經不適合android平臺了。

  • Retrofit
    Retrofit 是一個 RESTful 的 HTTP 網絡請求框架的封裝。注意這里并沒有說它是網絡請求框架,主要原因在于網絡請求的工作并不是 Retrofit 來完成的。Retrofit 2.0 開始內置 OkHttp,前者專注于接口的封裝,后者專注于網絡請求的高效,二者分工協作,宛如古人的『你耕地來我織布』,小日子別提多幸福了。參考深入淺出 Retrofit
    retrofit與picasso一樣都是在okhttp基礎之上做的封裝,項目中可以直接用了。Retrofit因為也是square出的,所以大家可能對它更崇拜些。Retrofit的跟Volley是一個套路,但解耦的更徹底:比方說通過注解來配置請求參數,通過工廠來生成CallAdapter,Converter,你可以使用不同的請求適配器(CallAdapter), 比方說RxJava,Java8, Guava。你可以使用不同的反序列化工具(Converter),比方說json, protobuff, xml, moshi等等。炒雞解耦,里面涉及到超多設計模式,個人覺得是很經典的學習案例。雖然支持Java8, Guava你可能也不需要用到。xml,protobuff等數據格式你也可能不需要解析。but,萬一遇到鬼了呢。至于性能上,個人覺得這完全取決于請求client,也就是okhttp的性能,跟這些封裝工具沒太大關系。

二、HttpURLConnection

1.簡單封裝


public class NetUtils
{
    public static String post(String url, String content) {
        HttpURLConnection conn = null;
        try {
            // 創建一個URL對象
            URL mURL = new URL(url);
            // 調用URL的openConnection()方法,獲取HttpURLConnection對象
            conn = (HttpURLConnection) mURL.openConnection();
            
            conn.setRequestMethod("POST");// 設置請求方法為post
            conn.setReadTimeout(5000);// 設置讀取超時為5秒
            conn.setConnectTimeout(10000);// 設置連接網絡超時為10秒
            conn.setDoOutput(true);// 設置此方法,允許向服務器輸出內容
            
            // post請求的參數
            String data = content;
            // 獲得一個輸出流,向服務器寫數據,默認情況下,系統不允許向服務器輸出內容
            OutputStream out = conn.getOutputStream();// 獲得一個輸出流,向服務器寫數據
            out.write(data.getBytes());
            out.flush();
            out.close();
            
            int responseCode = conn.getResponseCode();// 調用此方法就不必再使用conn.connect()方法
            if (responseCode == 200) {
                InputStream is = conn.getInputStream();
                String response = getStringFromInputStream(is);
                return response;
            } else {
                throw new NetworkErrorException("response status is "+responseCode);
            }
            
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.disconnect();// 關閉連接
            }
        }
        
        return null;
    }
    
    public static String get(String url) {
        HttpURLConnection conn = null;
        try {
            // 利用string url構建URL對象
            URL mURL = new URL(url);
            conn = (HttpURLConnection) mURL.openConnection();
            
            conn.setRequestMethod("GET");
            conn.setReadTimeout(5000);
            conn.setConnectTimeout(10000);
            
            int responseCode = conn.getResponseCode();
            if (responseCode == 200) {
                
                InputStream is = conn.getInputStream();
                String response = getStringFromInputStream(is);
                return response;
            } else {
                throw new NetworkErrorException("response status is "+responseCode);
            }
            
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            
            if (conn != null) {
                conn.disconnect();
            }
        }
        
        return null;
    }
    
    private static String getStringFromInputStream(InputStream is) throws IOException {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        // 模板代碼 必須熟練
        byte[] buffer = new byte[1024];
        int len = -1;
        while ((len = is.read(buffer)) != -1) {
            os.write(buffer, 0, len);
        }
        is.close();
        String state = os.toString();// 把流中的數據轉換成字符串,采用的編碼是utf-8(模擬器默認編碼)
        os.close();
        return state;
    }
}

注意網絡權限!被坑了多少次。
<uses-permission android:name="android.permission.INTERNET"/>

異步通常伴隨者他的好基友回調。這是通過回調封裝的Utils類。

public class AsynNetUtils {
    public interface Callback{
        void onResponse(String response);
    }
    
    public static void get(final String url, final Callback callback){
        final Handler handler = new Handler();
        new Thread(new Runnable() {
            @Override
            public void run() {
                final String response = NetUtils.get(url);
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onResponse(response);
                    }
                });
            }
        }).start();
    }
    
    public static void post(final String url, final String content, final Callback callback){
        final Handler handler = new Handler();
        new Thread(new Runnable() {
            @Override
            public void run() {
                final String response = NetUtils.post(url,content);
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onResponse(response);
                    }
                });
            }
        }).start();
    }
}

使用方法

private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    textView = (TextView) findViewById(R.id.webview);
    AsynNetUtils.get("http://www.baidu.com", new AsynNetUtils.Callback() {
        @Override
        public void onResponse(String response) {
            textView.setText(response);
        }
    });
}

嗯,一個蠢到哭的網絡請求方案成型了。愚蠢的地方有很多:

  • 每次都new Thread,new Handler消耗過大
  • 沒有異常處理機制
  • 沒有緩存機制
  • 沒有完善的API(請求頭,參數,編碼,攔截器等)與調試模式
  • 沒有Https

2.HTTP緩存機制
參考HTTP緩存機制
緩存對于移動端是非常重要的存在。

  • 減少請求次數,減小服務器壓力.
  • 本地數據讀取速度更快,讓頁面不會空白幾百毫秒。
  • 在無網絡的情況下提供數據。
流程圖
三、OkHttp

參考
OkHttp使用介紹
Okhttp使用詳解
OKHttp源碼解析

先看一下四大核心類:OkHttpClient、Request、Call 和 Response。
1.OkHttpClient
OkHttpClient表示了HTTP請求的客戶端類,在絕大多數的App中,我們只應該執行一次new OkHttpClient(),將其作為全局的實例進行保存,從而在App的各處都只使用這一個實例對象,這樣所有的HTTP請求都可以共用Response緩存、共用線程池以及共用連接池。

默認情況下,直接執行OkHttpClient client = new OkHttpClient()就可以實例化一個OkHttpClient對象。

可以配置OkHttpClient的一些參數,比如超時時間、緩存目錄、代理、Authenticator等,那么就需要用到內部類OkHttpClient.Builder,設置如下所示:

OkHttpClient client = new OkHttpClient.Builder().
        readTimeout(30, TimeUnit.SECONDS).
        cache(cache).
        proxy(proxy).
        authenticator(authenticator).
        build();

OkHttpClient本身不能設置參數,需要借助于其內部類Builder設置參數,參數設置完成后,調用Builder的build方法得到一個配置好參數的OkHttpClient對象。這些配置的參數會對該OkHttpClient對象所生成的所有HTTP請求都有影響。

有時候我們想單獨給某個網絡請求設置特別的幾個參數,比如只想讓某個請求的超時時間設置為一分鐘,但是還想保持OkHttpClient對象中的其他的參數設置,那么可以調用OkHttpClient對象的newBuilder()方法,代碼如下所示:

OkHttpClient client = ...

OkHttpClient clientWith60sTimeout = client.newBuilder().
        readTimeout(60, TimeUnit.SECONDS).
        build();

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

2.Request
Request類封裝了請求報文信息:請求的Url地址、請求的方法(如GET、POST等)、各種請求頭(如Content-Type、Cookie)以及可選的請求體。一般通過內部類Request.Builder的鏈式調用生成Request對象。

3.Call
Call代表了一個實際的HTTP請求,它是連接Request和Response的橋梁,通過Request對象的newCall()方法可以得到一個Call對象。Call對象既支持同步獲取數據,也可以異步獲取數據。

執行Call對象的execute()方法,會阻塞當前線程去獲取數據,該方法返回一個Response對象。
執行Call對象的enqueue()方法,不會阻塞當前線程,該方法接收一個Callback對象,當異步獲取到數據之后,會回調執行Callback對象的相應方法。如果請求成功,則執行Callback對象的onResponse方法,并將Response對象傳入該方法中;如果請求失敗,則執行Callback對象的onFailure方法。

4.Response
Response類封裝了響應報文信息:狀態嗎(200、404等)、響應頭(Content-Type、Server等)以及可選的響應體。可以通過Call對象的execute()方法獲得Response對象,異步回調執行Callback對象的onResponse方法時也可以獲取Response對象。

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

private final OkHttpClient client = new OkHttpClient();

public void run() throws Exception {
  Request request = new Request.Builder()
      .url("http://publicobject.com/helloworld.txt")
      .build();

  Response response = client.newCall(request).execute();

  if (!response.isSuccessful()) 
      throw new IOException("Unexpected code " + response);

  Headers responseHeaders = response.headers();
  for (int i = 0; i < responseHeaders.size(); i++) {
    System.out.println(responseHeaders.name(i) + ": " + 
    responseHeaders.value(i));
  }

  System.out.println(response.body().string());
}

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

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的響應頭,如下圖所示:

Paste_Image.png

為了解決同時獲取多個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流處理。

6.異步GET
以下示例演示了如何異步發送GET網絡請求,代碼如下所示:

private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/helloworld.txt")
        .build();

    client.newCall(request).enqueue(new Callback() {
      @Override
      public void onFailure(Call call, IOException e) {
        e.printStackTrace();
      }

      @Override
      public void onResponse(Call call, Response response) throws IOException {
        if (!response.isSuccessful()) 
        throw new IOException("Unexpected code " + response);

        Headers responseHeaders = response.headers();
        for (int i = 0, size = responseHeaders.size(); i < size; i++) {
          System.out.println(responseHeaders.name(i) + ": " + 
          responseHeaders.value(i));
        }

        System.out.println(response.body().string());
      }
    });
  }

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

要想異步執行網絡請求,需要執行Call對象的enqueue方法,該方法接收一個okhttp3.Callback對象,enqueue方法不會阻塞當前線程,會新開一個工作線程,讓實際的網絡請求在工作線程中執行。一般情況下這個工作線程的名字以“Okhttp”開頭,并包含連接的host信息,比如上面例子中的工作線程的name是Okhttp http://publicobject.com/...

當異步請求成功后,會回調Callback對象的onResponse方法,在該方法中可以獲取Response對象。當異步請求失敗或者調用了Call對象的cancel方法時,會回調Callback對象的onFailure方法。onResponse和onFailure這兩個方法都是在工作線程中執行的。

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

private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://api.github.com/repos/square/okhttp/issues")
        .header("User-Agent", "OkHttp Headers.java")
        .addHeader("Accept", "application/json; q=0.5")
        .addHeader("Accept", "application/vnd.github.v3+json")
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println("Server: " + response.header("Server"));
    System.out.println("Date: " + response.header("Date"));
    System.out.println("Vary: " + response.headers("Vary"));
  }

上面的代碼通過addHeader方法添加了兩個Accept請求頭,且二者的值不同,這樣服務器收到客戶端發來的請求后,就知道客戶端既支持application/json類型的數據,也支持application/vnd.github.v3+json類型的數據。

8.用POST發送String

可以使用POST方法發送請求體。下面的示例演示了如何將markdown文本作為請求體發送到服務器,服務器會將其轉換成html文檔,并發送給客戶端。

public static final MediaType MEDIA_TYPE_MARKDOWN
      = MediaType.parse("text/x-markdown; charset=utf-8");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    String postBody = ""
        + "Releases\n"
        + "--------\n"
        + "\n"
        + " * _1.0_ May 6, 2013\n"
        + " * _1.1_ June 15, 2013\n"
        + " * _1.2_ August 11, 2013\n";

    Request request = new Request.Builder()
        .url("https://api.github.com/markdown/raw")
        .post(RequestBody.create(MEDIA_TYPE_MARKDOWN, postBody))
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println(response.body().string());
  }

下面對以上代碼進行說明:
Request.Builder的post方法接收一個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 Types和MIME 參考手冊,較詳細地列出了所有的數據的MIME類型。以下是幾種常見數據的MIME類型值:

  • json :application/json
  • xml:application/xml
  • png:image/png
  • jpg: image/jpeg
  • gif:image/gif

在該例中,請求體會放置在內存中,所以應該避免用該API發送超過1M的數據。

9.用POST發送Stream流
下面的示例演示了如何使用POST發送Stream流。

 public static final MediaType MEDIA_TYPE_MARKDOWN
      = MediaType.parse("text/x-markdown; charset=utf-8");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    RequestBody requestBody = new RequestBody() {
      @Override public MediaType contentType() {
        return MEDIA_TYPE_MARKDOWN;
      }

      @Override public void writeTo(BufferedSink sink) throws IOException {
        sink.writeUtf8("Numbers\n");
        sink.writeUtf8("-------\n");
        for (int i = 2; i <= 997; i++) {
          sink.writeUtf8(String.format(" * %s = %s\n", i, factor(i)));
        }
      }

      private String factor(int n) {
        for (int i = 2; i < n; i++) {
          int x = n / i;
          if (x * i == n) return factor(x) + " × " + i;
        }
        return Integer.toString(n);
      }
    };

    Request request = new Request.Builder()
        .url("https://api.github.com/markdown/raw")
        .post(requestBody)
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println(response.body().string());
  }

下面對以上代碼進行說明:
以上代碼在實例化RequestBody對象的時候重寫了contentType()和writeTo()方法。
覆寫contentType()方法,返回markdown類型的MediaType。
覆寫writeTo()方法,該方法會傳入一個Okia的BufferedSink類型的對象,可以通過BufferedSink的各種write方法向其寫入各種類型的數據,此例中用其writeUtf8方法向其中寫入UTF-8的文本數據。也可以通過它的outputStream()方法,得到輸出流OutputStream,從而通過OutputSteram向BufferedSink寫入數據。

10.用POST發送File
下面的代碼演示了如何用POST發送文件。

 public static final MediaType MEDIA_TYPE_MARKDOWN
      = MediaType.parse("text/x-markdown; charset=utf-8");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    File file = new File("README.md");

    Request request = new Request.Builder()
        .url("https://api.github.com/markdown/raw")
        .post(RequestBody.create(MEDIA_TYPE_MARKDOWN, file))
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println(response.body().string());
  }

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

11.用POST發送Form表單中的鍵值對
如果想用POST發送鍵值對字符串,可以使用在post()方法中傳入FormBody對象,FormBody繼承自RequestBody,類似于Web前端中的Form表單。可以通過FormBody.Builder構建FormBody。示例代碼如下所示:

 private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    RequestBody formBody = new FormBody.Builder()
        .add("search", "Jurassic Park")
        .build();
    Request request = new Request.Builder()
        .url("https://en.wikipedia.org/w/index.php")
        .post(formBody)
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println(response.body().string());
  }

需要注意的是,在發送數據之前,Android會對非ASCII碼字符調用encodeURIComponent方法進行編碼,例如”Jurassic Park”會編碼成”Jurassic%20Park”,其中的空格符被編碼成%20了,服務器端會其自動解碼。

12.用POST發送multipart數據
我們可以通過Web前端的Form表單上傳一個或多個文件,Okhttp也提供了對應的功能,如果我們想同時發送多個Form表單形式的文件,就可以使用在post()方法中傳入MultipartBody對象。MultipartBody繼承自RequestBody,也表示請求體。只不過MultipartBody的內部是由多個part組成的,每個part就單獨包含了一個RequestBody請求體,所以可以把MultipartBody看成是一個RequestBody的數組,而且可以分別給每個RequestBody單獨設置請求頭。
示例代碼如下所示:

private static final String IMGUR_CLIENT_ID = "...";
  private static final MediaType MEDIA_TYPE_PNG = MediaType.parse("image/png");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    // Use the imgur image upload API as documented at 
    // https://api.imgur.com/endpoints/image
    RequestBody requestBody = new MultipartBody.Builder()
        .setType(MultipartBody.FORM)
        .addFormDataPart("title", "Square Logo")
        .addFormDataPart("image", "logo-square.png",
            RequestBody.create(MEDIA_TYPE_PNG,
            new File("website/static/logo-square.png")))
        .build();

    Request request = new Request.Builder()
        .header("Authorization", "Client-ID " + IMGUR_CLIENT_ID)
        .url("https://api.imgur.com/3/image")
        .post(requestBody)
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println(response.body().string());
  }

下面對以上代碼進行說明:

MultipartBody要通過其內部類MultipartBody.Builder進行構建。

通過MultipartBody.Builder的setType()方法設置MultipartBody的MediaType類型,一般情況下,將該值設置為MultipartBody.FORM,即W3C定義的multipart/form-data類型,詳見Forms in HTML documents。

通過MultipartBody.Builder的方法addFormDataPart(String name, String value)或addFormDataPart(String name, String filename, RequestBody body)添加數據,其中前者添加的是字符串鍵值對數據,后者可以添加文件。

MultipartBody.Builder還提供了三個重載的addPart方法,其中通過addPart(Headers headers, RequestBody body)方法可以在添加RequestBody的時候,同時為其單獨設置請求頭。

13.用Gson處理JSON響應
Gson是Google開源的一個用于進行JSON處理的Java庫,通過Gson可以很方面地在JSON和Java對象之間進行轉換。我們可以將Okhttp和Gson一起使用,用Gson解析返回的JSON結果。
下面的示例代碼演示了如何使用Gson解析GitHub API的返回結果。

private final OkHttpClient client = new OkHttpClient();
  private final Gson gson = new Gson();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://api.github.com/gists/c2a7c39532239ff261be")
        .build();
    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    Gist gist = gson.fromJson(response.body().charStream(), Gist.class);
    for (Map.Entry<String, GistFile> entry : gist.files.entrySet()) {
      System.out.println(entry.getKey());
      System.out.println(entry.getValue().content);
    }
  }

  static class Gist {
    Map<String, GistFile> files;
  }

  static class GistFile {
    String content;
  }

下面對以上代碼進行說明:

訪問GitHub的https://api.github.com/gists/c2a7c39532239ff261be的返回結果如下所示:

Paste_Image.png

Gist類對應著整個JSON的返回結果,Gist中的Map<String, GistFile> files對應著JSON中的files。

files中的每一個元素都是一個key-value的鍵值對,key是String類型,value是GistFile類型,并且GistFile中必須包含一個String content。OkHttp.txt就對應著一個GistFile對象,其content值就是GistFile中的content。

通過代碼Gist gist = gson.fromJson(response.body().charStream(), Gist.class),將JSON字符串轉換成了Java對象。將ResponseBody的charStream方法返回的Reader傳給Gson的fromJson方法,然后傳入要轉換的Java類的class。

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

Okhttp中,多個緩存實例同時訪問同一個緩存目錄會出錯,大部分的應用只應該調用一次new OkHttpClient(),然后為其配置緩存目錄,然后在App的各個地方都使用這一個OkHttpClient實例對象,否則兩個緩存實例會互相影響,導致App崩潰。

緩存示例代碼如下所示:

private final OkHttpClient client;

  public CacheResponse(File cacheDirectory) throws Exception {
    int cacheSize = 10 * 1024 * 1024; // 10 MiB
    String okhttpCachePath = getCacheDir().getPath() + File.separator + "okhttp";
    File okhttpCache = new File(okhttpCachePath);
    if(!okhttpCache.exists()){
        okhttpCache.mkdirs();
    }

    Cache cache = new Cache(okhttpCache, cacheSize);

    client = new OkHttpClient.Builder()
        .cache(cache)
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/helloworld.txt")
        .build();

    Response response1 = client.newCall(request).execute();
    if (!response1.isSuccessful()) 
    throw new IOException("Unexpected code " + response1);

    String response1Body = response1.body().string();
    System.out.println("Response 1 response:          " + response1);
    System.out.println("Response 1 cache response:    " + response1.cacheResponse());
    System.out.println("Response 1 network response:  " + response1.networkResponse());

    Response response2 = client.newCall(request).execute();
    if (!response2.isSuccessful()) throw new IOException("Unexpected code " + response2);

    String response2Body = response2.body().string();
    System.out.println("Response 2 response:          " + response2);
    System.out.println("Response 2 cache response:    " + response2.cacheResponse());
    System.out.println("Response 2 network response:  " + response2.networkResponse());

    System.out.println("Response 2 equals Response 1? " + 
    response1Body.equals(response2Body));
  }

下面對以上代碼進行說明:

我們在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也會使用該配置對緩存進行處理。

15.取消請求
當請求不再需要的時候,我們應該中止請求,比如退出當前的Activity了,那么在Activity中發出的請求應該被中止。可以通過調用Call的cancel方法立即中止請求,如果線程正在寫入Request或讀取Response,那么會拋出IOException異常。同步請求和異步請求都可以被取消。
示例代碼如下所示:

private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    // This URL is served with a 2 second delay.
    Request request = new Request.Builder()
        .url("http://httpbin.org/delay/2")
        .build();

    final long startNanos = System.nanoTime();
    final Call call = client.newCall(request);

    // Schedule a job to cancel the call in 1 second.
    executor.schedule(new Runnable() {
      @Override public void run() {
        System.out.printf("%.2f Canceling call.%n", (System.nanoTime() - startNanos) / 1e9f);
        call.cancel();
        System.out.printf("%.2f Canceled call.%n", (System.nanoTime() - startNanos) / 1e9f);
      }
    }, 1, TimeUnit.SECONDS);

    try {
      System.out.printf("%.2f Executing call.%n", (System.nanoTime() - startNanos) / 1e9f);
      Response response = call.execute();
      System.out.printf("%.2f Call was expected to fail, but completed: %s%n",
          (System.nanoTime() - startNanos) / 1e9f, response);
    } catch (IOException e) {
      System.out.printf("%.2f Call failed as expected: %s%n",
          (System.nanoTime() - startNanos) / 1e9f, e);
    }
  }

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

16.設置超時
一次HTTP請求實際上可以分為三步:

  • 客戶端與服務器建立連接
  • 客戶端發送請求數據到服務器,即數據上傳
  • 服務器將響應數據發送給客戶端,即數據下載

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

為此,可以通過OkHttpClient.Builder的connectTimeout()方法設置客戶端和服務器建立連接的超時時間,通過writeTimeout()方法設置客戶端上傳數據到服務器的超時時間,通過readTimeout()方法設置客戶端從服務器下載響應數據的超時時間。

在2.5.0版本之前,Okhttp默認不設置任何的超時時間,從2.5.0版本開始,Okhttp將連接超時、寫入超時(上傳數據)、讀取超時(下載數據)的超時時間都默認設置為10秒。如果HTTP請求需要更長時間,那么需要我們手動設置超時時間。

示例代碼如下所示:

private final OkHttpClient client;

  public ConfigureTimeouts() throws Exception {
    client = new OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build();
  }

  public void run() throws Exception {
    // This URL is served with a 2 second delay.
    Request request = new Request.Builder()
        .url("http://httpbin.org/delay/2") 
        .build();

    Response response = client.newCall(request).execute();
    System.out.println("Response completed: " + response);
  }

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

17.處理身份驗證
有些網絡請求是需要用戶名密碼登錄的,如果沒提供登錄需要的信息,那么會得到401 Not Authorized未授權的錯誤,這時候Okhttp會自動查找是否配置了Authenticator,如果配置過Authenticator,會用Authenticator中包含的登錄相關的信息構建一個新的Request,嘗試再次發送HTTP請求。
示例代碼如下所示:

private final OkHttpClient client;

  public Authenticate() {
    client = new OkHttpClient.Builder()
        .authenticator(new Authenticator() {
          @Override public Request authenticate(Route route, Response response)
            throws IOException {
            System.out.println("Authenticating for response: " + response);
            System.out.println("Challenges: " + response.challenges());
            String credential = Credentials.basic("jesse", "password1");
            return response.request().newBuilder()
                .header("Authorization", credential)
                .build();
          }
        })
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/secrets/hellosecret.txt")
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) 
    throw new IOException("Unexpected code " + response);

    System.out.println(response.body().string());
  }

上面對以上代碼進行說明:

OkHttpClient.Builder的authenticator()方法接收一個Authenticator對象,我們需要實現Authenticator對象的authenticate()方法,該方法需要返回一個新的Request對象,該新的Request對象基于原始的Request對象進行拷貝,而且要通過header("Authorization", credential)方法對其設置登錄授權相關的請求頭信息。

通過Response對象的challenges()方法可以得到第一次請求失敗的授權相關的信息。如果響應碼是401 unauthorized,那么會返回”WWW-Authenticate”相關信息,這種情況下,要執行OkHttpClient.Builder的authenticator()方法,在Authenticator對象的authenticate()中 對新的Request對象調用header("Authorization", credential)方法,設置其Authorization請求頭;如果Response的響應碼是407 proxy unauthorized,那么會返回”Proxy-Authenticate”相關信息,表示不是最終的服務器要求客戶端登錄授權信息,而是客戶端和服務器之間的代理服務器要求客戶端登錄授權信息,這時候要執行OkHttpClient.Builder的proxyAuthenticator()方法,在Authenticator對象的authenticate()中 對新的Request對象調用header("Proxy-Authorization", credential)方法,設置其Proxy-Authorization請求頭。

如果用戶名密碼有問題,那么Okhttp會一直用這個錯誤的登錄信息嘗試登錄,我們應該判斷如果之前已經用該用戶名密碼登錄失敗了,就不應該再次登錄,這種情況下需要讓Authenticator對象的authenticate()方法返回null,這就避免了沒必要的重復嘗試,代碼片段如下所示:

if (credential.equals(response.request().header("Authorization"))) {
   return null; 
}

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

ResponseBody和Response都實現了Closeable和AutoCloseable接口,它們都有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代碼塊,如下所示:

Call call = client.newCall(request);
 try (Response response = call.execute()) {
   ... // Use the response.
 }

將Response response = call.execute()放入到try()的括號之中,由于Response 實現了Closeable和AutoCloseable接口,這樣對于編譯器來說,會隱式地插入finally代碼塊,編譯器會在該隱式的finally代碼塊中執行Response的close()方法。

也可以在異步回調方法onResponse()中,執行類似的try代碼塊,try()代碼塊括號中的ResponseBody也實現了Closeable和AutoCloseable接口,這樣編譯器也會在隱式的finally代碼塊中自動關閉響應體,代碼如下所示:

Call call = client.newCall(request);
   call.enqueue(new Callback() {
     public void onResponse(Call call, Response response) throws IOException {
       try (ResponseBody responseBody = response.body()) {
         ... // Use the response.
       }
     }

     public void onFailure(Call call, IOException e) {
       ... // Handle the failure.
     }
   });

響應體中的數據有可能很大,應該只讀取一次響應體的數據。調用ResponseBody的bytes()或string()方法會將整個響應體數據寫入到內存中,可以通過source()、byteStream()或charStream()進行流式處理。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容