这节课是 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),弹出对话框告知用户应用无响应,并询问用户是否要关闭它。
事实上,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 |
- 所有在后台线程执行的指令都放在
doInBackground
中,这是一个必须实现的抽象 method。 - 在执行
doInBackground
中的后台线程的指令之前,可以将一些必要的准备指令放入在主线程运行的onPreExecute
中。 - 对于一些耗时很长的后台线程,可以在
doInBackground
中调用publishProgress
并传入进度值,然后在onProgressUpdate
中更新任务进度,实现进度条的功能。因为onProgressUpdate
运行在主线程,所以它可以更新 UI。 - 在
doInBackground
中的后台线程的指令执行完毕后,会执行onPostExecute
中的指令,输入参数为doInBackground
的返回值;因为onPostExecute
运行在主线程,所以可以将返回值用于更新 UI,实现主线程与后台线程之间的数据传递。
下面是一段 AsyncTask 的代码示例。
private class DownloadFilesTask extends AsyncTask {
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
初始化,需要传入三个参数:
- ID: 识别 Loader 的唯一标识,可以是任意数字。当应用内有多个 Loader 时,就根据 ID 区分每一个 Loader。
- args: 传入构造函数的可选参数,如无设置为
null
。 - LoaderCallbacks
callback: Loader 的回调对象,可设置为 this
表示回调对象即 Activity 本身,回调函数放在 Activity 内,在 Activity 类名后面添加implements
参数,例如在 Quake Report App 中:
public class EarthquakeActivity extends AppCompatActivity
implements LoaderCallbacks> {
...
// 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> 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> loader, List 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> loader) {
// Loader reset, so we can clear out our existing data.
mAdapter.clear();
}
onLoadFinished
: 当 Loader 在后台线程完成数据加载后调用,有两个输入参数,分别为 Loader 实例和后台线程的任务的运行结果。此时应该根据结果更新 UI。onLoaderReset
: 当 Loader 被重置时调用,意味着其加载的数据不再有用,此时应该清除应用获取的数据。onCreateLoader
: 创建并返回一个新的 Loader,通常是 CursorLoader。在 Quake Report App 中,创建并返回了一个 AsyncTaskLoader 的自定义类。
AsyncTaskLoader 是一个 Loader 的子类,能够由 LoaderManager 管理,而实际的工作是由 AsyncTask 完成的。同时 AsyncTaskLoaderdoInBackground
抽象 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> {
/** 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 loadInBackground() {
if (mUrl == null) {
return null;
}
// Perform the network request, parse the response, and extract a list of earthquakes.
List earthquakes = QueryUtils.fetchEarthquakeData(mUrl);
return earthquakes;
}
}
- 将泛型参数 D 指定为 List
对象,在 doInBackground
中创建并返回对象实例。List对象在三个回调函数中都以 Loader - > 作为单独的对象传入,称为 BLOB。
- 调用
initLoader
时会自动调用onStartLoading
,此时应该调用forceLoad
启动 Loader。
综上所述,在 Quake Report App 中 AsyncTaskLoader 的工作流程为:
- 在 EarthquakeActivity 的
onCreate()
实例化 LoaderManager,随后调用initLoader
初始化 Loader。 -
initLoader
自动调用onStartLoading
,此时调用forceLoad
启动 Loader。 - Loader 启动后,在后台线程执行
doInBackground
创建并返回 List对象。 - 当 Loader 加载数据完毕时,会通知 LoaderManager 将数据传入
onLoadFinished
更新 UI。 - 如果 EarthquakeActivity 被销毁,LoaderManager 也会销毁对应的 Loader,然后调用
onLoaderReset
表示当前加载的数据已无效,此时将应用获取的数据清除。 - 如果在 Loader 完成加载数据之前 EarthquakeActivity 被销毁,LoaderManager 不会销毁对应的 Loader,也不会调用
onLoaderReset
,而是随后将加载好的数据传入新的 EarthquakeActivity 中。
功能实现和布局优化
- Empty States
当 ListView 没有元素或其它对象无法显示时,应用默认显示空白,为了提供更好的用户体验,应用应该处理这种空状态 (Empty States) 的情况,解决方案可以参考 Material Design。
在 Quake Report App 中,为 ListView 添加一个空视图。首先在 XML 中添加一个 TextView,ID 为 "empty_view"。
In earthquake_activity.xml
然后在 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> loader, List earthquakes) {
// Set empty state text to display "No earthquakes found."
mEmptyStateTextView.setText(R.string.no_earthquakes);
…
}
测试空状态时,可以把 onLoadFinished
内更新 UI 的指令注释掉,使应用接收不到 Loader 加载的数据。记得在测试完毕后取消注释。
- 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
在应用启动后,ProgressBar 就会在屏幕中显示,直到完成数据加载时,在 onLoadFinished
中隐藏 ProgressBar。
In EarthquakeActivity.java
@Override
public void onLoadFinished(Loader> loader, List 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();
}
- Network Connectivity Status
应用在进行网络操作时会遇到设备无蜂窝或 Wi-Fi 连接的情况,将这一情况告知用户是一种好的做法。在 Android 中通过 ConnectivityManager 来检查设备的连接状态,并作出相应的处理方案。
(1)请求 ACCESS_NETWORK_STATE
权限
In AndroidManifest.xml
这是一个正常权限,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 earthquakeList = new ArrayList();
或
List earthquakeList = new LinkedList();
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 响应字符的功能。
如果遇到无法下载示例目录的情况 (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 网站 中找到。