課程 2: HTTP 網路

HsuJin發表於2018-02-04

這節課是 Android 開發(入門)課程 的第三部分《訪問網路》的第二節課,導師是 Chris Lei 和 Joe Lewis。由於上節課的 JSON 是硬編碼的佔位符,並不是真正從網路獲取的資料,所以按照計劃的開發步驟,要實現從網路獲取資料,這節課先通過一個叫作 Soonami 的示例應用 (Sample App) 來驗證網路 (Networking) 相關的程式碼。因為網路命題的內容很龐大,所以課程中會從實用性出發,僅對用到的部分提供相應的資訊,不作深入討論。

Soonami App 同樣使用 USGS API 顯示是否有地震引起的海嘯預警,分四個步驟完成:

  1. Form HTTP Request
  2. Send the Request
  3. Receive the Response and make sense of it
  4. Update the UI

關鍵詞:Android Permissions、Android System Architecture、Exception、try/catch/finally block、HTTP Request、URL Class、HttpURLConnection、HTTP Verb、HTTP Status Code、StringBuilder、InputStream、InputStreamReader、BufferedReader、Method Chaining

Android Permissions

在進行 Android 中的網路操作前,先了解一下 Android 許可權的相關知識。預設情況下 Android 應用不具備任何許可權,當應用需要使用裝置的藍芽、網路連線、指紋識別,或者訪問使用者的日曆、地址、聯絡人等操作時,應用就需要請求許可權,完整的 Android 許可權列表可以到 Android Developers 網站 檢視。

Android 許可權按保護等級分為幾種型別,其中最重要的兩種是正常許可權 (Normal Permissions) 和危險許可權 (Dangerous Permissions)。

  1. 正常許可權:允許的操作對使用者資訊和其它應用的資料無影響,例如使用裝置的藍芽、網路連線、指紋識別等,完整列表可以到 Android Developers 網站 檢視。當應用請求正常許可權時,Android 會自動授予應用該許可權,無需使用者介入。

  2. 危險許可權:允許訪問使用者的個人資訊,可能會對其它應用的資料產生影響,例如訪問使用者的日曆、地址、聯絡人等。當應用請求危險許可權時,需要由使用者手動處理該請求。危險許可權是通過 許可權組 (Permission Groups) 來管理的(正常許可權也可能包含在許可權組內,不過許可權組對其無影響,所以無需考慮許可權組內的正常許可權)。
    (1)當裝置執行在 Android 6.0 (API Level 23) 以及應用的 targetSdkVersion 為 23 或以上時,Android 會在應用執行時 (Runtime),彈出對話方塊,顯示應用請求的危險許可權所在的許可權組。如果使用者拒絕許可權請求,應用未能獲得該許可權,那麼它就無法提供對應的功能,但仍能正常執行;如果使用者同意該請求,就相當於授予應用整個許可權組的許可權。例如應用請求 READ_CONTACTS 許可權時,這個許可權屬於 CONTACTS 許可權組,系統就會在應用執行時彈出對話方塊,顯示應用請求 CONTACTS 許可權組,如果使用者同意該請求,此時應用只獲得 READ_CONTACTS 許可權,但是在這個基礎上,如果應用再請求同一許可權組的 WRITE_CONTACTS 許可權,Android 會自動授予應用該許可權。
    (2)當裝置執行在 Android 5.1 (API Level 22) 或應用的 targetSdkVersion 為 22 或以下時,Android 會在應用安裝時 (Install Time),彈出對話方塊,顯示應用請求的所有許可權組列表,使用者必須同意所有的許可權請求,否則無法安裝應用。

為應用請求 Android 許可權的方法是在 AndroidManifest 中新增 <uses-permission> 標籤以及對應的屬性,注意標籤名不是 <user-permission>,例如在 Soonami App 中請求網路訪問的許可權:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.android.soonami">
    <uses-permission android:name="android.permission.INTERNET"/>
    ...
