課程 3: 執行緒與並行

weixin_33716557發表於2018-01-20

這節課是 Android 開發(入門)課程 的第三部分《訪問網路》的第三節課,導師是 Chris Lei 和 Joe Lewis。這節課在前兩節課的基礎上將網路傳輸的程式碼新增到 Quake Report App 中,重點介紹多工處理的概念和處理方案,最後完善一些功能細節和 UI 優化。

關鍵詞:Threads & Parallelism、AsyncTask Class、Inner Class、Loader、Empty States、ProgressBar、ConnectivityManager

Multitasking

在 Android 中,網路操作 (Networking) 需要經歷建立連線、等待響應等耗時較長的過程,應用在執行這類任務時,期間為了保持活躍狀態,不被使用者認為應用無響應,所以應用需要同時進行其它任務,這就是多工處理 (Multitasking) 的概念。在 Java 中可以把一個或一系列任務看成一個執行緒 (Thread)(不是程式 (Process),兩者的差異可以檢視這篇 Android Developers 文件),多執行緒形成並行 (Parallelism) 的結構關係。

執行緒是儲存指令(任務)序列的容器,指令預設儲存在主執行緒 (Main Thread) 中,主執行緒通常處理繪製佈局,響應使用者互動等任務,所以也叫做 UI 執行緒。執行緒內的任務是序列的,當同一時間內發生多起事件時,任務會放到佇列 (Queue) 中,按順序依次執行。就像在高速公路的出入口,收費站只開了一個視窗,車輛只能排隊一個一個地繳費。

在 Quake Report App 中,如果網路操作放在主執行緒中,那麼應用在完成網路操作前就無法進行其它任務,例如響應使用者的點選事件;如果此時使用者認為應用無響應,反覆點選螢幕的話,主執行緒的任務佇列就會越來越長;當 Android 判斷應用的主執行緒被阻塞 (block) 超過一定時間(五秒左右),就會觸發 ANR (Application Not Responding),彈出對話方塊告知使用者應用無響應,並詢問使用者是否要關閉它。

9639656-629ccf5b96dd1844.png
ANR 對話方塊示例

事實上,Android 瞭解網路操作阻塞主執行緒是一個經常發生的場景,所以 Android 不允許將網路操作放在主執行緒中,它會觸發 NetworkOnMainThreadException 異常使應用崩潰。因此這裡需要引入後臺執行緒 (Background Thread),也叫工作執行緒 (Worker Thread),將網路操作放入一個獨立的後臺執行緒中,這樣一來,即使網路操作的耗時較長,也不會影響主執行緒響應使用者互動。關於保持應用迅速響應的更多討論可以檢視這篇 Android Developers 文件

AsyncTask

Android 提供了 AsyncTask Class 用於引入後臺執行緒,它適用於短期的一次性任務,相對其它工作執行緒比較簡單。AsyncTask 是一個抽象類,它可以在後臺執行緒中執行任務,任務完成後將結果傳遞給主執行緒以更新 UI(不能在後臺執行緒中更新 UI)。資料在主執行緒與後臺執行緒之間的傳遞,是通過 AsyncTask 中執行在不同執行緒的 method 完成的。四個常用的 AsyncTask method 之間的關係和所線上程如下表。

Method When is it called Thread
onPreExecute Before the task is executed Main
doInBackground After onPreExecute Background
onProgressUpdate After publishProgress is called, while onPreExecute is executing Main
onPostExecute After doInBackground finish Main
  1. 所有在後臺執行緒執行的指令都放在 doInBackground 中,這是一個必須實現的抽象 method。
  2. 在執行 doInBackground 中的後臺執行緒的指令之前,可以將一些必要的準備指令放入在主執行緒執行的 onPreExecute 中。
  3. 對於一些耗時很長的後臺執行緒,可以在 doInBackground 中呼叫 publishProgress 並傳入進度值,然後在 onProgressUpdate 中更新任務進度,實現進度條的功能。因為 onProgressUpdate 執行在主執行緒,所以它可以更新 UI。
  4. doInBackground 中的後臺執行緒的指令執行完畢後,會執行 onPostExecute 中的指令,輸入引數為 doInBackground 的返回值;因為 onPostExecute 執行在主執行緒,所以可以將返回值用於更新 UI,實現主執行緒與後臺執行緒之間的資料傳遞。

