安卓基础之网络请求

前面提到的webview只能算作是安卓应用网络技术的一部分。作为客户端,安卓也可以向服务器发送HTTP请求,然后处理服务器返回的数据。年初花了一个多月的时间学习HTTP协议,现在主要整理安卓中网络请求的相关知识,主要还是与Web中的Ajax进行对比学习。

<!--more-->

参考:

1. HttpURLConnection

安卓内置了两套处理http请求的接口,HttpClientHttpURLConnection。查了相关的资料,貌似在Android 6.0之后HttpClient已经被废弃了,因此现在学习HttpURLConnection就可以了(这就是后学者的优势,也是几年后我们这批人的劣势~~)。

跟使用Ajax一样,在安卓中一个完整的网络请求包括

  • 实例化一个请求对象
  • 指定请求方法,设置相关参数
  • 发送请求
  • 处理返回结果

下面来看看在安卓中具体的代码实现

1.1. 请求实例

获取HttpURLConnection对象的方式十分简单

URL url = new URL("http://10.0.2.2:9999/android");
connection = (HttpURLConnection)url.openConnection();

这跟Ajax貌似有点不一样,安卓中是先实例化一个URL对象,然后调用该对象的openConnection获得对应的请求实例

xhr.open("http://10.0.2.2:9999/android")

1.2. 请求参数

常规的请求方法还是GETPOST两种,通过setRequestMethod方法指定,至于GET和POST两个方法的区别这里就不扯了。

connection.setRequestMethod("POST");

由于不存在像网页一样由服务端渲染页面,因此处理双端的数据传输就十分重要(感觉用RESTful非常合适啊)。起初我也以为参数设置跟Ajax一样简单,然!而!并!不!是!这!样!的!

由于POST请求将数据放在请求报文主体中,而GET请求是将数据拼在链接后面,因此处理方式有些不一样。先来看看POST的

1.2.1. POST

// 允许向请求报文填入数据 
connection.setDoOutput(true);
// 设置请求头
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
// 获取输出对象
OutputStream out = connection.getOutputStream();

// 接下来就是完成设置参数的工作了
Map<String,String> params = new HashMap<String, String>();
params.put("name", "txm");
// 这里封装了一个getRequestData方法
byte[] data = getRequestData(params, "utf-8").toString().getBytes();
// 最后向输出对象中写入参数
out.write(data);