</manifest>
複製程式碼

正如上面描述的,網路訪問屬於 Android 的正常許可權,系統會自動授予應用該許可權,無需使用者介入。

Tips:
1. 雖然應用獲得一個危險許可權就意味著獲得了整個許可權組的許可權,但是在將來的 Android SDK 中一些許可權可能會從一個許可權組移動到另一個許可權組,所以不應該根據許可權組來假定應用是否獲得某些許可權,最佳做法 (Best Practice) 是在 AndroidManifest 中明確請求每個許可權。
2. 當應用要用到拍照、地圖等功能時,可以通過 Intent 呼叫相應的應用來實現,從而避免請求過多的許可權。過多的許可權請求會引起使用者的懷疑,所以應用應該儘可能少地請求許可權,同時確保具有充分的理由向使用者解釋需要請求許可權的原因。

Android System Architecture

之所以 Android 有系統許可權的概念,是因為 Android 是一種許可權分離 (privilege-separated) 的作業系統,應用以唯一的身份標識執行。也就是說,每個 Android 應用都執行在一個程式沙盒 (Process Sandbox) 中,應用需要明確請求沙盒外的資源和資料。這種模式是由 Android 系統框架決定的,應用與裝置之間的互動通過一系列的層抽象 (Layer Abstraction) 實現,每一層實現一部分功能,越底層實現的功能越小。

課程 2: HTTP 網路

上圖是簡化的 Android 系統框架,完整的圖表可以到 Android Developers 網站 檢視。

  1. 頂層是應用層,開發者寫的所有應用程式都在這一層。App 通過呼叫下一層的 Android Framework class,如 TextView、Activity,使應用僅用幾行程式碼就完成很多複雜的工作,如顯示文字、開啟一個新頁面。
  2. 次層是框架層,這一層提供了許多 Android Framework class,它們通過呼叫下一層的程式碼來避免很多重複的複雜工作,最終達到控制裝置硬體的效果。框架層是連線應用和裝置的橋樑。
  3. 次底層是系統層,這一層有一套複雜的控制裝置硬體的程式碼,用來規範應用和系統程式如何訪問硬體資源,從而實現裝置上的多個應用共享同一套硬體。
  4. 底層是物理層,指的是裝置硬體,如 Wi-Fi、藍芽,以及 CPU、GPU、記憶體等電子器件。

在 Soonami App 中,應用通過 Android Framework 的 HttpURLCOnnection 類使用裝置上的蜂窩或 Wi-Fi 硬體裝置,以從網路上獲取資料,而不是直接操作 Android 系統,更不是直接控制裝置硬體。

Exception

如果 Soonami App 在沒有獲得網路訪問許可權的情況下進行相關的操作,應用會產生 SecurityException 導致應用崩潰。事實上,一些應用崩潰的原因往往是沒有正確處理 Exception(例外/異常)。Exception 是 Throwable class 的一個擴充套件類(另一個是 Error),當程式碼執行失敗或遇到意外狀態時會觸發異常(Throw an Exception),稱為異常事件 (Exception Event)。Exception class 的子類定義了許多異常事件的型別,例如 IllegalStateException 表示有 method 被非法狀態下呼叫;NullPointerException 表示對空物件進行非法操作。所以異常可以理解為錯誤 (error),但它可以被捕獲 (catch) 處理或包含到 Exception 類的例項中;與其它類一樣,開發者也可以建立自定義的 Exception class,例如下面的 InvalidPurchaseException。

public void completePurchase() throws InvalidPurchaseException {
    ...
    ...
    throw new InvalidPurchaseException();
    ...
}
複製程式碼
  1. 在任何地方觸發異常時都要用到 Java 關鍵字 throw
  2. 異常觸發後下面的程式碼不會執行。