下面是一段 AsyncTask 的程式碼示例。

private class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
     protected Long doInBackground(URL... urls) {
         int count = urls.length;
         long totalSize = 0;
         for (int i = 0; i < count; i++) {
             totalSize += Downloader.downloadFile(urls[i]);
             publishProgress((int) ((i / (float) count) * 100));
             // Escape early if cancel() is called
             if (isCancelled()) break;
         }
         return totalSize;
     }

     protected void onProgressUpdate(Integer... progress) {
         setProgressPercent(progress[0]);
     }

     protected void onPostExecute(Long result) {
         showDialog("Downloaded " + result + " bytes");
     }
 }

這裡建立了一個 AsyncTask 的自定義類 DownloadFilesTask。AsyncTask 是一個泛型類,輸入引數為三個泛型引數:Params、Progress、Result。泛型引數與抽象類和介面的概念類似,它是引數化的資料型別,在具體實現時需要指定資料型別,在使用時必須傳入對應的資料型別,這稱為型別安全 (Type Safety)。

Note:
1. 泛型資料型別 (Generic Type) 必須是物件資料型別,所以指定 void 時需要寫成它的物件型別 Void;類似地,在指定原始資料型別時,也要寫成對應的物件型別:

Primitive Data Types Object Data Types
int Integer
boolean Boolean
short Short
long Long
double Double
float Float
byte Byte
char Character

2. 資料型別後面的 ... 表示可傳入任意數量的引數,稱為可變引數 (Variable Argument, abbr. Varargs)。可變引數可看作是陣列,兩者訪問元素的方法相同,即在變數名後加 [index] 按索引訪問,例如上面的 urls[i]progress[0]

在 DownloadFilesTask 中分別將 Params、Progress、Result 三個泛型引數指定為 URL、Integer、Long。

(1)Params > URL
Params 引數指後臺執行緒的任務的輸入引數,也就是 doInBackground(Params... params) 的形參。它是可變引數,實參通過在主執行緒中呼叫 execute(Params... params) 傳入。例如 DownloadFilesTask 在主執行緒中傳入三個 URL 引數:

new DownloadFilesTask().execute(url1, url2, url3);

然後在 doInBackground 中通過 urls[i] 使用傳入的三個 URL 引數。

Note: 通常 doInBackground 需要考慮 Params 實參為空的異常情況,若未正確處理會導致應用崩潰,這裡可以新增 if-else 語句判斷如果不存在 Params 引數,那麼提前返回 null,不執行下面的任務。

(2)Progress > Integer
Progress 引數指後臺執行緒的任務的執行進度,是 publishProgress(Progress... values)onProgressUpdate(Progress... values) 兩個進度相關的 method 的輸入引數。它是可變引數,所以對於執行多個任務的 AsyncTask 來說,可以生成多個 Progress 引數,通過 progress[index] 分別訪問每個引數。在 DownloadFilesTask 中 Progress 引數指定為 Integer,通過在 doInBackground 呼叫 publishProgress 並傳入 Progress 引數,然後在 onProgressUpdate 根據 Progress 輸入引數更新進度。

Note: 對於一些短期的後臺執行緒,不需要實現進度條的情況,可以將 Progress 引數指定為 Void(注意首字母大寫),無需實現 onProgressUpdate method。

(3)Result > Long
Result 引數指後臺執行緒的任務的輸出結果,也就是 doInBackground(Params... params) 的返回值,同時也是 onPostExecute 的輸入引數。這一點符合邏輯,後臺執行緒的任務完成後,將結果傳入隨後在主執行緒執行的 method 中進行 UI 更新。注意 Result 引數不是可變引數。

Note: 通常 onPostExecute 需要考慮 Result 實參為 null 的異常情況,若未正確處理會導致應用崩潰,這裡可以新增 if-else 語句判斷如果 Result 引數為 null,那麼提前結束 method (如 return;),不執行下面的任務。

