安卓学习日志 Day13 — 线程与并行

简介

现在已经在 Soonami 应用 中 成功使用网络来请求数据,那是否可以考虑 将网络应用到 QuakeReport 应用中呢?

答案是 No。我们在处理网络调用时需要许多操作及时发生,设备需要与无线信号连接 并发送请求,然后服务器必须创建响应并将其送回。这不知道要花费多少时间,而我们希望应用能够同时执行其他任务,例如 响应用户输入。

同时处理多个不同的任务,被称为多任务处理,每个任务可以看作是 Java 中的一个线程。

本文将通过一个简单的示例应用(叫做 Did You Feel It?),来了解这些线程与并行的基本概念,以及如何在 Android 应用中使用它们。最后会将这些都添加到 Quake Report 应用当中,以便从服务器中提取地震数据同时可以响应用户输入。

你感觉到了吗?

我们将简要探讨一个名为 “Did You Feel It?” 的新示例应用,该应用会查看特定地震 并告诉用户 人们感受到的地震强度。这些数据来自 USGS 网站,在 USGS 网站中有一个 Did You Feel It? 页面,从感觉到地震的人们那里收集信息,并绘制地图,显示人们的经历和破坏程度。

Did You Feel It? 应用的代码可以从 GitHub 仓库获得,使用 Git 命令克隆到初始代码:

git clone -b starting-point https://github.com/HEY-BLOOD/DidYouFeelIt.git

之后导入 到 Android Studio 中运行,程序会直接崩溃,且报错信息如下:

在这里插入图片描述

应用无法运行且提示存在一个叫做 android.os.NetworkOnMainThreadException 异常,从字面意思来看,好像是安卓系统中网络在主线程而产生的异常,事实也确实如此,这也是 Did You Feel It? 应用存在的意义。

主线程与后台进程

为什么 Did You Feel It? 应用崩溃了,NetworkOnMainThreadException 异常又到底意味着什么?

当出现 NetworkOnMainThreadException 异常时,在主线程中执行网络操作是不允许的,因为这会造成应用无响应或延迟。线程是保存指令序列的容器,目前为止 我们所写的所有代码都是 在主线程中执行的。主线程也称为 UI 线程,主线程可处理绘图操作,以响应用户输入(点击,滚动等),但主线程一次只能处理一个事件。所以 如果在同一时间发生多起事件,这些事件就会依次排在后面,等待前面的事件完成后再执行。

Android 系统能够同时运行多个线程, 因此可以相互独立的处理 两组或多组任务。如果有多个线程需要运行, Android 还会就何时运行哪些线程进行优先级排序, 并确定各线程的运行时长。

线程也知道如何保存其位置。它不仅会记录所有变量的值, 还会记住完成当前正在执行的指令 会调用的函数系列: 即能够重新完成工作所需的一切内容。 旨在利用多线程, 因此,在 Android 平台上构建应用并没有什么不同!

AsyncTask

在本文刚开始 测试运行 Did You Feel It? 应用时,出现了 NetworkOnMainThreadException异常,因为 Android 不允许开发人员在主线程进行网络请求,以至于造成应用无响应或延迟。

通常抛出一个异常使应用崩溃可以强制开发人员使用最优的办法,并且在后台线程运行网络操作,然后将结果返回给 UI 线程。就 Did You Feel It? 应用来说,并不需要线程的全部功能,只需要在一个单独的线程上,运行一个 HTTP 请求的任务即可,该线程不能是用于处理 UI 线程事件的线程。Android 框架工程师预料到了这会变成一个普通需求,并创建了专门的 Java 类使这一模式变得非常简单,这个类叫做 AsyncTask

AsyncTask 回调方法

AsyncTask 不同与之前所学的其他类型,如 Activity、View,它们都运行在主线程当中。而 AsyncTask 类的某些部分运行在主线程中,其他的部分运行在单独的后台线程中。

AsyncTask必须子类化才能使用。这个子类将覆盖至少一个方法 doInBackground (Params…),通常会覆盖第二个方法 onPostExecute (Result)

图片来自 https://www.runoob.com/w3cnote/android-tutorial-ansynctask.html

设计提示:理论上,应用中的内容是即时加载的。如果 未加载,请尝试允许用户与应用的其他部分进行交互, 以便用户不会坐在那里无所事事,或是观看进度条。

AsyncTask 泛型参数

AsyncTask 的泛型参数有三个,如:<Params, Progress, Result>。Params 作为输入,Progress 表示进度,Result 为运行结束后的返回结果。

官方示例如下:

Here is an example of subclassing:

 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");
     }
 }
 

Once created, a task is executed very simply:

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

修复 Did You Feel It?

  1. 创建 AsyncTask 的子类作为 MainActivity 类中的 私有内部类。 实现 doInBackground() 方法来获取地震数据并 返回结果。 实现 onPostExecute() 方法以根据我们的结果 更新 UI。

    私有内部类EarthquakeAsyncTask 定义如下:

    /**
     * Displays the perceived strength of a single earthquake event based on responses from people who
     * felt the earthquake.
     */
    public class MainActivity extends AppCompatActivity {
          
          
    
        …………
    
        private class EventAsyncTask extends AsyncTask<String, Void, Event> {
          
          
            @Override
            protected Event doInBackground(String... urls) {
          
          
                // Perform the HTTP request for earthquake data and process the response.
                Event earthquake = Utils.fetchEarthquakeData(USGS_REQUEST_URL);
                return earthquake;
            }
    
            @Override
            protected void onPostExecute(Event result) {
          
          
                // Update the information displayed to the user.
                updateUi(result);
            }
        }
    
    }
    
  2. 在 MainActivity 的 onCreate() 方法中创建内部类的实例, 并执行。

    public class MainActivity extends AppCompatActivity {
          
          
    
    	…………
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
          
          
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            EarthquakeAsyncTask task = new EarthquakeAsyncTask();
            task.execute(USGS_REQUEST_URL);
    
        }
        …………
    }
    
  3. 运行应用 效果如下(更改前后代码对比参考 此链接):