異常可分為檢查異常和非檢查異常 (Checked and Unchecked Exception)。

  • 所有非 RuntimeException(Exception 的子類)的異常都是檢查異常,都必須在方法簽名 (Method Signature) 中宣告,表示呼叫該 method 時必須處理異常,例如呼叫上面的 completePurchase() method 時必須處理 InvalidPurchaseException 異常。這種做法在一個類內的輔助方法 (Helper Method) 很常用,可以將異常處理轉移到呼叫 method 的地方。
  • 所有 RuntimeException 都是非檢查異常,編譯器不會強制 (check) 程式碼處理異常。雖然 Java 有使用異常的標準框架,但這並不意味著一定要在發生錯誤時觸發異常。理論上,在出現錯誤或意外情況時,應該在程式碼中提供一些合理的預設行為,儘可能地使程式碼繼續執行,這種做法叫靜默失敗 (Failing Silently);但是如果錯誤會對接下來的程式碼造成影響,那麼就要觸發異常以通知此錯誤。開發者需要根據實際情況進行權衡。

處理 Exception 的方法通常是將可能觸發異常的 method 放到 try/catch/finally 區塊中,例如下面的 openFile method:

public void openFile() {
    FileReader reader = null;
    try {
        // constructor may throw FileNotFoundException
        reader = new FileReader("someFile");
        int i = 0;
        while (i != -1) {
            //reader.read() may throw IOException
            i = reader.read();
            System.out.println((char) i);
        }
    } catch (FileNotFoundException e) {
        Log.e(LOG_TAG, "Problem reading the file.", e);
    } catch (IOException e) {
        Log.e(LOG_TAG, "Problem opening the file.", e);
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                Log.e(LOG_TAG, "Problem closing the file.", e);
            }
        }
        System.out.println("--- File End ---");
    }
}
複製程式碼
  1. FileReader()reader.read() 兩個可能觸發異常的 method 放進 try 區塊,並用兩個 catch 區塊分別處理不同的異常,在這裡是通過 Log 日誌記錄錯誤資訊。
  2. 如果 FileReader() 觸發異常,那麼就不再執行 try 區塊內的程式碼,而是跳到 catch (FileNotFoundException e) 處理異常,然後跳到 finally 執行該區塊內的程式碼,最後跳出 try/catch 區塊,從上至下繼續執行下面的程式碼。如果 reader.read() 觸發異常,則會跳到 catch (IOException e) 處理異常,接下來的步驟與上面相同。因此,try 區塊內的程式碼無法保證總是會執行,程式碼不會同時進入兩個 catch 區塊。
  3. 無論是否觸發異常,finally 區塊內的程式碼都會執行。
  4. 注意變數的作用域,例如這裡的 reader 變數是在 try/catch 區塊外宣告的,如果在 try 區塊內宣告變數,那麼變數的作用域僅在 try/catch 區塊內。

Tips:
1. 在 Android Studio 中開啟 Java 檔案,選中左側的 "7:Structure" 標籤,可以按照巢狀結構清晰地選擇瀏覽檔案中的 Java 變數、類、物件。
2. 在 Android Studio 中選中被識別出錯誤的程式碼(有波浪下劃線)使用快捷鍵 opt(alt)+enter 可以選擇 Android Studio 提供的解決方案。例如選擇 "Surround with try/catch" 可以快速新增 try/catch 區塊,在 catch 區塊內還會自動新增 e.printStackTrace(); 表示列印錯誤堆疊。

Networking

網路是計算機(包括手機、膝上型電腦、伺服器等)之間交換資訊的概念,它的基本原理是一臺計算機向另一臺計算機傳送 HTTP 請求,傳送端通常稱為客戶端,接收端為 Web 伺服器;伺服器作出響應後,客戶端能夠獲取響應並從中提取資訊。例如使用瀏覽器開啟 Google 搜尋主頁時,瀏覽器作為客戶端向 Google 伺服器傳送 HTTP 請求,瀏覽器接收到 Google 伺服器的響應後解析資料,最後重新整理頁面顯示一個完整的網頁。利用 Chrome 瀏覽器的開發者工具(在空白處右鍵選擇 Inspect),在 Network 介面可以看到瀏覽器已載入的資源 (HTML, CSS, JavaScript),選中其中一項,在 Headers 標籤頁下可以看到瀏覽器向 Google 伺服器傳送的請求的相關資訊,如 URL、method、響應程式碼等。