Inner Class

在 AsyncTask 中,後臺執行緒的任務完成後通常需要在 onPostExecute 根據任務結果來更新 UI,此時 onPostExecute 需要引用 Activity 的檢視,說明 AsyncTask 要與 Activity 緊密合作,因此 AsyncTask class 通常作為 Activity 的內部類 (Inner Class),包括在 Activity 內,宣告為 private,而不是作為一個單獨的 Java Class 檔案。這樣一來,不僅精簡了程式碼,減少了 Java 檔案的數量;AsyncTask 也能夠訪問 Activity 內的全域性變數與 method 了,例如 AsyncTask 能夠在 onPostExecute 對 Activity 內的一個全域性變數 TextView 呼叫 setText method,實現更新 UI 操作。

Loader

在 Quake Report App 中,AsyncTask 作為 EarthquakeActivity 的內部類,並且在 onCreate 建立並執行了 AsyncTask。這意味著當 EarthquakeActivity 建立時,也會建立一個 AsyncTask 在後臺執行緒執行網路操作任務。如果在 AsyncTask 完成後臺執行緒的任務之前,裝置切換了旋轉方向,Android 為了能夠顯示正確的檢視會重新建立一個 EarthquakeActivity ,此時又會建立並執行一個新的 AsyncTask;之前的 Activity 會被銷燬並回收記憶體,但此時卻無法回收正在進行網路操作的 AsyncTask,只能等待任務完成後才能回收;而且即使 AsyncTask 完成了任務,從網路獲取的資料也不再有用,因為 Activity 已經被銷燬了。如果使用者頻繁旋轉裝置,每次建立新的 Activity 也會建立一個 AsyncTask,導致裝置重複進行無意義的網路操作,消耗大量記憶體。這些問題的解決方案是 Loader。

當例如旋轉裝置、更改語言等裝置配置變更 (Device Configuration Changes) 發生在應用執行時 (Runtime),預設情況下 Android 會使 Activity 重啟,而 Loader 不受此影響,它會保持已有資料,在新的 Activity 建立後,將資料傳給新的 Activity。另外,Loader 在 Activity 被永久銷燬時,也會跟著銷燬,不會造成多餘的操作。

引入 Loader

// Get a reference to the LoaderManager, in order to interact with loaders.
LoaderManager loaderManager = getLoaderManager();

// Initialize the loader. Pass in the int ID constant defined above and pass in null for
// the bundle. Pass in this activity for the LoaderCallbacks parameter (which is valid
// because this activity implements the LoaderCallbacks interface).
loaderManager.initLoader(EARTHQUAKE_LOADER_ID, null, this);

在 Activity 的 onCreate() 或 Fragment 的 onActivityCreated() 通過例項化 LoaderManager 引入 Loader,隨後呼叫 initLoader 初始化,需要傳入三個引數:

  1. ID: 識別 Loader 的唯一標識,可以是任意數字。當應用內有多個 Loader 時,就根據 ID 區分每一個 Loader。
  2. args: 傳入建構函式的可選引數,如無設定為 null
  3. LoaderCallbacks<D> callback: Loader 的回撥物件,可設定為 this 表示回撥物件即 Activity 本身,回撥函式放在 Activity 內,在 Activity 類名後面新增 implements 引數,例如在 Quake Report App 中:
public class EarthquakeActivity extends AppCompatActivity
        implements LoaderCallbacks<List<Earthquake>> {
    ...

    // LoaderManager.LoaderCallbacks inside EarthquakeActivity class.
}

Note:
1. 一個 Activity 或 Fragment 內只有一個 LoaderManager,它可以管理多個 Loader。
2. 如果在 initLoader 傳入的 ID 已經屬於一個 Loader,那麼就會使用那個 Loader。這就是裝置配置變更不會產生新的 Loader 造成記憶體浪費的原因,因為新的 Activity 執行 initLoader 時會發現傳入的 ID 已屬於之前建立的 Loader。如果 ID 之前不存在,那麼就會呼叫 onCreateLoader() 回撥函式建立一個新的 Loader。
3. initLoader 的返回值為 Loader 物件,但並不需要獲取它 (capture a reference to it),LoaderManager 會自動管理 Loader 物件。因此,開發者幾乎不需要直接操作 Loader,往往是通過回撥函式來處理資料載入的事件。