处理空或 null 情况

目前,所有的代码是针对最佳 情况编写的。假设 EarthquakeAsyncTask 通过预期的输入调用, 且在执行网络请求时未出现任何 错误。

如果…

  • 未来使用此应用的队友更改了代码,并试图以不输入任何参数的方式来执行 EarthquakeAsyncTask,这将如何?应用可能会崩溃,因为它假设至少为任务输入 1 个字符串。
  • USGS 的计算机存在内部服务器错误,无法将响应解析为 Event 对象,这又将如何?应用将尝试使用无效或空 Event 对象更新 UI。

试图进一步验证应用,以便接触代码的任何其他开发者 不会不小心在应用中导致 bug 或崩溃。 为此,需要将当前类以外的代码编写假设、 以及超出控制的假设 最小化。

如果可以接受任何输入(零输入、1 个 输入、2 个输入等)或处理任何意外行为(服务器 做出有效或无效响应),并做出得体的处理而不使应用崩溃, 那么就说明代码变得更加稳健。

为了这个目的,对 “你感觉到了吗?” 应用进行下列修改, 以处理 EarthquakeAsyncTask 中空或 Null 情况。

在 doInBackground 方法中,检查 url 数组是否至少具有 1 个 条目且第一个条目不为空。如果数组长度为 0 或第一个条目为空,则通过返回 null 提早离开 此方法。我们需要返回 null,因为需要有对象 作为返回值。如果有 1 个有效字符串 URL,则继续 获取数据。

  protected Event doInBackground(String... urls) {
    
    
      // 如果不存在任何 URL 或第一个 URL 为空,切勿执行请求。
      if (urls.length < 1 || urls[0] == null) {
    
    
         return null;
      }

      Event result = Utils.fetchEarthquakeData(urls[0]);
      return result;
  }

在 onPostExecute 方法中,如果不存在地震结果,则提早 返回。

  protected void onPostExecute(Event result) {
    
    
     // 如果不存在任何结果,则不执行任何操作。
     if (result == null) {
    
    
         return;
     }

     updateUi(result);
  }

下面是完整的 EarthquakeAsyncTask 类声明。

  /**
  * {@link AsyncTask} 用于在后台线程上执行网络请求,然后
  * 使用响应中的第一个地震更新 UI。
  */
  private class EarthquakeAsyncTask extends AsyncTask<String, Void, Event> {
    
    

     /**
      * 此方法在后台线程上激活(调用),因此我们可以执行
      * 诸如做出网络请求等长时间运行操作。
      *
      * 因为不能从后台线程更新 UI,所以我们仅返回 
      * {@link Event} 对象作为结果。
      */
     protected Event doInBackground(String... urls) {
    
    
         // 如果不存在任何 URL 或第一个 URL 为空,切勿执行请求。
         if (urls.length < 1 || urls[0] == null) {
    
    
             return null;
         }

         Event result = Utils.fetchEarthquakeData(urls[0]);
         return result;
     }

     /**
      * 此方法是在完成后台工作后,在主 UI 线程上
      * 激活的。
      *
      * 可以在此方法内修改 UI。我们得到 {@link Event} 对象
      * (该对象从 doInBackground() 方法返回),并更新屏幕上的视图。
      */
     protected void onPostExecute(Event result) {
    
    
         // 如果不存在任何结果,则不执行任何操作。
         if (result == null) {
    
    
             return;
         }

         updateUi(result);
     }
  }

最后编译并运行应用时,应该看起来和之前没有任何区别,代码更改前后对比参考 此链接。

要在 GitHub 上浏览“你感觉到了吗?”应用的完整和最终状态, 请单击此处

更改地震报告应用

这两天已经了解了足够多的概念,HTTP、URL、HttpURLConnection、线程等,所以是时候来完成挑战了。

现在 回到 Quake Report 应用当中,使用 在 Soonami 应用中学到的 HTTP 网络 和 Did You Feel It? 应用中学到的异步任务,来改进 Quake Report 应用,以便能够从 网络请求数据。

访问 安卓学习日志 Day11 — JSON 解析 回顾一下 Quake Report 应用。

我列出了一个 更改 Quake Report 应用的清单:

  • 在 QueryUtils 中移除硬编码的 JSON 响应
  • 在 QueryUtils 类中添加赋值方法来创建 URL 对象、执行网络请求、输入流转化为字符串、解析 JSON(此查询 将提供全球最近发生的震级至少为 5 级 的 最多20个地震)
  • 修改 JSON 解析方法从 Web 服务器响应中提取地震列表
  • 在 MainActivity 中声明内部类 EarthquakeAsyncTask 作为异步任务
  • 在 MainActivity.onCreate() 方法中实例化 EarthquakeAysncTask 并执行任务
  • 最后,添加所需的 Internet 权限

这里涉及大量步骤。我已经在下面以文本形式写出了这些编码步骤。

移除硬编码响应

首先应该移除 QueryUtils 类中硬编码的 JSON 响应,因此 在 QueryUtils 类中找到如下代码并删除:

/**
 * Sample JSON response for a USGS query
 */
private static final String SAMPLE_JSON_RESPONSE = ………… ;

这时在已有的 extractEarthquakes() 方法中 会引发一个 错误提示 Cannot resolve symbol 'SAMPLE_JSON_RESPONSE' 表示无法解释 SAMPLE_JSON_RESPONSE 这个变量,因为被删除了。