課程 2: HTTP 網路

HTTP 請求 (HTTP Request) 是網路交換資訊的基礎部分,HTTP(超文字傳輸協議,Hypertext Transfer Protocol)是其中的核心技術。類似在餐廳點披薩,顧客需要明確告訴服務員披薩的尺寸和配料等資訊,客戶端傳送 HTTP 請求也需要明確指出請求內容以及提供方式,其中一項重要指標是 URL(統一資源定位器,Uniform Resource Locator),它決定了資料來源的地址或位置,在 API 中稱為端點 (Endpoints)。一個 URL 示例如下,它包含了五個基本元素:

https://example.com/animal/mammal/primate?diet=omnivore&active=night#tarsier
複製程式碼
  1. 傳送協議 (Protocol/Scheme):通常為 http 或 https,後接 // 標記符。
  2. 伺服器 (Host/Domain/Authority):Web 資源的主體,通常是域名,如 google.com,有時是 IP 地址,如 192.168.0.1。後面可以接網路埠號(數字,若為 HTTP 的預設值 ":80" 可省略)。
  3. 資源路徑 (Resource Path):類似目錄結構,表示資源在伺服器中的位置。
  4. 查詢 (Query):可選,以 ? 為開始,每個引數用 & 分隔。
  5. 片段 (Fragment):可選,以 # 為開始,指頁面中的某些資源 ID,表示頁面會從該資源開始顯示。

在 Android 中利用 URL class 來生成訪問 API 端點的 URL,例如在 Soonami App 中新建一個名為 createUrl 的 URL 物件,在 try/catch 區塊內通過字串構造 URL 物件,同時可捕獲 MalformedURLException 並通過 Log 日誌記錄錯誤資訊。

/**
 * Returns new URL object from the given string URL.
 */
private URL createUrl(String stringUrl) {
    URL url = null;
    try {
        url = new URL(stringUrl);
    } catch (MalformedURLException exception) {
        Log.e(LOG_TAG, "Error making the HTTP request.", exception);
        return null;
    }
    return url;
}
複製程式碼

建立 URL 物件後,通過呼叫 url.openConnection() 建立一個 HttpURLConnection 物件,通過呼叫其中的 method 就可以在 Android 中生成 HTTP 請求了。這種模式是有 Android 系統框架決定的,通過層抽象使 App 僅用幾行程式碼就能夠完成複雜的工作。例如在這裡就通過 Android Framework 的 HttpURLCOnnection 類使用裝置上的蜂窩或 Wi-Fi 硬體裝置,以從網路上獲取資料,而不是直接操作 Android 系統,更不是直接控制裝置硬體。

Tip: OkHttp 是一個開源的 HTTP 客戶端第三方庫,它也可以實現 Android 的網路操作。

/**
 * Make an HTTP request to the given URL and return a String as the response.
 */
