課程 1: JSON 解析

weixin_34166847發表於2018-02-04

結束 Android 開發(入門)課程 的第二部分《多螢幕應用》後,來到第三部分《訪問網路》,這部分課程要完成一個名為 Quake Report 的應用,這個應用從網路獲取資料,列出全球範圍內最近發生的地震資訊,包括時間、地點、震級。

Quake Report App 分三節課完成,每個課程的進度分配如下:

  1. JSON Parsing 從 USGS API 請求資料,瞭解返回的資料結構,並提取資料。
  2. HTTP Networking 將資料輸入 App,涉及 Android 許可權、後臺執行緒等內容。
  3. Threads & Parallelism 瞭解 HTTP 請求的端對端路徑,實時更新資料,並顯示出來。

這是第三部分《訪問網路》的第一節課,導師是 Chris Lei 和 Joe Lewis。這節課的重點是解析 API 返回的 HTTP 響應中包含的 JSON。因為此前課程都沒有涉及網路訪問的內容,所以這節課會循序漸進地介紹相關知識。首先了解 USGS API,再在匯入已有程式碼後通過 Java 提取和格式化後設資料,然後暫時通過 JSON 響應示例作為佔位符資料,最後優化佈局。

關鍵詞:API、JSON、Key/Value Pair、Traversal Path、JSONObject、Utility class、SimpleDateFormat、String.split、DecimalFormat、drawable.shape、GradientDrawable、ContextCompat.getColor、switch Statement、Math.floor

USGS API

Quake Report App 要從網路獲取地震資料,那麼就要用到 API。API(應用程式程式設計介面,Application Programming Interface)是一個軟體產品或服務將其一部分功能或資料提供給其它軟體使用的一種方法,API 的提供者和使用者互相形成一種程式設計合作關係 (Programming Partnership),相互創造出更大的價值。針對地震資料的 API,Google 搜尋 "earthquake api" 可以找到 USGS(美國地質勘探局,U. S. Geological Survey)網站提供相應的技術支援。點選 "For Developers" 目錄下的 "API Documentation" 可以看到,USGS API 支援通過 URL 請求資料,格式如下:

https://earthquake.usgs.gov/fdsnws/event/1/[METHOD][?[PARAMETERS]]
複製程式碼

在這裡 URL 可分為三部分:

  1. 頭部 https://earthquake.usgs.gov/fdsnws/event/1/,固定不變。
  2. 按照不同資料需求接上 METHOD,支援 catalogs、count、query 等。Quake Report App 要獲取地震資訊,所以這裡用到 query
  3. 最後新增引數 ?[PARAMETERS],支援資料格式、時間、震級等。引數無需用 [] 包括,第一個引數前用 ? 連線,引數之間用 & 連線。

例如要獲取 2014 年 1 月 1 日至 2014 年 1 月 2日之間震級大於五級的地震資料,並以 GeoJSON 格式返回資料,那麼向 USGS API 請求資料的 URL 如下:

https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=2014-01-01&endtime=2014-01-02&minmagnitude=5
複製程式碼
  1. methodquery,後面用 ? 連線引數。
  2. parameters 有四個,相互之間用 & 連線,引數分別為
    (1)format=geojson 指定資料以 GeoJSON 格式返回;
    (2)starttime=2014-01-01 指定資料開始時間為 2014 年 1 月 1 日;
    (3)endtime=2014-01-02 指定資料截止時間為 2014 年 1 月 2日;
    (4)minmagnitude=5 指定資料的震級為五級以上。

API 返回的 GeoJSON 資料 沒有可讀性,可以複製到 JSON Pretty Print 網站 格式化後檢視。例如 "time" 是用 Unix 時間戳(從 1970 年 1 月 1 日零時開始所經過的毫秒數,整數,便於時間計算,詳細資訊可以觀看這個 YouTube 視訊)的形式記錄,表示地震發生的時間;"felt" 表示 USGS 網站使用者反饋的震感;"tsunami" 是一個布林型別資料,表示地震是否觸發海嘯預警;"title" 是包含震級和震源地的英文字元;"coordinates" 是一個三維資料,包含震源的經度、緯度、深度。

JSON