實現 LoaderManager.LoaderCallbacks 的三個回撥函式

@Override
public Loader<List<Earthquake>> onCreateLoader(int i, Bundle bundle) {
    // Create a new loader for the given URL
    return new EarthquakeLoader(this, USGS_REQUEST_URL);
}

@Override
public void onLoadFinished(Loader<List<Earthquake>> loader, List<Earthquake> earthquakes) {
    // Hide loading indicator because the data has been loaded
    View loadingIndicator = findViewById(R.id.loading_indicator);
    loadingIndicator.setVisibility(View.GONE);

    // Set empty state text to display "No earthquakes found."
    mEmptyStateTextView.setText(R.string.no_earthquakes);

    // Clear the adapter of previous earthquake data
    mAdapter.clear();

    // If there is a valid list of {@link Earthquake}s, then add them to the adapter's
    // data set. This will trigger the ListView to update.
    if (earthquakes != null && !earthquakes.isEmpty()) {
        mAdapter.addAll(earthquakes);
    }
}

@Override
public void onLoaderReset(Loader<List<Earthquake>> loader) {
    // Loader reset, so we can clear out our existing data.
    mAdapter.clear();
}
  1. onLoadFinished: 當 Loader 在後臺執行緒完成資料載入後呼叫,有兩個輸入引數,分別為 Loader 例項和後臺執行緒的任務的執行結果。此時應該根據結果更新 UI。

  2. onLoaderReset: 當 Loader 被重置時呼叫,意味著其載入的資料不再有用,此時應該清除應用獲取的資料。

  3. onCreateLoader: 建立並返回一個新的 Loader,通常是 CursorLoader。在 Quake Report App 中,建立並返回了一個 AsyncTaskLoader 的自定義類。

AsyncTaskLoader 是一個 Loader 的子類,能夠由 LoaderManager 管理,而實際的工作是由 AsyncTask 完成的。同時 AsyncTaskLoader<D> 也是一個泛型類,輸入引數為泛型引數 D,是 doInBackground 抽象 method 的返回值資料型別。在 Quake Report App 中,新建一個 Java Class 檔案,實現 AsyncTaskLoader 的自定義類 EarthquakeLoader。

In EarthquakeLoader.java

/**
 * Loads a list of earthquakes by using an AsyncTask to perform the
 * network request to the given URL.
 */
public class EarthquakeLoader extends AsyncTaskLoader<List<Earthquake>> {
    /** Tag for log messages */
    private static final String LOG_TAG = EarthquakeLoader.class.getName();

    /** Query URL */
    private String mUrl;

    /**
     * Constructs a new {@link EarthquakeLoader}.
     *
     * @param context of the activity
     * @param url to load data from
     */
    public EarthquakeLoader(Context context, String url) {
        super(context);
        mUrl = url;
    }

    @Override
    protected void onStartLoading() {
        forceLoad();
    }

    /**
     * This is on a background thread.
     */
    @Override
    public List<Earthquake> loadInBackground() {
        if (mUrl == null) {
            return null;
        }

        // Perform the network request, parse the response, and extract a list of earthquakes.
        List<Earthquake> earthquakes = QueryUtils.fetchEarthquakeData(mUrl);
        return earthquakes;
    }
}
  1. 將泛型引數 D 指定為 List<Earthquake> 物件,在 doInBackground 中建立並返回物件例項。List<Earthquake> 物件在三個回撥函式中都以 Loader<List<Earthquake>> 作為單獨的物件傳入,稱為 BLOB。
  2. 呼叫 initLoader 時會自動呼叫 onStartLoading,此時應該呼叫 forceLoad 啟動 Loader。

