Android编程权威指南(第二版)学习笔记(二十三)—— 第23章 HTTP 与后台任务

2/13/2017来源:iOS开发人气:881

本章主要讲了如何使用 android 系统的网络连接,并介绍了格式化 JSON 和多线程编程 AsyncTask 的使用。另外,挑战练习里还结合了 Gson 库的使用。

GitHub 地址: 完成23章但未完成挑战 完成23章挑战1:使用 Gson 完成23章挑战2:添加分页 完成23章挑战3:动态调整网格列

1. 网络连接基本

首先要在 Manifest 文件中请求网络权限

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

然后我们建立一个网络请求的函数:

// 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<String,Void,Void> task = new AsyncTask<String,Void,Void>() { 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<Void,Integer,Void> haveABaby = new AsyncTask<Void,Integer,Void>() { 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<Integer, Void, List<GalleryItem>> { @Override protected List<GalleryItem> doInBackground(Integer... params) { return new FlickrFetchr().fetchItems(params[0]); } @Override protected void onPostExecute(List<GalleryItem> 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<GalleryItem> mPhoto; public List<GalleryItem> 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); } });