然后将我们提供的字符串 URL 作为静态最终常量存储在 EarthquakeActivity 文件中。我们在变量上使用 “private” 访问修饰符,因为除了 EarthquakeActivity 外,没有其他类需要引用它。

在 EarthquakeActivity.java 中:

    /**
     * URL for earthquake data from the USGS dataset
     */
    private static final String USGS_REQUEST_URL =
            "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&orderby=time&minmag=5&limit=20";

网络权限

首先在 AndroidManifest.xml 文件中声明 Internet 权限,以便应用能够访问网络。

在 AndroidManifest.xml 中:

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

基础轮廓

然后将我们提供的字符串 URL 作为静态最终常量存储在 EarthquakeActivity 文件中。我们在变量上使用 “private” 访问修饰符,因为除了 EarthquakeActivity 外,没有其他类需要引用它。

在 EarthquakeActivity.java 中:

    private class EarthquakeAsyncTask extends AsyncTask<String, Void, List<Earthquake>> {
    
    
        @Override
        protected List<Earthquake> doInBackground(String... urls) {
    
    
            return null;
        }

        @Override
        protected void onPostExecute(List<Earthquake> earthquakes) {
    
    
            
        }
    }

辅助方法

在 EarthquakeAsyncTask doInBackground() 方法中,我们需要 执行网络请求。我们的应用中尚无 执行该操作的代码。

  • 打开 QueryUtils.java 文件。添加下列新辅助方法。这些方法基本是从我们之前使用的 “Soonami” 和 “你感觉到了吗?” 应用中复制和粘贴的。

在 QueryUtils.java 中添加:

    /**
     * 从给定字符串 URL 返回新 URL 对象。
     */
    private static URL createUrl(String stringUrl) {
    
    
        URL url = null;
        try {
    
    
            url = new URL(stringUrl);
        } catch (MalformedURLException e) {
    
    
            Log.e(LOG_TAG, "Problem building the URL ", e);
        }
        return url;
    }

    /**
     * 向给定 URL 进行 HTTP 请求,并返回字符串作为响应。
     */
    private static String makeHttpRequest(URL url) throws IOException {
    
    
        String jsonResponse = "";

        // If the URL is null, then return early.
        if (null == url) {
    
    
            return jsonResponse;
        }

        HttpURLConnection urlConnection = null;
        InputStream inputStream = null;
        try {
    
    
            urlConnection = (HttpURLConnection) url.openConnection();
            urlConnection.setRequestMethod("GET");
            urlConnection.setReadTimeout(10000 /* milliseconds */);
            urlConnection.setConnectTimeout(15000 /* milliseconds */);
            urlConnection.connect();

            // 如果请求成功(响应代码 200),
            // 则读取输入流并解析响应。
            if (200 == urlConnection.getResponseCode()) {
    
    
                inputStream = urlConnection.getInputStream();
                jsonResponse = readFromStream(inputStream);
            } else {
    
    
                Log.e(LOG_TAG, "Error response code: " + urlConnection.getResponseCode());
            }

        } catch (IOException e) {
    
    
            Log.e(LOG_TAG, "Problem retrieving the earthquake JSON results.", e);
        } finally {
    
    
            if (null != urlConnection) {
    
    
                urlConnection.disconnect();
            }
            if (null != inputStream) {
    
    
                // 关闭输入流可能会抛出 IOException,这就是 makeHttpRequest(URL url) 方法签名
                // 指定可能抛出 IOException 的原因。
                inputStream.close();
            }
        }
        return jsonResponse;
    }

    /**
     * 将 {@link InputStream} 转换为包含
     * 来自服务器的整个 JSON 响应的字符串。
     */
    private static String readFromStream(InputStream inputStream) throws IOException {
    
    
        StringBuilder output = new StringBuilder();
        if (null != inputStream) {
    
    
            InputStream in;
            InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
            BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
            String line = bufferedReader.readLine();
            while (null != line) {
    
    
                output.append(line);
                line = bufferedReader.readLine();
            }
        }
        return output.toString();
    }

JSON 解析方法

修改处理 JSON 解析的 extractEarthquakes() 方法。我将其重命名为 extractFeatureFromJson() 并将 JSON 响应字符串作为输入。不是将 extractFeatureFromJson() 方法硬编码为只能解析硬编码 SAMPLE_JSON_RESPONSE 字符串,相反,如果我们接受字符串输入,此方法在不同环境中将更易于重用,(同时删除 已经不需要的 extractEarthquakes() 方法)。