private String makeHttpRequest(URL url) throws IOException {
    String jsonResponse = "";

    // If the URL is null, then return early.
    if (url == null) {
        return jsonResponse;
    }
    HttpURLConnection urlConnection = null;
    InputStream inputStream = null;
    try {
        urlConnection = (HttpURLConnection) url.openConnection();
        urlConnection.setRequestMethod("GET");
        urlConnection.setReadTimeout(10000 /* milliseconds */);
        urlConnection.setConnectTimeout(15000 /* milliseconds */);
        urlConnection.connect();

        // If the request is successful (response code 200),
        // then read the input stream and parse the response.
        if (urlConnection.getResponseCode() == 200) {
            inputStream = urlConnection.getInputStream();
            jsonResponse = readFromStream(inputStream);
        } else {
            Log.e(LOG_TAG, "Error response code: " + urlConnection.getResponseCode());
        }
    } catch (IOException e) {
        Log.e(LOG_TAG, "Problem retrieving the earthquake JSON results.", e);
    } finally {
        if (urlConnection != null) {
            urlConnection.disconnect();
        }
        if (inputStream != null) {
            // function must handle java.io.IOException here
            inputStream.close();
        }
    }
    return jsonResponse;
}
複製程式碼
  1. urlConnection = (HttpURLConnection) url.openConnection();
    通過 url.openConnection() 建立一個 HttpURLConnection 物件,預設返回的資料型別為 URLConnection,不過 HttpURLConnection 是 URLConnection 的子類,所以這裡可以轉換資料型別。
  2. urlConnection.setRequestMethod("GET");
    呼叫 HttpURLConnection 的 setRequestMethod method 來設定 HTTP 動詞。

HTTP 方法或動詞 (Method/Verb) 是客戶端傳送 HTTP 請求的另一項重要指標,通過它來完成客戶端與伺服器之間的互動,通常是四種操作,建立 (Create)、讀取 (Read)、更新 (Update)、刪除 (Delete),簡寫 CRUD)。常用的 HTTP 動詞有:
(1)GET: 客戶端從伺服器獲取或檢索資料。
(2)POST: 客戶端向伺服器傳送一些資料。
(3)PUT: 客戶端更新伺服器上的資料。
(4)DELETE: 客戶端刪除伺服器上的資料。

在 Soonami App 中,客戶端要從伺服器中獲取地震資訊,屬於讀取操作,所以這裡設定 HTTP 動詞為 GET。HTTP 動詞的詳細資訊可以到這個網站檢視,裡面詳細敘述了每個 HTTP 動詞的用法,以及對應的伺服器響應。儘管 HTTP 動詞的用法遵循一定的規則,但是對於不同 API 而言會有差異,最終應用要以 API 文件為準。

  1. urlConnection.setReadTimeout(10000 /* milliseconds */);
    呼叫 HttpURLConnection 的 setReadTimeout method 來設定讀取資料的延時為 10000 毫秒。

  2. urlConnection.setConnectTimeout(15000 /* milliseconds */);
    呼叫 HttpURLConnection 的 setConnectTimeout method 來設定連線延時為 15000 毫秒。

  3. urlConnection.connect();
    打包 HTTP 請求並將其傳送到伺服器。這行程式碼是客戶端與伺服器建立 HTTP 連線的位置,在此之前的內容屬於設定 HTTP 請求,在此之後的屬於接收響應並解析資料的內容。

  4. urlConnection.getResponseCode() == 200
    呼叫 HttpURLConnection 的 getResponseCode method 來獲取 HTTP 響應程式碼。

伺服器對 HTTP 請求的響應通過 HTTP 響應程式碼 (HTTP Status Code) 表示,響應程式碼為三位數字,按首位數字分為五類響應,完整的 HTTP 響應程式碼列表可以到 Wikipedia 檢視。
(1)1xx Informational Responses
資訊狀態碼,表示請求已被伺服器接收,但仍需繼續處理。
(2)2xx Success
成功狀態碼,表示請求已成功被伺服器接收、理解、並接受。常見 "200 OK" 表示請求已成功,請求的資料返回客戶端。
(3)3xx Redirection
重定向狀態碼,表示客戶端需要採取進一步的操作才能完成請求。常見 "301 Moved Permanently" 表示請求的資源已永久移動到新位置。
(4)4xx Client Errors
客戶端錯誤狀態碼,表示客戶端可能發生了錯誤,妨礙伺服器的處理。常見 "400 Bad Request" 表示由於明顯的客戶端錯誤(請求語法錯誤,欺騙性路由請求等),伺服器不會處理該請求;"403 Forbidden" 表示伺服器已經理解請求,但是拒絕執行它;"404 Not Found" 表示請求的資源未在伺服器上找到。
(5)5xx Server Errors
伺服器錯誤狀態碼,表示伺服器在處理請求的過程中有錯誤或者異常狀態發生,無法完成有效的請求。常見 "502 Bad Gateway" 表示作為閘道器或代理工作的伺服器嘗試執行請求時,從上游伺服器接收到無效的響應。