綜上所述,在 Quake Report App 中 AsyncTaskLoader 的工作流程為:

  1. 在 EarthquakeActivity 的 onCreate() 例項化 LoaderManager,隨後呼叫 initLoader 初始化 Loader。
  2. initLoader 自動呼叫 onStartLoading,此時呼叫 forceLoad 啟動 Loader。
  3. Loader 啟動後,在後臺執行緒執行 doInBackground 建立並返回 List<Earthquake> 物件。
  4. 當 Loader 載入資料完畢時,會通知 LoaderManager 將資料傳入 onLoadFinished 更新 UI。
  5. 如果 EarthquakeActivity 被銷燬,LoaderManager 也會銷燬對應的 Loader,然後呼叫 onLoaderReset 表示當前載入的資料已無效,此時將應用獲取的資料清除。
  6. 如果在 Loader 完成載入資料之前 EarthquakeActivity 被銷燬,LoaderManager 不會銷燬對應的 Loader,也不會呼叫 onLoaderReset,而是隨後將載入好的資料傳入新的 EarthquakeActivity 中。
功能實現和佈局優化
  1. Empty States

當 ListView 沒有元素或其它物件無法顯示時,應用預設顯示空白,為了提供更好的使用者體驗,應用應該處理這種空狀態 (Empty States) 的情況,解決方案可以參考 Material Design

在 Quake Report App 中,為 ListView 新增一個空檢視。首先在 XML 中新增一個 TextView,ID 為 "empty_view"。

In earthquake_activity.xml

<RelativeLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent">

     <ListView
         android:id="@+id/list"
         android:orientation="vertical"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:divider="@null"
         android:dividerHeight="0dp"/>

     <!-- Empty view is only visible when the list has no items. -->
     <TextView
         android:id="@+id/empty_view"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_centerInParent="true"
         android:textAppearance="?android:textAppearanceMedium"/>
  </RelativeLayout>

然後在 Java 中設定 TextView 為 ListView 的 EmptyView。

In EarthquakeActivity.java

private TextView mEmptyStateTextView;

@Override
protected void onCreate(Bundle savedInstanceState) {
    …
    mEmptyStateTextView = (TextView) findViewById(R.id.empty_view);
    earthquakeListView.setEmptyView(mEmptyStateTextView);
    … 
}

為了避免在啟動應用時螢幕首先顯示 ListView 的空檢視, 所以不在 XML 中設定 TextView 的文字,而在 onLoadFinished 中,完成資料載入後,將文字設定為 "No earthquakes found." 字串。

In EarthquakeActivity.java

@Override
public void onLoadFinished(Loader<List<Earthquake>> loader, List<Earthquake> earthquakes) {
    // Set empty state text to display "No earthquakes found."
    mEmptyStateTextView.setText(R.string.no_earthquakes);
    …
}

測試空狀態時,可以把 onLoadFinished 內更新 UI 的指令註釋掉,使應用接收不到 Loader 載入的資料。記得在測試完畢後取消註釋。

  1. Progress and Activity Indicator

應用在載入內容時應該使用進度和活動指示符 (Progress and Activity Indicator) 告知使用者當前的狀態,例如視訊緩衝進度。Material Design 提供了線性和圓形兩種指示符,可分為確定指示符 (Determinate Indicator) 用於明確知道任務進度的場景,如檔案下載的進度;以及不確定指示符 (Indeterminate Indicator) 用於不明確進度的場景,如從網路重新整理推文。

Android 提供了 ProgressBar 來實現進度指示符。首先在 XML 中新增一個 ProgressBar,ID 為 "loading_indicator"。

In earthquake_activity.xml

<!-- Loading indicator is only shown before the first load -->
<ProgressBar
    android:id="@+id/loading_indicator"
    style="@style/Widget.AppCompat.ProgressBar"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true"/>

在應用啟動後,ProgressBar 就會在螢幕中顯示,直到完成資料載入時,在 onLoadFinished 中隱藏 ProgressBar。

In EarthquakeActivity.java

@Override
public void onLoadFinished(Loader<List<Earthquake>> loader, List<Earthquake> earthquakes) {
    View loadingIndicator = findViewById(R.id.loading_indicator);
    loadingIndicator.setVisibility(View.GONE);
    …
}