在 QueryUtils.java 中:

    /**
     * 返回通过解析给定 JSON 响应构建的 {@link Earthquake} 对象
     * 列表。
     */
    private static List<Earthquake> extractFeatureFromJson(String earthquakeJSON) {
    
    
        // 如果 JSON 字符串为空或 null,将提早返回。
        if (TextUtils.isEmpty(earthquakeJSON)) {
    
    
            return null;
        }

        // 创建一个可以添加地震的空 ArrayList
        List<Earthquake> earthquakes = new ArrayList<>();

        // 尝试解析 JSON 响应字符串。如果格式化 JSON 的方式存在问题,
        // 则将抛出 JSONException 异常对象。
        // 捕获该异常以便应用不会崩溃,并将错误消息打印到日志中。
        try {
    
    

            // 通过 JSON 响应字符串创建 JSONObject
            JSONObject baseJsonResponse = new JSONObject(earthquakeJSON);

            // 提取与名为 "features" 的键关联的 JSONArray,
            // 该键表示特征(或地震)列表。
            JSONArray earthquakeArray = baseJsonResponse.getJSONArray("features");

            // 针对 earthquakeArray 中的每个地震,创建 {@link Earthquake} 对象
            for (int i = 0; i < earthquakeArray.length(); i++) {
    
    

                // 获取地震列表中位置 i 处的单一地震
                JSONObject currentEarthquake = earthquakeArray.getJSONObject(i);

                // 针对给定地震,提取与名为 "properties" 的键关联的 JSONObject,
                // 该键表示该地震所有属性的
                // 列表。
                JSONObject properties = currentEarthquake.getJSONObject("properties");

                // 提取名为 "mag" 的键的值
                double magnitude = properties.getDouble("mag");

                // 提取名为 "place" 的键的值
                String location = properties.getString("place");

                // 提取名为 "time" 的键的值
                long time = properties.getLong("time");

                // 提取名为 "url" 的键的值
                String url = properties.getString("url");

                // 使用 JSON 响应中的震级、位置、时间和 url,
                // 创建新的 {@link Earthquake} 对象。
                Earthquake earthquake = new Earthquake(magnitude, location, time, url);

                // 将该新 {@link Earthquake} 添加到地震列表。
                earthquakes.add(earthquake);
            }

        } catch (JSONException e) {
    
    
            // 在 "try" 块中执行上述任一语句时若系统抛出错误,
            // 则在此处捕获异常,以便应用不会崩溃。在日志消息中打印
            // 来自异常的消息。
            Log.e("QueryUtils", "Problem parsing the earthquake JSON results", e);
        }

        // 返回地震列表
        return earthquakes;
    }

完整辅助方法

添加 fetchEarthquakeData() 辅助方法,该方法将所有步骤连接在一起,即创建 URL、发送请求、处理响应、解析数据。由于这是 EarthquakeAsyncTask 需要交互的唯一 “public” QueryUtils 方法,因此将 QueryUtils 中所有其他辅助方法设置为 “private”。

在 QueryUtils.java 中:

/**
 * 查询 USGS 数据集并返回 {@link Earthquake} 对象的列表。
 */
 public static List<Earthquake> fetchEarthquakeData(String requestUrl) {
    
    
    // 创建 URL 对象
    URL url = createUrl(requestUrl);

    // 执行 URL 的 HTTP 请求并接收返回的 JSON 响应
    String jsonResponse = null;
    try {
    
    
        jsonResponse = makeHttpRequest(url);
    } catch (IOException e) {
    
    
        Log.e(LOG_TAG, "Problem making the HTTP request.", e);
    }

    // 从 JSON 响应提取相关域并创建 {@link Earthquake} 的列表
    List<Earthquake> earthquakes = extractFeatureFromJson(jsonResponse);

    // 返回 {@link Earthquake} 的列表
    return earthquakes;
 }

异步任务

到达 onPostExecute() 方法后,需要更新 ListView。更新列表内容的唯一方式是更新 EarthquakeAdapter 中的数据集。为访问并修改 EarthquakeAdapter 的实例,我们需要将其设置为 EarthquakeActivity 中的全局变量。

在 MainActivity.java 中:

    /** 地震列表的适配器 */
    private EarthquakeAdapter earthquakeAdapter;
  • 然后更新适配器的所有引用,以使用 earthquakeAdapter 变量名称。

在 MainActivity.java 中:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    
    
        super.onCreate(savedInstanceState);
        setContentView(R.layout.earthquake_activity);

        // 在布局中查找 {@link ListView} 的引用
        ListView earthquakeListView = (ListView) findViewById(R.id.list);

        // 创建新适配器,将空地震列表作为输入
        earthquakeAdapter = new EarthquakeAdapter(this, new ArrayList<Earthquake>());

        // 在 {@link ListView} 上设置适配器
        // 以便可以在用户界面中填充列表
        earthquakeListView.setAdapter(earthquakeAdapter);

        // 在 ListView 上设置项目单击监听器,该监听器会向 Web 浏览器发送 intent,
        // 打开包含有关所选地震详细信息的网站。
        earthquakeListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
    
    
            @Override
            public void onItemClick(AdapterView<?> adapterView, View view, int position, long l) {
    
    
                // 查找单击的当前地震
                Earthquake currentEarthquake = earthquakeAdapter.getItem(position);

                // 将字符串 URL 转换成 URI 对象(传递到 Intent 构造函数中)
                Uri earthquakeUri = Uri.parse(currentEarthquake.getUrl());

                // 创建新 intent 以查看地震 URI
                Intent websiteIntent = new Intent(Intent.ACTION_VIEW, earthquakeUri);

                // 发送 intent 以启动新活动
                startActivity(websiteIntent);
            }
        });
    }

现在可以在 EarthquakeAsyncTask onPostExecute() 方法中引用该适配器。

在 EarthquakeAsyncTask(位于 EarthquakeActivity 内)中:

        @Override
        protected void onPostExecute(List<Earthquake> earthquakes) {
    
    
            // 清除之前地震数据的适配器
            earthquakeAdapter.clear();

            // 如果存在 {@link Earthquake} 的有效列表,则将其添加到适配器的
            // 数据集。这将触发 ListView 执行更新。
            if (!earthquakes.isEmpty() && null != earthquakes) {
    
    
                earthquakeAdapter.addAll(earthquakes);
            }
        }

以及在后台线程上运行并执行网络请求的 doInBackground() 方法:

        @Override
        protected List<Earthquake> doInBackground(String... urls) {
    
    
            // 如果不存在任何 URL 或第一个 URL 为空,切勿执行请求。
            if (urls.length < 1 || urls[0] == null) {
    
    
                return null;
            }

            List<Earthquake> earthquakes = QueryUtils.fetchEarthquakeData(urls[0]);
            return earthquakes;
        }

现在可以初始化并执行任务,并在请求 URL 常量中进行传递。