事實上,USGS API 支援多種格式的響應資料,包括 CSV、XML、GeoJSON 等,這裡選擇 GeoJSON 並不意味著它是最好的,而是因為 JSON 是現今許多簽名 Web 服務中最常用的響應格式,GeoJSON 則是 JSON 的一種特殊風格,定製用於表示地理資訊。對於開發者而言,擁有使用 JSON 的經驗後,其它格式也能快速上手。

JSON 的全稱是 JavaScript Object Notation,但其實它與 Javascript 語言並不相關,名稱用 JS 開頭是因為最初設計時為了促進 Web 的有效溝通。實際上 JSON 是組織資料的一種策略型規則,它是獨立的資料交換格式,可以使用任何語言解析,例如 Android 用到的 Java 語言。

JSON 結構

1   {
2       "size" : 9.5,
3       "wide" : true,
4       "country-of-origin" : "usa",
5       "style" : {
6           "categories" : [ "boot", "winklepicker" ],
7           "color" : "black"
8       }
9   }
複製程式碼

上面是一段簡單的 JSON 示例,雖然 JSON 採用完全獨立於語言的文字格式,但是也使用了類似於 C 語言家族 (C++, Java, Python) 的習慣,包括字串、物件、陣列等。

  1. 第 2、3、4、7 行的格式相同,稱為鍵/值對 (Key/Value Pair)。
    (1)冒號 : 左側的是鍵 (Key),由 "" 包括,表示一類資料的名稱。
    (2)冒號 : 右側的是值 (Value),表示一類資料的值,可以是數值、布林型別、字串、陣列、物件等。其中字串由 "" 包括,使用 \ 轉義。
    (3)鍵/值對之間用 , 分隔。
  2. 第 6 行的鍵/值對,鍵是 categories,值是一個陣列,由 [] 包括,元素之間用 , 分隔。
  3. 第 5 行的鍵/值對,鍵是 style,值是一個物件,由 {} 包括。物件是鍵/值對的集合,這裡是 categoriescolor 兩個鍵/值對的集合,這就形成了巢狀結構。
  4. 整個 JSON 檔案由 {} 包括,所以一個 JSON 就是一個物件,名稱常用 root 表示。

詳細的 JSON 結構介紹可以到官網檢視。

JSON 物件樹

JSON 存在巢狀結構,也就產生了 JSON 物件樹,要訪問其中的節點,就有了遍歷路徑 (Traversal Path) 的概念。例如要訪問上面的 JSON 示例中的第一個 categories 元素,遍歷路徑如下:

Root > JSONObject with key "style" > JSONArray with key "categories" >  Look for the first element in the array
複製程式碼

JSON 物件樹節點的遍歷路徑對解析 JSON 至關重要,它的作用與之前提到的虛擬碼 (Pseudo code) 的作用類似,幫助開發者理清程式設計思路。複雜的 JSON 檔案可以複製到 JSON Formatter 網站 格式化後,選擇摺疊或展開節點檢視。

在 Android 中解析 JSON

// Create a JSONObject from the SAMPLE_JSON_RESPONSE string
JSONObject baseJsonResponse = new JSONObject(SAMPLE_JSON_RESPONSE);

// Extract the JSONArray associated with the key called "features",
// which represents a list of features (or earthquakes).
JSONArray earthquakeArray = baseJsonResponse.getJSONArray("features");

// For each earthquake in the earthquakeArray, create an {@link Earthquake} object
for (int i = 0; i < earthquakeArray.length(); i++) {
    // Get a single earthquake at position i within the list of earthquakes
    JSONObject currentEarthquake = earthquakeArray.getJSONObject(i);
    // For a given earthquake, extract the JSONObject associated with the
    // key called "properties", which represents a list of all properties
    // for that earthquake.
    JSONObject properties = currentEarthquake.getJSONObject("properties");
    // Extract the value for the key called "mag"
    double magnitude = properties.getDouble("mag");
    // Extract the value for the key called "place"
    String location = properties.getString("place");
    // Extract the value for the key called "time"
    long time = properties.getLong("time");
    // Extract the value for the key called "url"
    String url = properties.getString("url");

    // Add the new {@link Earthquake} to the list of earthquakes.
    earthquakes.add(new Earthquake(magnitude, location, time, url));
}
複製程式碼