測試進度指示符時,可以使用 Thread.sleep(2000); 強制後臺執行緒睡眠兩秒鐘,使開發者有充分的時間觀察進度指示符。注意 Thread.sleep() method 需要處理 InterruptedException 異常,所以要把它放進 try/catch 區塊中。

try {
    Thread.sleep(2000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
  1. Network Connectivity Status

應用在進行網路操作時會遇到裝置無蜂窩或 Wi-Fi 連線的情況,將這一情況告知使用者是一種好的做法。在 Android 中通過 ConnectivityManager 來檢查裝置的連線狀態,並作出相應的處理方案。

(1)請求 ACCESS_NETWORK_STATE 許可權

In AndroidManifest.xml

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

這是一個正常許可權,Android 會自動授予應用該許可權,無需使用者介入。

(2)例項化 ConnectivityManager 並獲取裝置的連線狀態

In EarthquakeActivity.java

// Get a reference to the ConnectivityManager to check state of network connectivity
ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);

// Get details on the currently active default data network
NetworkInfo networkInfo = connMgr.getActiveNetworkInfo();

(3)利用 if-else 語句根據裝置的連線狀態作出處理方案

In EarthquakeActivity.java

// If there is a network connection, fetch data
if (networkInfo != null && networkInfo.isConnected()) {
    // Get a reference to the LoaderManager, in order to interact with loaders.
    LoaderManager loaderManager = getLoaderManager();

    // Initialize the loader. Pass in the int ID constant defined above and pass in null for
    // the bundle. Pass in this activity for the LoaderCallbacks parameter (which is valid
    // because this activity implements the LoaderCallbacks interface).
    loaderManager.initLoader(EARTHQUAKE_LOADER_ID, null, this);
} else {
    // Otherwise, display error
    // First, hide loading indicator so error message will be visible
    View loadingIndicator = findViewById(R.id.loading_indicator);
    loadingIndicator.setVisibility(View.GONE);

    // Update empty state with no connection error message
    mEmptyStateTextView.setText(R.string.no_internet_connection);
}

當裝置已連線網路的情況下才開始通過 LoaderManager 從網路獲取資料,當裝置無連線時將 ListView 的空檢視顯示為 "No Internet Connection."

Tips:
1. 在 Quake Report App 中使用了 ArrayList,與它相似的有 LinkedList,兩者都屬於 List 介面的具象類。如果 App 需要重構程式碼,由 ArrayList 改為 LinkedList,那麼就要修改多處程式碼,這很麻煩。因此最佳做法是,無論 ArrayList 還是 LinkedList,只要使用 List 物件,就使用 List,僅在物件例項的定義處指定一個具象類即可。這樣可以保持程式碼的靈活性。例如:

List<Earthquake> earthquakeList = new ArrayList<Earthquake>();

List<Earthquake> earthquakeList = new LinkedList<Earthquake>();

2. 在 Quake Report App 中使用了 ArrayAdapter 的兩個 method,分別為 mAdapter.clear() 表示清除所有元素資料,mAdapter.addAll(data) 表示將 data 新增到介面卡的資料集中。

3. 在 Android Studio 中選擇 File > New > Import Sample...,在彈出的對話方塊搜尋關鍵字,可以匯入 Google 提供的示例應用。例如搜尋 Network 可以找到 Network Connect App,它使用了 HttpsURLConnection 進行網路操作,AsyncTask 作為 Activity 的內部類,實現從 google.com 獲取前 500 個 HTML 響應字元的功能。

9639656-5289dcd5ee1c3fea.png

如果遇到無法下載示例目錄的情況 (Failed to download samples index, please check your connection and try again),檢查 Android Studio 中 Preference 的 HTTP Proxy 選項是否選中 Auto-detect proxy settings。選中此選項可以讓 Android Studio 通過系統代理科學上網。如果仍無法解決問題,所有示例應用也可以在 Android Developers 網站 中找到。

9639656-f13bcbcb1395d02b.png

相關文章