在 EarthquakeActivity.java 中:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    
    
        super.onCreate(savedInstanceState);
        setContentView(R.layout.earthquake_activity);

        ………………

        // 启动 AsyncTask 以获取地震数据
        EarthquakeAsyncTask task = new EarthquakeAsyncTask();
        task.execute(USGS_REQUEST_URL);
    }

完成所有的更改后编译并允许,应用应如下所示!(这一部分更改了大量的代码,代码更改前后对比差异参考 此链接

Loader

看起来现在已经挺不错,但是 AsyncTask 存在一个缺陷。当我们打开应用时,Mainctivity 会首先初始化布局,然后在后台线程中 从网络请求数据。如何在请求数据时将屏幕转为横向,那么竖屏时的 Activity 就会被销毁并重新创建一个适用于横屏的 Activity,但原本竖屏时的 Activity 的后退线程仍然在请求数据,这些请求的数据是不必要的,因为 用于显示数据的 Activity 已经没有了。而且,如果连续在横屏和竖屏之间选择多次,就会创建出多个后台线程并运行,这会造成严重的资源浪费。

顺带一提,在 API level 30 中 AsyncTask 已经被弃用了,官方推荐使用 standard java.util.concurrent or Kotlin concurrency utilities 来代替。

AsyncTask 适用于处理许多事情,但对于更改用户界面的数据来说 却不是最佳选择,对此 我们需要使用 Loader。

  • 无论从 Loader 上请求多少次数据,默认情况下 Loader 只获取一次数据,因此不会产生大量的 Activity 和 AsyncTask。
  • 当一个 Activity 停止时它会自动告知所有 Loader 退出正在进行的任务,因为数据已经不会被显示了。
  • Loader 会始终保留整个 Activity 的配置更改,比如 旋转。

因此,当我们启用 Loader 获取数据 同时旋转屏幕,获取数据后 Loadr 会自动将数据返回至新的 Activity。

AsyncTaskLoader

AsyncTaskLoader 具有 Loader 和 AsyncTask 相同的功能,可以像 AsyncTask 一样在后台线程执行操作,同时又能像 Loader 一样很方便得用于加载数据。

AsyncTaskLoader 需要了解的方法有:

  • loadInBackground():与 AsyncTask 中的 doInBackground() 方法原理相同,运行在后台线程中。
  • onLoadFinished():对应 AsyncTask 中的 onPostExecute() 方法

更多方法请查看官方文档。

使用 Loader

将 AsyncTask 改成使用 AsyncTaskLoader 需要做三件事:

  • 自定义 EarthquakeLoader 加载器,继承自 AsyncTaskLoader 抽象类。
  • 在 EarthquakeActivity 中实现 LoaderManager.LoaderCallbacks\<Earthquake\> 接口
  • 在 EarthActivity 创建后,新建或重用现有的 Loader

为定义 EarthquakeLoader 类,扩展 AsyncTaskLoader 并 指定 List 作为泛型参数,该参数说明了 预期加载的数据类型。在本例中,loader 正在 加载 Earthquake 对象的列表。然后,将获取 构造函数和 loadInBackground()中的字符串 URL, 我们将返回 EarthquakeAsyncTask,执行与在 doInBackground 中完全相同的操作。

重要信息:请注意,还需要覆盖 onStartLoading() 方法 来调用 forceLoad() 方法,必须执行该步骤才能实际触发 loadInBackground() 方法的执行。

新建 EarthquakeLoader.java 如下:

package com.example.quakereport;

import android.content.Context;

import androidx.loader.content.AsyncTaskLoader;

import java.util.List;

/**
 * 通过使用 AsyncTask 执行
 * 给定 URL 的网络请求,加载地震列表。
 */
public class EarthquakeLoader extends AsyncTaskLoader<List<Earthquake>> {
    
    

    /**
     * 日志消息标签
     */
    private static final String LOG_TAG = EarthquakeLoader.class.getName();

    /**
     * 查询 URL
     */
    private String url;

    /**
     * 构建新 {@link EarthquakeLoader}。
     * <p>
     * 活动的 @param 上下文
     * 要从中加载数据的 @param url
     */
    public EarthquakeLoader(Context context, String url) {
    
    
        super(context);
        this.url = url;
    }

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

    /**
     * 这位于后台线程上。
     */
    @Override
    public List<Earthquake> loadInBackground() {
    
    
        if (null == url) {
    
    
            return null;
        }

        // 执行网络请求、解析响应和提取地震列表。
        List<Earthquake> earthquakes = QueryUtils.fetchEarthquakeData(url);
        return earthquakes;
    }
}

在活动中实现 LoaderCallbacks 有些 复杂。首先,需要用于实现 LoaderCallbacks 接口的 EarthquakeActivity ,以及用于 指定 loader 返回内容(本例中为 Earthquake 列表)的泛型参数。

在 EarthquakeActivity.java 中更改类的声明:

public class EarthquakeActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<List<Earthquake>> {
    
    
    …………
}

顺便提一下,要导入以下语句,以便可以 在代码中引用这些类。

 import android.app.LoaderManager;
 import android.loader.content.Loader;

首先,需要为 loader 指定 ID。仅当在同一活动中使用 多个 loader 时才真正相关。我们 ,可选择任意整数选择数字 1。

    /**
     * 地震 loader ID 的常量值。可选择任意整数。
     * 仅当使用多个 loader 时该设置才起作用。
     */
    public static int EARTHQUAKE_LOADER_ID = 1;

然后,覆盖在 LoaderCallbacks 接口中指定的三个方法。

需要 onCreateLoader(),前提是 LoaderManager 已确定具有指定的 ID 的 loader 当前未 运行,因此需要新建一个。

    @NonNull
    @Override
    public Loader<List<Earthquake>> onCreateLoader(int id, @Nullable Bundle args) {
    
    
        // 为给定 URL 创建新 loader
        return new EarthquakeLoader(this, USGS_REQUEST_URL);
    }

需要 onLoadFinished() - 将在其中执行与 在 onPostExecute() 中完全相同的操作,并使用地震数据更新 UI - 通过 更新适配器中的数据集。

    @Override
    public void onLoadFinished(@NonNull Loader<List<Earthquake>> loader, List<Earthquake> earthquakes) {
    
    
        // 清除之前地震数据的适配器
        earthquakeAdapter.clear();

        // 如果存在 {@link Earthquake} 的有效列表,则将其添加到适配器的
        // 数据集。这将触发 ListView 执行更新。
        if (!earthquakes.isEmpty() && earthquakes != null) {
    
    
            earthquakeAdapter.addAll(earthquakes);
        }
    }

需要 onLoaderReset(),系统会通知 loader 的数据不再有效。实际上, 简单的 loader 不会出现这样的情况,正确的做法是 通过清理适配器的数据集 移除 UI 中的所有地震数据。

    @Override
    public void onLoaderReset(@NonNull Loader<List<Earthquake>> loader) {
    
    
        // 重置 Loader,以便能够清除现有数据。
        earthquakeAdapter.clear();
    }

最后,要检索地震,需要获取 loader 管理器并 告知 loader 管理器使用指定 ID 初始化 loader, 第二个参数可用于传递一系列附加信息, 这里不一一介绍。第三个参数是 应接收 LoaderCallbacks(以及加载 完成时的数据!)的对象- 也就是本活动将执行的操作。此代码 将进入 EarthquakeActivity 的 onCreate() 方法,以便 打开应用时可初始化 loader。

 @Override
 protected void onCreate(Bundle savedInstanceState) {
    
    // 引用 LoaderManager,以便与 loader 进行交互。
    LoaderManager loaderManager = getSupportLoaderManager();

    // 初始化 loader。传递上面定义的整数 ID 常量并作为捆绑
    // 传递 null。为 LoaderCallbacks 参数(由于
    // 此活动实现了 LoaderCallbacks 接口而有效)传递此活动。
    loaderManager.initLoader(EARTHQUAKE_LOADER_ID, null, this);
 }

现在可以安全地删除在 EarthquakeActivity 中 声明的 EarthquakeAsyncTask 类。还应移除 用于在活动的 onCreate() 方法中创建和执行 任务的代码。同时删除文件顶部的 AsyncTask 导入 语句。

在设备上运行应用时,不应出现任何错误, 应该看到与之前一样的地震列表 - 只是 其中的代码更加稳健!

这里不再提供运行效果的截图,更改代码前后对比参考 此链接

验证 Loader 行为

Loader 在什么时候会加载数据,可以通过在自定义的 EarthquakeLoader 类和 LoaderManager.LoaderCallbacks接口 的方法中添加日志,以此来获得通知。

首先,在 EarthquakeActivity 中添加日志:

public class EarthquakeActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<List<Earthquake>> {
    
    
        …………

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    
    
        Log.i(LOG_TAG, "TEST: onCreate() called ...");

        
        …………

        // 初始化 loader。传递上面定义的整数 ID 常量并作为捆绑
        // 传递 null。为 LoaderCallbacks 参数(由于
        // 此活动实现了 LoaderCallbacks 接口而有效)传递此活动。
        Log.i(LOG_TAG, "TEST: loaderManager.initLoader() called ...");
        loaderManager.initLoader(EARTHQUAKE_LOADER_ID, null, this);
    }

    @NonNull
    @Override
    public Loader<List<Earthquake>> onCreateLoader(int id, @Nullable Bundle args) {
    
    
        Log.i(LOG_TAG, "TEST: onCreateLoader() called ...");

        …………
    }

    @Override
    public void onLoadFinished(@NonNull Loader<List<Earthquake>> loader, List<Earthquake> earthquakes) {
    
    
        Log.i(LOG_TAG, "TEST: onLoadFinished() called ...");

        …………
        }
    }

    @Override
    public void onLoaderReset(@NonNull Loader<List<Earthquake>> loader) {
    
    
        Log.i(LOG_TAG, "TEST: onLoaderReset() called ...");

        …………
    }
}