上面这些代码看起来还很正常,按部就班来嘛,然后就是实现这个getRequestData方法(来源: android (java) 网络发送get/post请求参数设置

public static StringBuffer getRequestData(Map<String, String> params, String encode) {
    // 存储封装好的请求体信息
    StringBuffer stringBuffer = new StringBuffer();        
    try {
        for(Map.Entry<String, String> entry : params.entrySet()) {
            stringBuffer.append(entry.getKey())
                    .append("=")
                    .append(URLEncoder.encode(entry.getValue(), encode))
                    .append("&");
        }

          //删除最后的一个"&"
        stringBuffer.deleteCharAt(stringBuffer.length() - 1);    
    } catch (Exception e) {
        e.printStackTrace();
    }
    return stringBuffer;
}

实现这个方法的理由是OutputStream对象的write方法需要的是byte[]类型的参数。感觉自己呵呵哒~里面的好几个概念都不会:StringBuffer,URLEncoder,这个先去补一补吧:

  • StringBuffer类中的方法主要偏重于对于字符串的变化,比如上面出现的appenddeleteCharAt方法
  • URLEncoder类包含将字符串转换为application/x-www-form-urlencoded MIME 格式的静态方法,这个应该跟JS中的encodeURI类似

1.2.2. GET

我们知道GET方法是通过在URL后拼接参数来进行传递的,因此比POST方法要简单一些,下面自己写了一个格式化GET请求url的方法。

public String formateGetUrl(String url, Map<String, String> params){

    // 拼接链接参数
    StringBuilder sb = new StringBuilder();
    sb.append(url).append("?");
    try {
        for(Map.Entry<String, String> entry : params.entrySet()){
            sb.append(entry.getKey()).append("=").append(URLEncoder.encode(entry.getValue(), "utf-8"));
            sb.append("&");
        }
        sb.deleteCharAt(sb.length() - 1);
    }catch (Exception e){
        e.printStackTrace();
    }

    return sb.toString();
}

主要实现是通过StringBuilder类提供的方法对参数进行拼接(在JS中直接模板字符串就搞定了),然后需要做的是根据返回的带参数的url实例化对应的URL对象啥的

可以看见,使用GET方法对参数的处理是在获取connection实例之前进行的,而使用POST方法对参数的处理是在获取connection实例之后进行的(相对麻烦一点)。这点跟Ajax比较像:通过open方法的链接上带参数(GET方法),或者在send方法中传参数(POST方法)

1.3. 发送请求

对参数的处理完成之后,就应该是发送请求了。在此之前,还可以对整个请求进行相关设置,比如指定请求方法,超时时间等

connection = (HttpURLConnection)url.openConnection();
connection.setRequestMethod("GET");

然后建立连接

connection.connect();

这里有个比较蛋疼的问题:建立连接并不会向服务器发送数据,真正发送数据需要调用

// 获取响应状态码
int statusCode = connection.getResponseCode();

查到还有相关的博客上说实际上调用getInputStream方法才会发送数据,getResponseCode内部同样调用了getInputStream方法

Exception exc = null;
try {
    getInputStream();
} catch (Exception e) {
    exc = e;
}

总之看起来,同步代码展示网络请求确实不如异步代码那么直观。

1.4. 处理数据

前面提到的getInputStream就是用来获取返回数据流的,这里可以通过BufferedReader来逐行提取数据

InputStream in = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
StringBuilder response = new StringBuilder();
String line;

while((line=reader.readLine()) != null){
    response.append(line);
}

Log.i(TAG, response.toString());    

通过这种方式可以获得服务器返回的字符串结果,看起来比Ajax中responseText啥的要麻烦一些~那么,如果返回了json(这应该是很常见的),在Java中该怎么处理呢?好吧,找了半天,基本都是使用第三方的jar包,这里推荐google-gson

String res = response.toString();
// 这里的返回数据{"name": "txm", "age": 24}
Log.i(TAG, res); 
JsonParser parse =new JsonParser();
try {
    JsonObject json = (JsonObject) parse.parse(res);  //创建jsonObject对象

    Log.i(TAG, "age:"+json.get("age").getAsInt());
    Log.i(TAG, "name:"+json.get("name").getAsString());

} catch (JsonIOException e) {
    e.printStackTrace();
}

相关的接口还是去翻文档吧。

1.5. 小结

至此,从新建请求到处理数据,一个完整的的HTTP请求业务就完成了,其中还有很多需要注意的细节,在之后的使用过程中慢慢熟练。当务之急是封(chao)装(xi)一个Android中的网络请求库,每次都这么写要把人折腾疯的~

2. 线程通信

上面写到处理完数据就戛然而止了,实际上还存在两个很重要的问题

  • 网络请求是十分耗时的,上面从建立连接到获得返回结果需要几百毫秒到几秒甚至更长的时间,如何处理网络请求阻塞程序运行的问题呢
  • 在实际的项目中,我们往往需要通过数据来修改UI或者跳转页面(否则发送的请求毫无意义),这个问题又怎么解决呢

在Ajax中,我们可以通过将open方法的第二个参数设置为true来实现异步请求(貌似现在有的浏览器都强制只能发送异步请求),防止网络请求阻塞用户的其他操作。

在Android中使用线程来解决这个问题,需要注意的是UI的更新必须在UI线程(即主线程)中完成,因此必须在网络请求线程获得请求结果之后,将更新UI的消息发送到了主线程的消息对象,让主线程做处理。

因此上面的问题可以缩小为:如何把子线程中的消息传递给主线程?有三种方法来解决这个问题

2.1. Handler

首先在主线程实例一个handler类,并定义好对应的分支处理逻辑,然后等待从子线程发送的消息,触发对应分支的逻辑,然后执行相关的操作。由于最后的逻辑操作是在主线程中执行,因此可以改变UI。感觉像是典型的“观察者-发布者模式”。

下面先实例一个Handler类。

public void requestCallback(){
    // 预先定义对应的状态
    final int GET_SUCCESS = 0,
        POST_SUCCESS = 1;

    // 绑定网络请求handler
    mHandler = new Handler(){
        @Override
        public void handleMessage(Message msg){
            super.handleMessage(msg);
            switch (msg.what) {
                case GET_SUCCESS:
                    // 拿到数据
                    JsonObject data = (JsonObject)msg.obj;

                    String name = data.get("name").getAsString();

                    // 完成主界面更新
                    Toast.makeText(MainActivity.this, "welcome, " + name, Toast.LENGTH_SHORT).show();
                    break;
                case POST_SUCCESS:
                    Log.i(TAG, "post success");
                default:
                    Log.i(TAG, msg.obj.toString());
                    Log.i(TAG, msg.what + "");
                    break;
            }
        }
    };
}

然后在网络请求子线程请求成功时使用先前定义的mHandler对象的sendMessage方法,进入主线程中对应的处理分支。

public void sendGetRequest(final String urlPath){
    new Thread(new Runnable() {
        @Override
        public void run() {
              // 网络请求相关代码省略...
              if (statusCode == 200){
                String res = parseResponseData(in);

                JsonObject json= parseJSON(res);

                // 需要数据传递,用下面方法;
                Message msg = new Message();
                // 可以是基本类型,可以是对象,可以是List、map等;
                msg.obj = json;
                // 指定预先定义的分支
                msg.what = 0;
                // 发送消息
                mHandler.sendMessage(msg);

            }
        }
    }
}

可以看见sendMessage方法接受一个Message对象,我们可以在这个msg对象上挂载我们需要传回主线程的数据和分支,这里需要注意的是:

  • 如果不指定msg.what,则默认为0,如果对应的分支恰好有case 0,则需要注意bug的产生
  • msg.obj可以指定为基本类型和对象,由于是预先定义的分支处理逻辑,因此可以将数据强制转换成其本身的类型。

可以看见使用Handler + Thread的方式来更新UI还是比较繁琐的,但是这是经常使用的一种方法。

2.2. runOnUIThread

另外一个方法就是在子线程中调用runOnUIThread方法,从而达到在修改UI的目的。

public void sendPostRequest(final String urlPath, final Map<String,String> params){
    new Thread(){
        public void run(){
            HttpURLConnection connection = null;
            JsonObject json = null;

            // 这里同样省略网络请求的代码...
            if (statusCode == 200){
                InputStream in = connection.getInputStream();
                String res = parseResponseData(in);

                json= parseJSON(res);
            }

              // 在子线程后调用runOnUIThread方法来修改当前Activity的UI
            final JsonObject data = json;
            runOnUIThread(new Runnable() {
                @Override
                public void run() {
                    Log.i(TAG, "ui thread");
                    String name = data.get("name").getAsString();
                    Toast.makeText(MainActivity.this,  "welcome, " + name, Toast.LENGTH_SHORT).show();
                }
            });
            Log.i(TAG, "post thread");
        }
    }.start();
}

执行代码可以发现,先打印的是post thread,然后才是ui thread,跟JS中的异步代码十分相似。实际上runOnUiThread方法接受的Runnable任务对象,

  • 如果当前线程是UI线程,那么任务是立即执行;
  • 如果当前线程不是UI线程,执行的操作是发布到事件队列的UI线程

2.3. view.post

这个暂时还没有接触,先挖个坑了~

2.4. 小结

至于为什么只能在主线程更新UI这个问题,初次遇见还有些困惑,后来看见有解释道:操作是可能并发的,但是界面只有一个,如果在同一时刻在不同的操作中都对UI界面进行更新,那不全乱套了吗?所以,这个限制还是很有必要的。随之而来的就是如何在子界面中更新主界面,上面提到的这些方法应该是最常用和最基础的技术了。

3. 本地开发环境

使用WAMP或者NodeJS搭建一个本地HTTP服务器是一件非常容易的事情,谁知道在Android的模拟器中访问本地服务器路径却一直报错,折腾了一段时间,查资料得知:安卓模拟器把他自己当作了localhost,如果想要在模拟器上访问当前的电脑,需要使用Android内置IP10.0.2.2访问~~好大一个乌龙。

既然地址找到了,剩下的事情就容易多了,balabala敲代码吧。