根據 HTTP 響應程式碼,針對伺服器的不同響應作出對應的處理方案,使程式碼能夠正常執行,同時增加程式碼的魯棒性。例如在 Soonami App 中,USGS 伺服器對 GET 動詞的響應可能是 200 表示 OK,也可能是 404 表示未找到資源,應用通過 if-else 語句實現僅在 USGS 伺服器響應程式碼為 "200 OK" 時接收響應並解析資料,收到其它響應程式碼時通過 Log 日誌記錄錯誤資訊。

  1. inputStream = urlConnection.getInputStream();
    將伺服器返回的資料存放在 InputStream 中。對於計算機而言,每一段資料,無論是文字還是圖片,都是存放在位元組大小的塊中,應用在接收資料時資料以資料流 (InputStream) 的形式輸入。資料流是抽象的,以二進位制 (0/1) 儲存。

  2. jsonResponse = readFromStream(inputStream); 通過 readFromStream 輔助方法來解析 InputStream 資料流,最終傳給 jsonResponse 字串。由於資料流是二進位制 (0/1) 儲存的原始資料,所以應用在使用前需要解析成有意義的內容。例如這裡需要將伺服器返回的 GeoJSON 原始二進位制資料轉換成字串,readFromStream 輔助方法的程式碼如下:

/**
 * Convert the {@link InputStream} into a String which contains the
 * whole JSON response from the server.
 */
private String readFromStream(InputStream inputStream) throws IOException {
    StringBuilder output = new StringBuilder();
    if (inputStream != null) {
        InputStreamReader inputStreamReader = new InputStreamReader(inputStream, Charset.forName("UTF-8"));
        BufferedReader reader = new BufferedReader(inputStreamReader);
        String line = reader.readLine();
        while (line != null) {
            output.append(line);
            line = reader.readLine();
        }
    }
    return output.toString();
}
複製程式碼
  1. 通過 InputStreamReader 將 InputStream 的二進位制資料轉換為字元。其中 Charset 指定了如何將原始資料的逐個位元組轉換成字元,UTF-8 是一種廣泛應用的 Unicode 字元編碼。
  2. 由於 InputStreamReader 一次只能轉換一個字元,根據 InputStream 實際提供資料的不同方式,這可能會導致嚴重的效能問題,因此將 InputStreamReader 包裝到 BufferedReader 可以避免這個問題。例如上面的 reader.readLine(); 使 BufferedReader 在收到對某個字元的請求後會讀取並儲存該字元前後的一整行字元,當請求另一個字元時就能利用 BufferedReader 提前讀取的字元來實現請求,無需再呼叫 InputStreamReader。
  3. 由於資料解析過程中會不斷生成新的字元,如果將解析的字元存入字串,那麼就要不斷地對字串重新賦值,實際上是對 String 物件反覆進行刪除和建立操作,因此這裡引入一個新的 StringBuilder class。相對 String 而言,StringBuilder 是可變的 (Mutable),在改變字元時能夠節省很多系統資源。
    (1)StringBuilder output = new StringBuilder();
    與其它類一樣,通過建構函式建立一個 StringBuilder 物件。
    (2)output.append(line);
    通過 append method 新增字元序列。append method 可以在一行內多次呼叫,如 output.append(line1).append(line2);,這種方法叫做方法鏈 (Method Chaining)。
    (3)StringBuilder 的其它一些常用 method 有 output.deleteCharAt(3) 表示刪除索引號 3 的字元;output.toString() 表示將 StringBuilder 儲存到一個不可變 (Immutable) 的字串中。

相關文章