然后是 EarthquakeLoader:

public class EarthquakeLoader extends AsyncTaskLoader<List<Earthquake>> {
    
    

        …………

    @Override
    protected void onStartLoading() {
    
    
        Log.i(LOG_TAG, "TEST: onStartLoading() called ...");

        …………
    }

    /**
     * 这位于后台线程上。
     */
    @Override
    public List<Earthquake> loadInBackground() {
    
    
        Log.i(LOG_TAG, "TEST: loadInBackground() called ...");
        
        …………
    }
}

以及 QueryUtils 类的 fetchEarthquakeData() 方法:

    public static List<Earthquake> fetchEarthquakeData(String requestUrl) {
    
    
        Log.i(LOG_TAG, "TEST: fetchEarthquakeData() called ...");
        
        …………
    }

代码更改完成后(更改前后差异对比), 测试以下场景,观察日志输入:

  • 旋转设备
  • 转至主屏幕并返回到应用
  • 按返回按钮
  • 打开近期任务
  • 切换到不同的应用
  • 返回到应用
  • 走进自己的场景!

下面是我测试的日志截图:

显示启动应用 > 旋转设备 > 切到主屏幕再返回应用

其中注意可以看出,Loader 只在首次启动应用时调用了 onCreateLoader() 方法,而当旋转设备和 切到主屏幕再返回应用 时都没有再重新创建 Loader 实例,而是使用了现有的。