Android 提供了強大的 JSONObject class 用於解析 JSON,在瞭解遍歷路徑後通過豐富的 getter method 即可靈活處理 JSON。

  1. 解析 JSON 的程式碼放在一個 Utility class 內,該類的建構函式為 private,表示不應該建立 Utility 物件,因為 Utility class 只用於存放靜態變數 (static variable) 和 static method,它們可以直接用類名訪問,無需例項化。
  2. 這節課先驗證 JSON 解析,利用 JSONObject(String json) 建構函式傳入一個佔位符資料,新建 JSONObject 物件,名為 baseJsonResponse,對應的 JSON 物件樹節點為 Root。
  3. 針對 Quake Report App 需要的震級、地點、時間、URL 資訊,按照遍歷路徑通過相應的 getter method 獲取資料。
    (1)通過 getJSONArray 獲取 Root 內鍵為 features 的陣列;
    (2)通過 length() 獲取 features 陣列的長度;
    (3)通過 getJSONObject 獲取 features 陣列的元素 currentEarthquake 物件;
    (4)通過 getJSONObject 獲取 currentEarthquake 物件內的元素 properties 物件;
    (5)通過 getDouble 獲取 properties 物件內的元素 mag double 數值;
    (6)通過 getString 獲取 properties 物件內的元素 place 字串;
    (7)通過 getLong 獲取 properties 物件內的元素 time long 數值;
    (8)通過 getString 獲取 properties 物件內的元素 url 字串;
  4. 上述 getter method 在傳入不存在的鍵時會產生 JSONException 錯誤,這裡可以使用對應的 opt method 代替,例如 optString(String name) 在傳入不存在的字串時會返回一個空的字串,optInt(String name) 在傳入無法轉換為 int 的資料時會返回 0。

更多 JSONObject 的資訊可以參考這個入門教程

功能實現和佈局優化

設計師提供的應用 UI 原型,開發者要程式設計實現,雙方合作設計出殺手級的使用者體驗 (Designing a killer user experience)。如果沒有設計師也沒有關係,多花點時間按照 Material Design 設計也可以寫出優秀的應用。

  1. 顯示可讀的時間和日期

由於 USGS API 返回的地震發生時間資料是以 Unix 時間的形式記錄的,在應用中要顯示成可讀的時間和日期。Android 提供了神奇的 SimpleDateFormat class 來處理這個問題:將 Unix 時間傳入 Date 物件,新建 SimpleDateFormat 物件並指定所需的時間格式,最後呼叫 SimpleDateFormat.format method 就實現了時間的格式化。

// The time in milliseconds of the earthquake.
long timeInMilliseconds = 1454124312220L;
// Create a new Date object from the time in milliseconds of the earthquake.
Date dateObject = new Date(timeInMilliseconds);
// Create a new SimpleDateFormat object by assigning the format of the date.
SimpleDateFormat dateFormatter = new SimpleDateFormat("MMM DD, yyyy");
// Get the formatted date string (i.e. "Mar 3, 1984") from a Date object.
String dateToDisplay = dateFormatter.format(dateObject);
複製程式碼

在 SimpleDateFormat 中,時間格式通過字元表示:

Letter Date or Time Component Example
y Year 1996; 96
M Month in year (context sensitive) July; Jul; 07
D Day in year 189
d Day in month 7
H Hour in day (0-23) 0
m Minute in hour 30
s Second in minute 55
S Millisecond 978

完整表格可以到 Android Developers 網站檢視。

(1)區分大小寫,例如 D 表示一年中的天數,d 表示一月中的天數。
(2)一個字元僅表示一位數字,例如 1996 年在 yyyy 時顯示 1996,在 yy 時顯示 96。
(3)所有未在特殊字元表中列出的字元都將在輸出字串中直接顯示。例如,如果時間格式字串包含 :-,,則輸出字串也將在相應位置直接包含相同的標點符號。

  1. 操控字串

在 Quake Report App 中,需要將 USGS API 返回的地點資料分成兩部分顯示,第一行顯示地震與城市之間的距離,第二行指定具體的城市。

