本章主要讲了如何使用 android 系统的网络连接,并介绍了格式化 JSON 和多线程编程 AsyncTask 的使用。另外,挑战练习里还结合了 Gson 库的使用。
GitHub 地址:
完成23章但未完成挑战
完成23章挑战1:使用 Gson
完成23章挑战2:添加分页
完成23章挑战3:动态调整网格列
1. 网络连接基本
首先要在 Manifest 文件中请求网络权限
然后我们建立一个网络请求的函数:
// FlickrFetchr.java
// 参数是 url 字符串,并且需要抛出 IO 错误
public byte[] getUrlBytes(String urlSpec) throws IOException {
URL url = new URL(urlSpec);
// 打开连接
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try {
// 建立两个流对象
ByteArrayOutputStream out = new ByteArrayOutputStream();
// 使用 getInputStream() 方法时才会真正发送 GET 请求
// 如果要使用 POST 请求,需要调用 getOutputStream()
InputStream in = connection.getInputStream();
// 如果连接失败就抛出错误
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new IOException(connection.getResponseMessage() +
": with" +
urlSpec);
}
// 建立一个计数器
int bytesRead = 0;
// 建立一个缓存 buffer
byte[] buffer = new byte[1024];
// 用 InputStream.read 将数据读取到 buffer 中,
// 然后写到 OutputStream 中
while ((bytesRead = in.read(buffer)) > 0) {
out.write(buffer, 0, bytesRead);
}
// 之后一定要关闭 OutputStream
out.close();
return out.toByteArray();
} finally {
// 最后要关闭连接
connection.disconnect();
}
}
public String getUrlString(String urlSpec) throws IOException {
// 将结果转换成 String
return new String(getUrlBytes(urlSpec));
}
2. 线程与主线程
网络连接需要时间,Web 服务器可能需要1~2秒的时间来响应访问请求,文件下载则耗时更久。考虑到这个因素,Android 禁止任何主线程网络连接行为。即使强行在主线程中进行网络连接,Android 也会抛出 NetworkOnMainThreadException 异常。
这是为什么呢?要想知道,首先要了解什么是线程,什么是主线程以及主线程的用途是什么。
线程是个单一执行序列。单个线程中的代码会逐步执行。所有 Android 应用的运行都是从主线程开始的。然而,主线程不是线程那样的预定执行序列。相反,它处于一个无限循环的运行状态,等待着用户或系统触发事件的发生。事件触发后,主线程便负责执行代码,以响应这些事件。
主线程运行着所有更新 UI 的代码,其中包括响应 activity 的启动、按钮的点击等不同 UI 相关事件的代码。(由于响应的事件基本都与用户界面相关,主线程有时也叫作 UI 线程。)
事件处理循环让 UI 代码得以按顺序执行。这可以保证任何事件处理都不会发生冲突,同时代码也能够快速响应执行。
而网络连接相比其他任务更耗时。等待响应期间,用户界面毫无反应,这可能会导致应用无响应(Application Not Responding,ANR)现象发生,也就是一个弹框,要求你关闭应用。
怎样使用后台线程最容易呢?答案就是使用 AsyncTask 类
3. AsyncTask
3.1 AsyncTask 的生命
AsyncTask 类可以重写的方法和一个进程的生命过程对应:
-
onPreExecute()
执行之前 -
onProgressUpdate()
更新进展 -
doInBackground()
在线程中真正要完成的事 -
onPostExecute()
完成之后要做的事(在 UI 线程中执行) -
onCancelled()
退出之后
3.2 AsyncTask 的三个参数
其中模板的三个类类型参数(不能是基础类型)分别是:输入、进度、结果。
3.2.1 第一个参数:输入
第一个类型参数可指定输入参数的类型。可参考以下示例使用该参数:
AsyncTask task = new AsyncTask() {
public Void doInBackground(String... params) {
for (String parameter : params) {
Log.i(TAG, "Received parameter: " + parameter);
}
return null;
}
};
输入参数传入 execute(...)方法(可接受一个或多个参数): task.execute("第一个参数", "第二个参数", "……");
然后,再把这些变量参数传递给 doInBackground(...)方法。
3.2.2 第二个参数:进度
第二个类型参数可指定发送进度更新需要的类型。以下为示例代码:
final ProgressBar gestationProgressBar = /* 一个特定的进度条 */;
gestationProgressBar.setMax(42); /* 最大的进度 */
AsyncTask haveABaby = new AsyncTask() {
public Void doInBackground(Void... params) {
while (!babyIsBorn()) {
Integer weeksPassed = getNumberOfWeeksPassed();
publishProgress(weeksPassed); // 关键,将参数发送到 onProgressUpdate
patientlyWaitForBaby();
}
}
public void onProgressUpdate(Integer... params) {
int progress = params[0];
gestationProgressBar.setProgress(progress);
}
};
/* call when you want to execute the AsyncTask */
haveABaby.execute();
进度更新通常发生在执行的后台进程中。问题是,在后台进程中无法完成必要的 UI 更新。因此 AsyncTask 提供了 publishProgress(...)和 onProgressUpdate(...)方法。
其工作方式是这样的 : 在后台线程中 , 从 doInBackground(...) 方法中调用 publishProgress(...)方法。这样 onProgressUpdate(...)方法便能够在 UI 线程上调用。因此,在 onProgressUpdate(...)方法中执行 UI 更新就可行了,但必须在 doInBackground(...) 方法中使用 publishProgress(...)方法对它们进行管控。
3.2.3 第三个参数:结果
第三个类型参数是处理结果返回的类型参数。下面是本章的示例代码
// PhotoGalleryFragment.java
private class FetchItemsTask extends AsyncTask> {
@Override
protected List doInBackground(Integer... params) {
return new FlickrFetchr().fetchItems(params[0]);
}
@Override
protected void onPostExecute(List galleryItems) {
mItems = galleryItems;
setAdapter();
}
}
第三个参数就是在 doInBackground 中返回的结果,我们需要从后台请求 API 返回的 JSON 数据,然后将其格式化,返回的就是我们需要的数据。
4. JSON 数据解析
什么是 JSON 数据呢?JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式。易于人阅读和编写。同时也易于机器解析和生成。它基于 JavaScript 的一个子集。JSON 采用完全独立于语言的文本格式,但是也使用了类似于C语言家族的习惯(包括C, C++, C#, Java, JavaScript, Perl, Python 等)。这些特性使 JSON 成为理想的数据交换语言。
JSON 对象是一系列包含在{ }中的名值对。JSON 数组是包含在[ ]中用逗号隔开的 JSON 对象列表。对象彼此嵌套形成层级关系。详细的语法可以查看JSON 官网。
JSON 这种数据格式在同样基于这些结构的编程语言之间交换十分方便,所以网络服务器端越来越多地开始用 JSON 来交换数据,我们在这章使用的 API 同样如此。
一个例子
// 为节省版面,去掉了无关的属性
{
"photos": {
"page": 1,
"pages": 10,
"photo": [
{
"id": "31987348504",
"title": "Penny",
"url_s": "https://farm3.staticflickr.com/2915/31987348504_9a949c482d_m.jpg",
},
{
"id": "31987352214",
"title": "",
"url_s": "https://farm1.staticflickr.com/455/31987352214_58428f3a9d_m.jpg",
}
]
},
"stat": "ok"
}
对应的解析代码:
// 解析时用 try…catch,要抛出 JSONException 防止程序崩溃
// JSONObject 构造方法解析传入的 JSON 数据后
// 会生成与原始 JSON 数据对应的对象树
JSONObject jsonBody = new JSONObject(jsonString);
// 顶层 JSONObject 对应着原始数据最外层的{ }。它包含了一个叫作 photos 的嵌套 JSONObject
JSONObject photosJsonObject = jsonBody.getJSONObject("photos");
// 这个嵌套对象又包含了一个叫作 photo 的 JSONArray
JSONArray photoJsonArray = photosJsonObject.getJSONArray("photo");
// 这个嵌套数组中又包含了一组 JSONObject
// 这些 JSONObeject 就是要获取的一张张图片的元数据
for (int i = 0; i < photoJsonArray.length(); i++) {
JSONObject photoJsonObject = photoJsonArray.getJSONObject(i);
GalleryItem item = new GalleryItem();
item.setId(photoJsonObject.getString("id"));
item.setCaption(photoJsonObject.getString("title"));
if (!photoJsonObject.has("url_s")) {
continue;
}
item.setUrl(photoJsonObject.getString("url_s"));
items.add(item);
}
解析完成后就可以在 AsyncTask 的 onPostExecute 中对 UI 进行更新了。
5. 挑战练习
本章的挑战练习难度依次递增,考验了我们很多知识。
5.1 使用 Gson 库解析 JSON 数据
Gson 是 Google 官方推荐的 JSON 解析库,使用 Gson 不用写任何解析代码,它能自动将 JSON 数据映射为 Java 对象。
5.1.1 添加 Gson 依赖
在 File -> Project Structure -> Dependencies 中添加 gson 依赖
5.1.2 构建对应的 POJO 类
由于不想更改原本的 GalleryItem 类,并且想让成员变量的命名符合 java 的命名规范,我使用了 @SerializedName()
注解,这个注解注明了 Gson 在转换时对应的键名。并且构建了一个新的类,用于匹配对应的 API 结构:
// PhotoBean.java
public class PhotoBean {
public static final String STATUS_OK = "ok"
, STATUS_FAILED = "fail";
@SerializedName("photos")
private PhotosInfo mPhotoInfo;
@SerializedName("stat")
private String mStatus;
@SerializedName("message")
private String mMessage;
public class PhotosInfo {
@SerializedName("photo")
List mPhoto;
public List getPhoto() {
return mPhoto;
}
}
// 省略 getter 和 setter
}
5.1.3 使用 Gson
Gson 的使用再简单不过了,与上面的代码相比有云泥之别:
PhotoBean photoBean = (PhotoBean) new Gson()
.fromJson(jsonString, PhotoBean.class);
不过记得要抛出 JsonSyntaxException。
5.2 分页显示
这个挑战的需求是:如果我们下滑最底部,就在后面添加下一页的内容。
所以在 url 的生成中我们还要加入 page 这个参数。我加入了一个成员变量 mNextPage 用于记录下次要请求的页面, 然后添加了一个常量 MAX_PAGES 用于控制最大请求页数。
5.2.1 RecyclerView.onScrollListener
onScrollListener 有两个可以重写的方法,一个是 onScrollStateChanged(),还有一个是 onScrolled,对我们这个需求来说,显然 onScrollStateChanged 比较合适,ScrollState 也有三种:
-
SCROLL_STATE_IDLE
: 视图没有被拖动,处于静止 -
SCROLL_STATE_DRAGGING
: 视图正在拖动中 -
SCROLL_STATE_SETTLING
: 视图在惯性滚动
这个挑战最关键的就是如何判断滑到最底端。首先滑动到最底端时前两个状态其实都可以,但是滑动到最底这个信息只有 LayoutManager 知道,我们可以直接看代码分析:
private RecyclerView.OnScrollListener onButtomListener =
new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
// 首先获取 LayoutManager
GridLayoutManager layoutManager = (GridLayoutManager) recyclerView.getLayoutManager();
// 然后可以找到最后显示的位置,一旦滚动就会获取该位置
mLastPosition = layoutManager.findLastCompletelyVisibleItemPosition();
// 如果静止的时候最后的位置大于等于数据个数
// 而且前一个任务完成时(防止多次重复)
if (newState == RecyclerView.SCROLL_STATE_IDLE
&& mLastPosition >= mPhotoAdapter.getItemCount() - 1) {
if (mFetchItemsTask.getStatus() == AsyncTask.Status.FINISHED) {
// 下一页加一,在小于最大页数时
// 弹出 Toast 表示正在加载
// 然后打开一个新任务,加载下一页
mNextPage++;
if (mNextPage <= MAX_PAGES) {
Toast.makeText(getActivity(), "waiting to load ……", Toast.LENGTH_SHORT).show();
// AsyncTask 只能执行一次,所以需要新建
mFetchItemsTask = new FetchItemsTask();
mFetchItemsTask.execute(mNextPage);
} else {
// 滑到最底提示已经到头了
Toast.makeText(getActivity(), "This is the end!", Toast.LENGTH_SHORT).show();
}
}
}
}
};
5.2.2 添加数据并展示
我在 Adapter 中加入了一个 addData 方法,将新的数据加入到数据集中,然后使用 notifyDataSetChanged 方法更新视图。
然后修改了 setAdapter 方法:
private void setAdapter() {
if (isAdded()) {
if (mPhotoAdapter == null) {
mPhotoAdapter = new PhotoAdapter(mItems);
mPhotoRecyclerView.setAdapter(mPhotoAdapter);
mPhotoRecyclerView.addOnScrollListener(onButtomListener);
} else {
mPhotoAdapter.addData(mItems);
}
}
}
5.3 动态调整网格列
使用 OnGlobalLayoutListener 即可:
mPhotoRecyclerView.getViewTreeObserver()
.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 计算列数,以 1080p 屏幕显示3列为基准
int columns = mPhotoRecyclerView.getWidth() / 350;
// 重新设置 LayoutManager、Adapter 和 Listener
mPhotoRecyclerView.setLayoutManager(new GridLayoutManager(getActivity(), columns));
mPhotoRecyclerView.setAdapter(mPhotoAdapter);
mPhotoRecyclerView.addOnScrollListener(onButtomListener);
// 滚动到之前看到的位置
mPhotoRecyclerView.getLayoutManager().scrollToPosition(mLastPosition);
//将 GlobalLayoutListener 去掉以避免多次触发
mPhotoRecyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
GitHub Page: kniost.github.io
:http://www.jianshu.com/u/723da691aa42