完善用户体验

列表的空状态

有时候应用没有数据可显示(例如 在邮件收件箱中 没有新的消息),作为开发人员就需要恰当得处理这种情况。设计较好的应用甚至通过放置图像,让用户获得比较愉快的体验,甚至还有更好的应用会用一些 初学者内容或 入门建议来填充空白区域,这些会根据应用的适用内容而发生变化。

Android 团体考虑到了这会成为一个普通的应用需求,所以在 ListView 中已经预留了当显示数据为空时 显示空白视图的 setEmptyView() 方法,应用会自动处理 数据列表视图和 空白视图的可见性。

下面将对 Quake Report 应用进行更改,使应用在服务器没有返回数据时显示 自定义的空白视图。

第一个任务,修改 earthquake_activity 布局。 添加父 RelativeLayout 而非单一 ListView, 因为 RelativeLayout 允许子视图相互重叠。第一个 子视图为 ListView,其宽度/高度与父视图相匹配,占满 整个屏幕。第二个子视图为 TextView,该视图为 空视图。TextView 具有高度和宽度 wrap_content,通过属性在父视图中垂直水平地居中放置 该子视图

更改 earthquake_activity.xml 文件如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- Layout for a list of earthquakes -->
    <ListView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:divider="@null"
        android:dividerHeight="0dp"
        android:orientation="vertical" />

    <!-- 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>

现在,需要将 TextView 作为 ListView 的空视图 进行挂接。可使用 ListView.setEmptyView() 方法。也可以使 空状态 TextView 成为全局变量,以便在 后面的方法中进行引用。只要使用 TextView 类, 便会将该类自动导入到 java 文件中。

在 EarthquakeActivity.java 中:

    /**
     * 列表为空时显示的 空视图
     */
    private TextView emptyView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    
    
        Log.i(LOG_TAG, "TEST: onCreate() called ...");

        super.onCreate(savedInstanceState);
        setContentView(R.layout.earthquake_activity);

        // 在布局中查找 {@link ListView} 的引用
        ListView earthquakeListView = (ListView) findViewById(R.id.list);

        // 为 ListView 绑定空视图,在无数据时显示
        emptyView = (TextView) findViewById(R.id.empty_view);
        earthquakeListView.setEmptyView(emptyView);
        
        …………
    }

为避免首次启动应用时屏幕中闪现“未发现地震。(No earthquakes found.)”消息, 可将空状态 TextView 留空, 直至完成第一次加载。在 onLoadFinished 回调 方法中,可将文本设置为字符串“ 未发现地震。(No earthquakes found.)”由于此操作的代价在承受范围内,所以 可在每次 loader 完成时设置此文本。 经过权衡之后,如此操作能获得更好的用户体验。

在 EarthquakeActivity.java 中:

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

还要记得在资源文件中将空状态消息声明为 字符串,以便可将应用本地化为其他语言(如果 日后决定支持这些语言)。

  <!-- Text to display in the list when there are no earthquakes [CHAR LIMIT=NONE] -->
  <string name="no_earthquakes">No earthquakes found.</string>

为了验证列表视图的空状态,可以将 USGS 网站的 URL 请求地址中的 limit 参数改为 0,这样服务器将返回 0 条数据,从而达到列表视图的空状态。

在 EarthquakeActivity.java 中更改:

    /**
     * URL for earthquake data from the USGS dataset
     */
    private static final String USGS_REQUEST_URL =
            "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&orderby=time&minmag=5&limit=0";

更改前后代码差异,在设备上运行应用以检查其是否运行。下面是我在设备上的运行截图,正常运行:

加载指示符

首先,向布局添加 ProgressBar 视图。可以 参阅常见 Android 视图备忘表来查看 XML 中的 ProgressBar 示例。我已将该视图添加为 RelativeLayout 的最后一个子视图,它将显示在其他 子视图的上方。将高度和宽度设置为“wrap_content”, 视图居中位于屏幕中央。样式 “@style/Widget.AppCompat.ProgressBar” 可使 ProgressBar 显示 为圆形加载指示符。

在 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"/>

    <!-- 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"/>

 </RelativeLayout>

在 EarthquakeActivity.java 中,完成第一次加载后 - 即调用 onLoadFinished() 时, 隐藏加载指示符(通过将 将可见性设置为 View.GONE)。

在 EarthquakeActivity.java 中:

@Override
 public void onLoadFinished(Loader<List<Earthquake>> loader, List<Earthquake> earthquakes) {
    
    
    // 因数据已加载,隐藏加载指示符
    View loadingIndicator = findViewById(R.id.loading_indicator);
    loadingIndicator.setVisibility(View.GONE);

    // 将空状态文本设置为显示“未发现地震。(No earthquakes found.)”
    mEmptyStateTextView.setText(R.string.no_earthquakes);

    // 清除之前地震数据的适配器
    mAdapter.clear();

    // 如果存在 {@link Earthquake} 的有效列表,则将其添加到适配器的
    // 数据集。这将触发 ListView 执行更新。
    if (earthquakes != null && !earthquakes.isEmpty()) {
    
    
        mAdapter.addAll(earthquakes);
    }
 }

在设备上运行应用,若网络调用耗时较长 应显示加载指示符。有时,网络 连接较快,以至于加载指示符在屏幕上显示时间很短 不足以被人眼捕捉到。

我的运行结果(网络比较差),代码更改前后差异参考 此链接

检查网络连接状态

人们常犯的一种错误就是只在最理想的场景中测试应用,比如 在家里或办公室进行开发,并且拥有流畅的网络连接。但作为专业的开发人员,有必要考虑到应用会在一些不理想或模棱两可的情况下使用,发生预料之外的情况,比如在网速较慢时应用会显示加载指示符来告知用户请求仍在进行。但是用户也可能会在完全没有 Internet 连接的地点使用应用(比如 乘坐飞机),那这时候会发生什么情况呢?应用会显示空白 还是意外崩溃呢?