// Get the original location string from the Earthquake object,
// which can be in the format of "5km N of Cairo, Egypt" or "Pacific-Antarctic Ridge".
String originalLocation = currentEarthquake.getLocation();

// If the original location string (i.e. "5km N of Cairo, Egypt") contains
// a primary location (Cairo, Egypt) and a location offset (5km N of that city)
// then store the primary location separately from the location offset in 2 Strings,
// so they can be displayed in 2 TextViews.
String primaryLocation;
String locationOffset;

// Check whether the originalLocation string contains the " of " text
if (originalLocation.contains(LOCATION_SEPARATOR)) {
    // Split the string into different parts (as an array of Strings)
    // based on the " of " text. We expect an array of 2 Strings, where
    // the first String will be "5km N" and the second String will be "Cairo, Egypt".
    String[] parts = originalLocation.split(LOCATION_SEPARATOR);
    // Location offset should be "5km N " + " of " --> "5km N of"
    locationOffset = parts[0] + LOCATION_SEPARATOR;
    // Primary location should be "Cairo, Egypt"
    primaryLocation = parts[1];
} else {
    // Otherwise, there is no " of " text in the originalLocation string.
    // Hence, set the default location offset to say "Near the".
    locationOffset = getContext().getString(R.string.near_the);
    // The primary location will be the full location string "Pacific-Antarctic Ridge".
    primaryLocation = originalLocation;
}
複製程式碼

(1)通過 contains(CharSequence cs) 判斷字串是否包含指定的字元,其中由於 String 是 CharSequence 的擴充套件類,所以這裡 CharSequence 作為輸入引數時,可以傳入 String。
(2)通過 split(String string) 根據輸入引數指定的位置對字串進行拆分,返回值為拆分後的字串陣列。拆分後的字串與輸入引數的匹配次數和位置有關,不包含輸入引數字元,詳細資訊可以到 Android Developers 網站檢視。
(3)除了上述操縱字串的 method,另外幾個常用的有 length() 獲取字串的字元數量,indexOf(String string) 返回輸入引數首次在字串匹配的位置索引,substring(int start, int end) 根據輸入引數指定的起止位置對字串進行裁剪,包括開始索引但不包括結束索引。

  1. 數字對齊

在 Quake Report App 中,需要將震級數字保留一位小數顯示,所以要格式化數字。與格式化時間類似,Android 提供了 DecimalFormat class 來處理這個問題。NumberFormat class 也可用於處理所有型別數字的格式,但它是一個抽象類, 而 DecimalFormat 是一個具象類,因此 DecimalFormat 相對而言比較簡單,特別是對於這種簡單的數字格式化需求。

// Get the magnitude from Earthquake object.
double magnitude = currentEarthquake.getMagnitude();
// Create a new DecimalFormat object by assigning the format of the digit.
DecimalFormat formatter = new DecimalFormat("0.0");
// Get the formatted magnitude digit.
String formattedMagnitude = formatter.format(magnitude);
複製程式碼

與 SimpleDateFormat 類似,在 DecimalFormat 中,數字格式通過字元表示:

Symbol Location Meaning
0 Number Digit(數字的佔位符)
# Number Digit, zero shows as absent(數字,但不顯示前導零)
. Number Decimal separator or monetary decimal separator
% Prefix or suffix Multiply by 100 and show as percentage

完整表格可以到 Android Developers 網站檢視。

  1. 圓形背景

為 Quake Report App 的 magnitude TextView 新增圓形背景,由於背景顏色需要根據震級大小變化,所以在這裡沒有新增多個不同顏色的影象資源,而是通過在 XML 中定義圓圈形狀,然後在 Java 中對顏色進行操作的方法實現,減少所需的資源數量。

(1)在 res/drawable 目錄下新增 New > Drawable resource file

In magnitude_circle.xml

<!-- Background circle for the magnitude value -->
<shape 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@color/magnitude1" />
    <size
        android:width="36dp"
        android:height="36dp" />
    <corners android:radius="18dp" />
</shape>
複製程式碼

android:shape 屬性設定為 oval(橢圓形),寬度、高度、轉角半徑三者配合好,畫出一個半徑為 18dp 的圓形。