那就测试一下,首先确保 QuakeReport 应用没有在运行 或 没有在后台运行 —— 打开手机的飞行模式 —— 再次启动应用,这时候应用意外崩溃了,那么看看应用崩溃时,到底出现了什么错误, 运行报错的截图如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U4lxwUsX-1611686697608)(.\Day13~2021-01-24.assets\image-20210127003209036.png)]

List 的 isEmpty() 被一个空对象调用了,因而 抛出了一个 NullPointException 空指针异常,并且产生异常的代码在 EarthauqkeActivity.java 文件的 140 行,如下:

        if (!earthquakes.isEmpty() && earthquakes != null) {
    
    

这是由于 if 判断的表达式中 从左到右 进行判断会先调用 isEmpty() 方法,而此时 调用该方法的对象为 null,故而抛出 NullPointException 空指针异常。

解决方法,交换 if 判断中 && 符合两边的表达式,如下:

		if (earthquakes != null && !earthquakes.isEmpty()) {
    
    

因此这并不是手机的飞行模式而使应用崩溃,代码的逻辑错误,修改了错误,重新以飞行模式的状态运行应用,这时运行结果如下:

这时候出现了 “没有发现地震” 的空白视图,这是因为 在发送 HTTP 网络请求时 将 Json 响应默认初始化为空字符串 "",同时在解析 JSON响应方法中 将 Json 响应为 "" 时返回的地震数据列表 设置成了空值,因此 显示 “No earthquakes found.” 空白视图。

这是一个缺陷,明明在无网络连接的飞行模式下启动应用, 可偏偏显示 “No earthquakes found.” 空白视图,就会让用户认为最近没有发送任何地震,显然这是错误的。

解决思路倒也简单,假设可以先检查网络连接的状态,如果网络是连接的,则进行正常的数据请求及显示操作,反之 提示用户 No internet connection. 无网络连接。

在网上经过得出一个解决方法:

  • 访问网络连接状态的前提是,应用必须具备访问网络状态的权限
  • 在获得中 获取 ConnectivityManager 对象,它是一个 Android 系统中用于 管理连接的服务
  • ConnectivityManager 对象中获取包含网络信息的 NetworkInfo 对象
  • NetworkInfo 对象判断网络的连接状态,并执行响应的操作

那么,首先是添加访问网络状态的权限,在清单文件 AndroidManifest.xml 中更改:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.quakereport">

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

    ………………

</manifest>

在活动创建时首先获取 ConnectivityManager 对象,再在从 ConnectivityManager 对象中获取包含网络信息的 NetworkInfo 对象并判断网络的连接状态。若有网络连接 则进行正常的数据请求与显示操作,否则 显示无网络连接的提示。

这一系列 的判断网络连接操作可以封装为一个辅助方法,方便在各处调用,会有更好的灵活性。

在 EarthquakeActivity 中定义辅助方法 checkNetworkConnection()

    /**
     * Check for connectivity status
     *
     * @return Network connection status
     */
    private boolean checkNetworkConnection() {
    
    
        // 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();

        // Check for connectivity status
        return networkInfo != null && networkInfo.isConnectedOrConnecting();
    }

根据网络连接状态执行不同的操作,更改 EarthquakeActivity.onCreate() 如下:

        // If there is a network connection, fetch data
        if (checkNetworkConnection()) {
    
    
            // 引用 LoaderManager,以便与 loader 进行交互。
            LoaderManager loaderManager = getSupportLoaderManager();

            // 初始化 loader。传递上面定义的整数 ID 常量并作为捆绑
            // 传递 null。为 LoaderCallbacks 参数(由于
            // 此活动实现了 LoaderCallbacks 接口而有效)传递此活动。
            Log.i(LOG_TAG, "TEST: loaderManager.initLoader() called ...");
            loaderManager.initLoader(EARTHQUAKE_LOADER_ID, null, this);
        } else {
    
    
            loadSpinner = findViewById(R.id.loading_spinner);
            loadSpinner.setVisibility(View.GONE);
            emptyView.setText(R.string.no_internet_connection);
        }

此处省略了部分 成员变量的添加与修改,更详细的代码更改查看 此链接

启动应用,确保应用在有无网络连接时都能按预期的所运行:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wEJmzQjI-1611686697610)(.\Day13~2021-01-24.assets\image-20210127022337214.png)]

总结

通过示例应用,深入了解网络主题的核心部分(线程与并行),了解 HTTP 请求的端对端路径,然后通过 USGS 中的请求数据实时更新地震报告应用,并在应用中显示全世界最近发生的地震。

参考

关于线程的 Android Performance Patterns Youtube视频,演讲者为 Google 技术推广工程师 Colt McAnlis。

AsyncTask | Android Developers

AsyncTask Android example - Stack Overflow

使用 AsyncTask 进行后台处理

查看 何时使用 List何时可能使用 ArrayList 与 LinkedList 中有关 StackOverflow 的热烈讨论。

Loaders | Android Developers

LoaderManager | Android Developers

LoaderManager.LoaderCallbacks | Android Developers

AsyncTaskLoader | Android Developers

Android AsyncTaskLoader Example with ListView and BaseAdapter

Android: Simple Loader and LoaderManager for loading data example

How to Use Loaders in Android | Grokking Android

Showing empty view when ListView is empty

什么是“飞行模式”?

How do you check the internet connection in android?

如何从“最近任务”列表中强制停止应用

猜你喜欢

转载自blog.csdn.net/weixin_45075891/article/details/113100275