(2)在 magnitude TextView 中應用 magnitude_circle.xml

android:background="@drawable/magnitude_circle"
複製程式碼

(3)在 Java 中操作背景顏色

// Fetch the background from the TextView, which is a GradientDrawable.
GradientDrawable magnitudeCircle = (GradientDrawable) magnitudeView.getBackground();
// Get the appropriate background color based on the current earthquake magnitude
int magnitudeColor = getMagnitudeColor(currentEarthquake.getMagnitude());
//  Set the color on the magnitude circle
magnitudeCircle.setColor(magnitudeColor);
複製程式碼

這裡新建了一個 GradientDrawable 物件,指向 magnitude TextView 的背景,最終通過 setColor method 來改變背景顏色。中間是一個輔助 method,根據當前的地震震級返回正確的顏色值,程式碼如下:

/**
 * Return the color for the magnitude circle based on the intensity of the earthquake.
 *
 * @param magnitude of the earthquake
 */
private int getMagnitudeColor(double magnitude) {
    int magnitudeColorResourceId;
    int magnitudeFloor = (int) Math.floor(magnitude);
    switch (magnitudeFloor) {
        case 0:
        case 1:
            magnitudeColorResourceId = R.color.magnitude1;
            break;
        case 2:
            magnitudeColorResourceId = R.color.magnitude2;
            break;
        case 3:
            magnitudeColorResourceId = R.color.magnitude3;
            break;
        case 4:
            magnitudeColorResourceId = R.color.magnitude4;
            break;
        case 5:
            magnitudeColorResourceId = R.color.magnitude5;
            break;
        case 6:
            magnitudeColorResourceId = R.color.magnitude6;
            break;
        case 7:
            magnitudeColorResourceId = R.color.magnitude7;
            break;
        case 8:
            magnitudeColorResourceId = R.color.magnitude8;
            break;
        case 9:
            magnitudeColorResourceId = R.color.magnitude9;
            break;
        default:
            magnitudeColorResourceId = R.color.magnitude10plus;
            break;
    }

    return ContextCompat.getColor(getContext(), magnitudeColorResourceId);
}
複製程式碼

(1)由於 GradientDrawable 的 setColor method 需要傳入 int argb,而不是顏色資源的 ID,所以這裡需要轉換一下,用到 ContextCompat.getColor method。

(2)由於震級數值是非布林型別的離散值,所以這裡引入一種新的 switch 流控語句,它可以替代 if-else 的多級巢狀,免除每一層都需要判斷變數值的重複工作。

  • switch 語句涉及了許多 Java 關鍵字,如 switchcasebreakdefault
  • switch 後的 () 內傳入需要執行的引數,隨後在 {} 內從上至下尋找 case 後匹配的資料,若輸入引數匹配其中一個 case 後的資料,則執行 : 下的程式碼,直到執行至 break;
  • 如果 switch 的輸入引數沒有匹配任何 case 後的資料,那麼程式碼會執行 default: 下的程式碼。雖然 default 程式碼不是強制的,但是為了增加程式碼的魯棒性,通常都會寫在 switch 語句的最後。
  • 如果 case 下的程式碼沒有 break;,那麼程式碼會執行到下一個 case,直到執行至 break;。因此,這種形式的程式碼實際上形成了一種或邏輯,例如上述一段程式碼的邏輯是,如果 magnitudeFloor 為 0 或 1,那麼 magnitudeColorResourceId 賦為 R.color.magnitude1,然後跳出 switch 語句。

(3)由於 switch 語句無法輸入 double 數值,所以這裡需要震級值轉換為 int,用到 Math.floor 將震級值的小數部分抹平。

  1. 佈局優化

如果要隱藏 ListView 專案間的分隔線,可以在 XML 中設定以下兩個屬性:

android:divider="@null"
android:dividerHeight="0dp"
複製程式碼

設定 TextView 的 ellipsizemaxLines 兩個屬性表示:如果 TextView 中的文字長度超過兩行,就可以在文字結尾處中新增省略號 ("..."),而不是隨內容增加行數。

android:ellipsize="end"
android:maxLines="2"
複製程式碼

相關文章