安卓网络通信(多线程、HTTP访问、图片加载、即时通信)

发布于:2024-06-18 ⋅ 阅读:(29) ⋅ 点赞:(0)

本章介绍App开发常用的以下网络通信技术,主要包括:如何以官方推荐的方式使用多线程技术,如何通过okhttp实现常见的HTTP接口访问操作,如何使用Dlide框架加载网络图片,如何分别运用SocketIO和WebSocket实现及时通信功能等。

多线程

本节介绍App开发对多线程的几种进阶用法,内容包括如何利用Message配合Handler完成主线程与分线程之间的简单通信,如何通过runOnUiThread方法简化分线程与处理器的通信机制,日和使用工作管理器代替IntentService实现后台任务管理。

分线程通过Handler操作界面

为了使App运行得更流畅,多线程技术被广泛应用于App开发。由于Android规定只有主线程(UI线程)才能直接操作界面,因此分线程若想修改界面就得另想办法,这要求有一种线程之间相互通信得机制。如果时主线程向分线程传递消息,可以在分线程的构造方法中传递参数,然而分线程向主线程传递消息并无捷径,为此Android设计了一个消息工具Message,通过结合Handler与Message能够实现线程间通信。
由分线程向主线程传递消息的过程主要有4个步骤,分别说明如下。

1.在主线程中构造一个处理器对象,并启动分线程

在Android中启动分线程有两种方式:一种是直接调用线程实例的start方法,另一种是通过处理器Handler对象的post方法启动线程实例。

2.在分线程中构造一个Message类型的消息包

Message是线程间通信存放消息的包裹,其作用类似于Intent机制的Bundle工具。消息实例可通过Message的obtain方法获得,比如下面这行代码:

Message message = Message.obtain(); // 获得默认的消息对象

也可以通过处理器对象的obtainMessage方法获得,比如下面这行代码:

Message message = mHandler.obtainMessage(); // 获得处理器的消息对象

获得消息实例之后,再给它补充详细的包裹信息,下面是Message工具的属性说明。
what:整型数,可存放本次消息的唯一标识。
arg1:整形数,可存放消息的处理结果。
arg2:整型数,可存放消息的处理代码。
obj:Object类型,可存放返回消息的数据结构。
replyTo:Messager(回应信使)类型,在跨进程通信中使用,在线程间通信用不着。

3.在分线程中通过处理器对象将Message消息发出去

处理器的消息操作主要包括各种send***方法和remove***方法,下面是这些消息操作方法的使用说明。

4.主线程的handler对象处理接收到的消息

主线程收到分线程发出的消息之后,需要实现处理器对象的handleMessage方法,在该方法中根据消息内容分别进行相应的处理,因为handleMessage方法在主线程(UI线程)中调用,所以方法内部可以直接操作界面元素。
综合上面的4个线程通信步骤,接下来通过一个实验观察线程间通信的效果。下面便是利用多线程技术实现新闻滚动的活动代码例子,其中结合了Handler与Message。

public class HandlerMessageActivity extends AppCompatActivity implements View.OnClickListener {
    private TextView tv_message; // 声明一个文本视图对象
    private boolean isPlaying = false; // 是否正在播放新闻
    private int BEGIN = 0, SCROLL = 1, END = 2; // 0为开始,1为滚动,2为结束
    private String[] mNewsArray = { "北斗导航系统正式开通,定位精度媲美GPS",
            "黑人之死引发美国各地反种族主义运动", "印度运营商禁止华为中兴反遭诺基亚催债",
            "贝鲁特发生大爆炸全球紧急救援黎巴嫩", "日本货轮触礁毛里求斯造成严重漏油污染"
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_handler_message);
        tv_message = findViewById(R.id.tv_message);
        findViewById(R.id.btn_start).setOnClickListener(this);
        findViewById(R.id.btn_stop).setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_start) { // 点击了开始播放新闻的按钮
            if (!isPlaying) { // 如果不在播放就开始播放
                isPlaying = true;
                new PlayThread().start(); // 创建并启动新闻播放线程
            }
        } else if (v.getId() == R.id.btn_stop) { // 点击了结束播放新闻的按钮
            isPlaying = false;
        }
    }

    // 定义一个新闻播放线程
    private class PlayThread extends Thread {
        @Override
        public void run() {
            mHandler.sendEmptyMessage(BEGIN); // 向处理器发送播放开始的空消息
            while (isPlaying) { // 正在播放新闻
                try {
                    sleep(2000); // 睡眠两秒(2000毫秒)
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Message message = Message.obtain(); // 获得默认的消息对象
                //Message message = mHandler.obtainMessage(); // 获得处理器的消息对象
                message.what = SCROLL; // 消息类型
                message.obj = mNewsArray[new Random().nextInt(5)]; // 消息描述
                mHandler.sendMessage(message); // 向处理器发送消息
            }
            mHandler.sendEmptyMessage(END); // 向处理器发送播放结束的空消息
            isPlaying = false;
        }
    }

    // 创建一个处理器对象
    private Handler mHandler = new Handler(Looper.myLooper()) {
        // 在收到消息时触发
        public void handleMessage(Message msg) {
            String desc = tv_message.getText().toString();
            if (msg.what == BEGIN) { // 开始播放
                desc = String.format("%s\n%s %s", desc, DateUtil.getNowTime(), "开始播放新闻");
            } else if (msg.what == SCROLL) { // 滚动播放
                desc = String.format("%s\n%s %s", desc, DateUtil.getNowTime(), msg.obj);
            } else if (msg.what == END) { // 结束播放
                desc = String.format("%s\n%s %s", desc, DateUtil.getNowTime(), "新闻播放结束");
            }
            tv_message.setText(desc);
        }
    };
}

运行App,先点击“开始播放新闻”按钮,此时分线程每隔两秒添加一条新闻,正在播放新闻的界面如下图所示。
在这里插入图片描述

稍等片刻再点击“停止播放新闻”按钮,此时主线程收到分线程的END消息,在界面上提示用户“新闻播放结束”,如下图所示。
在这里插入图片描述
根据以上的新闻播放效果,可知分线程的播放开始和播放结束指令都成功送到了主线程。

通过runOnUiThread快速操作界面

因为Android规定分线程不能直接操纵界面,所以它设计了处理程序(Handler)工具,由处理程序负责在主线程和分线程之间传递数据。如果分线程想刷新界面,就得向处理程序发送消息,由处理程序在handleMessage方法中操作控件。举个例子,上一小节“分线程通过Handler操作界面”讲到的通过分线程播报新闻便是经由处理程序操纵文本视图。分线程与处理程序交互的代码片段如下:

// 是否正在播放新闻
private boolean isPlaying = false; 

// 定义一个新闻播放线程
private class PlayThread extends Thread {
    @Override
    public void run() {
        mHandler.sendEmptyMessage(BEGIN); // 向处理器发送播放开始的空消息
        while (isPlaying) { // 正在播放新闻
            try {
                sleep(2000); // 睡眠两秒(2000毫秒)
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Message message = Message.obtain(); // 获得默认的消息对象
            //Message message = mHandler.obtainMessage(); // 获得处理器的消息对象
            message.what = SCROLL; // 消息类型
            message.obj = mNewsArray[new Random().nextInt(5)]; // 消息描述
            mHandler.sendMessage(message); // 向处理器发送消息
        }
        mHandler.sendEmptyMessage(END); // 向处理器发送播放结束的空消息
        isPlaying = false;
    }
}

// 创建一个处理器对象
private Handler mHandler = new Handler(Looper.myLooper()) {
    // 在收到消息时触发
    public void handleMessage(Message msg) {
        String desc = tv_message.getText().toString();
        if (msg.what == BEGIN) { // 开始播放
            desc = String.format("%s\n%s %s", desc, DateUtil.getNowTime(), "开始播放新闻");
        } else if (msg.what == SCROLL) { // 滚动播放
            desc = String.format("%s\n%s %s", desc, DateUtil.getNowTime(), msg.obj);
        } else if (msg.what == END) { // 结束播放
            desc = String.format("%s\n%s %s", desc, DateUtil.getNowTime(), "新闻播放结束");
        }
        tv_message.setText(desc);
    }
};

以上代码定义了一个新闻播放线程,接着主线程启动该线程,启动代码如下:

new PlayThread().start(); // 创建并启动新闻播放线程

上述代码处理分线程与处理程序的交互甚是繁琐,既要区分消息类型,又要来回类型。为此Android提供了一种简单的交互方式,分线程若想操纵界面控件,在线程内部调用runOnUiThread方法即可,调用代码如下:

// 回到主线程(UI线程)操作界面
runOnUiThread(new Runnable() {
    @Override
    public void run() {
        // 操作界面的代码放这里
    }
});

由于Runnable属于函数式接口,因此调用代码可简化如下:

// 回到主线程(UI线程)操作界面
runOnUiThread(()->{
	// 操作界面代码放这里
});

倘若Runnable的运行代码只有一行,那么Lambda表达式允许进一步简化,也就是省略外面的花括号,于是精简的代码编程以下这样:

// 回到主线程(UI线程)操作界面
runOnUiThread(()-> /* 如果只有一行代码,那么连花括号也可省掉 */ );

回看之前的新闻播报线程,把原来的消息发送代码系统统统改成runOnUiThread方法,修改后的播放代码如下:

// 是否正在播放新闻
private boolean isPlaying = false; 

// 播放新闻
private void broadcastNews() {
    String startDesc = String.format("%s\n%s %s", tv_message.getText().toString(),
            DateUtil.getNowTime(), "开始播放新闻");
    // 回到主线程(UI线程)操纵界面
    runOnUiThread(() -> tv_message.setText(startDesc));
    while (isPlaying) { // 正在播放新闻
        try {
            Thread.sleep(2000); // 睡眠两秒(2000毫秒)
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        String runDesc = String.format("%s\n%s %s", tv_message.getText().toString(),
                DateUtil.getNowTime(), mNewsArray[new Random().nextInt(5)]);
        // 回到主线程(UI线程)操纵界面
        runOnUiThread(() -> tv_message.setText(runDesc));
    }
    String endDesc = String.format("%s\n%s %s", tv_message.getText().toString(),
            DateUtil.getNowTime(), "新闻播放结束,谢谢观看");
    // 回到主线程(UI线程)操纵界面
    runOnUiThread(() -> tv_message.setText(endDesc));
    isPlaying = false;
}

从以上代码可见,处理程序的相关代码不见了,取而代之的是一行又一行runOnUiThread方法。
主线程启动播放器线程也只需要下面一行代码就够了:

new Thread(() -> broadcastNews()).start(); // 启动新闻播放线程

改造完毕后运行测试App,可观察到开始新闻播报效果如下图所示:
在这里插入图片描述

停止播放新闻效果如下如图所示:
在这里插入图片描述

工作管理器WorkManager

Android 11不光废弃了AsyncTask,还把IntentService一起废弃了,对于后台的异步服务,官方建议改为使用工作管理器WorkManager
除了IntentService之外,Android也提供了其他后台任务工具,例如工作调用器JobScheduler、闹钟管理器AlarmManager等。当然,这些后台工具的用法各不相同,徒增开发者的学习时间而已,所以谷歌索性把它们统一起来,在Jetpack库中推出了工作管理器WorkManager。这个WorkManager的兼容性很强,对于Android 6.0或更高版本的系统,它通过JobScheduler完成后台任务;对于Android 6.0以下版本的系统(不含Android 6.0),通过AlarmManager和广播接收器组合完成后台任务。无论采取哪种方案,后台任务最终都是由线程池Executor执行的。
因为WorkManager来自Jetpack库,所以使用之前要修改build.gradle.kts,增加下面一行以来配置:

implementation("androidx.work:work-runtime:2.9.0")

接着定义一个处理后台业务逻辑的工作者,该工作继承自Worker抽象类,就像异步任务需要从IntentService派生而来那样。自定义的工作者必须实现构造方法,并重写doWork方法,其中构造方法可获得外部传来的请求数据,而doWork方法处理具体的业务逻辑。特别注意,由于doWork方法运行于分线程,因此该方法内部不能操作界面控件。自定义工作者的示例代码如下:

public class CollectWork extends Worker {
    private final static String TAG = "CollectWork";
    private Data mInputData; // 工作者的输入数据

    public CollectWork(Context context, WorkerParameters workerParams) {
        super(context, workerParams);
        mInputData = workerParams.getInputData();
    }

    // doWork内部不能操纵界面控件
    @Override
    public Result doWork() {
        String desc = String.format("请求参数包括:姓名=%s,身高=%d,体重=%f",
                mInputData.getString("name"),
                mInputData.getInt("height", 0),
                mInputData.getDouble("weight", 0));
        Log.d(TAG, "doWork "+desc);
        Data outputData = new Data.Builder()
                .putInt("resultCode", 0)
                .putString("resultDesc", "处理成功")
                .build();
        //Result.success();
        //Result.failure();
        return Result.success(outputData); // success表示成功,failure表示失败
    }
}

然后在活动页面中构建并启动工作任务,详细过程主要分为下列4个步骤:

  1. 构建约束条件
    该步骤说明在哪些情况下才能执行后台任务,也就是运行后台任务的前提条件,此时用到了约束工具Constraints。约束条件的构建代码如下:
// 1、构建约束条件
Constraints constraints = new Constraints.Builder()
        //.setRequiresBatteryNotLow(true) // 设备电量充足
        //.setRequiresCharging(true) // 设备正在充电
        .setRequiredNetworkType(NetworkType.CONNECTED) // 已经连上网络
        .build();
  1. 构建输入数据
    该步骤把后台任务需要的参数封装到一个数据对象,此时用到了数据工具Data,构建输入数据的示例代码如下:
// 2、构建输入数据
Data inputData = new Data.Builder()
        .putString("name", "小明")
        .putInt("height", 180)
        .putDouble("weight", 80)
        .build();
  1. 构建工作请求
    该步骤把约束条件、输入数据等请求内容组装起来。此时用到了工作请求工具OneTimeWorkRequest,构建工作请求的示例代码如下:
// 3、构建一次性任务的工作请求。OneTimeWorkRequest表示一次性任务,PeriodicWorkRequest表示周期性任务
String workTag = "OnceTag";
OneTimeWorkRequest onceRequest = new OneTimeWorkRequest.Builder(CollectWork.class)
        .addTag(workTag) // 添加工作标签
        .setConstraints(constraints) // 设置触发条件
        .setInputData(inputData) // 设置输入参数
        .build();
UUID workId = onceRequest.getId(); // 获取工作请求的编号
  1. 执行工作请求
    该步骤生成工作管理器实例,并将步骤3的工作请求对象加入管理器的执行队列中,由管理器调度并执行请求任务,执行工作请求的实例代码如下:
// 4、执行工作请求
WorkManager workManager = WorkManager.getInstance(this);
workManager.enqueue(onceRequest); // 将工作请求加入执行队列

工作管理器不知拥有enqueue方法,还有其他的调度方法,常用的几个方法分别说明如下:

鉴于后台任务是异步执行的,因此若想知晓工作任务的处理结果,就得调用getWorkInfoByIdLiveData方法,获取工作信息并实时监听它的运行情况。查询工作结果的示例代码:

// 获取指定编号的工作信息,并实时监听工作的处理结果
workManager.getWorkInfoByIdLiveData(workId).observe(this, workInfo -> {
    Log.d(TAG, "workInfo:" + workInfo.toString());
    if (workInfo.getState() == WorkInfo.State.SUCCEEDED) { // 工作处理成功
        Data outputData = workInfo.getOutputData(); // 获得工作信息的输出数据
        int resultCode = outputData.getInt("resultCode", 0);
        String resultDesc = outputData.getString("resultDesc");
        String desc = String.format("工作处理结果为:resultCode=%d,resultDesc=%s",
                resultCode, resultDesc);
        tv_result.setText(desc);
    }
});

至此,工作管理器的任务操作步骤都过了一遍。有的读者可能会发现,步骤3的工作请求类的名称为OneTimeWorkRequest,读起来像是一次性工作。其实工作管理器不止支持设定依次性工作,也支持设定周期性工作,此时用到的工作请求名为PeriodicWorkRequest,构建的示例代码如下:

// 构建周期性任务的工作请求。周期性任务的间隔时间不能小于15分钟
String workTag = "PeriodTag";
PeriodicWorkRequest periodRequest = new PeriodicWorkRequest.Builder(
        CollectWork.class, 15, TimeUnit.MINUTES)
        .addTag(workTag) // 添加工作标签
        .setConstraints(constraints) // 设置触发条件
        .setInputData(inputData) // 设置输入参数
        .build();
UUID workId = periodRequest.getId(); // 获取工作请求的编号

最后在活动页面中继承工作管理器,运行App后点击启动按钮,执行结果如下图所示。
在这里插入图片描述

HTTP访问

本节介绍okhttp在App接口访问中的详细用法,内容包括如何利用移动数据格式JSON封装结构信息,以及如何从JSON串解析得结构对象;通过okhttp调用HTTP接口得三种方式(GET方式、表单格式得POST请求、JSON格式得POST请求);如何使用okhttp下载网络文件,以及如何将本地文件上传到服务器。

移动数据格式JSON

网络通信的交互数据格式有两大类,分别是JSON和XML,前者短小精悍,后者表现力丰富。对于App来说,基本采用JSON格式于服务器通信。原因很多,一个是手机流量很贵,表达同样的信息,JSON串比XML串短很多,在节省流量方面占了上风;另一个是JSON串解析得很快,也更省电,XML不但慢而且耗电。于是,JSON格式成了移动端事实上的网络数据格式标准。
先来看个购物订单的JSON串例子:

{
    "user_info": {
        "name": "思无邪",
        "address": "桃花岛水帘洞123号",
        "phone": "12345678901"
    },
    "goods_list": [
        {
            "goods_name": "Mate70",
            "goods_number": 1,
            "goods_price": 10086
        },
        {
            "goods_name": "小米15",
            "goods_number": 1,
            "goods_price": 8888
        },
        {
            "goods_name": "oneplus13",
            "goods_number": 3,
            "goods_price": 6666
        }
    ]
}

从以上JSON串的内容可以梳理出它的基本格式定义,详细说明如下:

  1. 整个JSON串由一对花括号包裹,并且内部的每个结构都以花括号包起来。
  2. 参数格式类似键值对,其中键名与键值以冒号分隔,形如“键名:键值”。
  3. 两个键值对之间以逗号分隔。
  4. 键名需要用双引号引起来,键值为数字的话则无需双引号,为字符串的话仍需双引号。
  5. JSON数组通过方括号表达,方括号内部依次罗列各个元素,具体格式形如“数组的键名:[元素1,元素2,元素3]”。

针对JSON字符串,Android提供了JSON解析工具,支持JSONObject(JSON对象)和
(JSON数组)的解析处理。

1.JSONObject

下面是JSONObject的常用方法。

  • JSONObject构造函数:从指定字符串构造一个JSONObject对象。
  • getJSONObject:获取指定名称的JSONObject对象。
  • getString:获取指定名称的字符串。
  • getInt:获取指定名称的整型数。
  • getDouble:获取指定名称的双精度数。
  • getBoolean:获取指定名称的布尔数。
  • getJSONArray:获取指定名称的JSONArray数组对象。
  • put:添加一个JSONObject对象。
  • toString:把当前的JSONObject对象输出为一个JSON字符串。

2.JSONArray

下面是JSONArray的常用方法。

  • length:获取JSONArray数组长度。
  • getJSONObject:获取JSONArray数组在指定位置的JSONObject对象。
  • put:往JSONArray数组中添加一个JSONObject对象。

虽然Android自带的JSONObject和JSONArray能够解析JSON串,但是这种手工解析实在太麻烦,费时费力还容易犯错,故而谷歌公司推出了专门的GSon支持库,方便开发者快速处理JSON串。
由于Gson是第三方库,因此首先要修改build.gradle.kts文件,往dependencies节点添加下面一行配置,表示导入指定版本的Gson库:

implementation("com.google.code.gson:gson:2.10")

接着在Java代码文件的头部添加如下一行导入语句,表示后面会用到Gson工具:

import com.google.gson.Gson;

完成上述两个步骤,就能在代码中调用Gson的各种处理方法了。Gson常见的应用场合主要有下列两个:

  1. 将数据对象转换为JSON字符串。此时可调用Gson工具的toJson方法,把指定的数据对象转换为JSON字符串。
  2. 从JSON字符串解析出数据对象。此时可调用Gson工具的fromJson方法,从JSON字符串解析得到指定类型的数据对象。

下面是通过Gson库封装与解析JSON串的活动代码例子:

public class JsonConvertActivity extends AppCompatActivity {
    private TextView tv_json; // 声明一个文本视图对象
    private UserInfo mUser; // 声明一个用户信息对象
    private String mJsonStr; // JSON格式的字符串

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_json_convert);
        mUser = new UserInfo("阿四", 25, 165L, 50.0f); // 创建用户实例
        mJsonStr = new Gson().toJson(mUser); // 把用户实例转换为JSON串
        tv_json = findViewById(R.id.tv_json);
        findViewById(R.id.btn_origin_json).setOnClickListener(v -> {
            mJsonStr = new Gson().toJson(mUser); // 把用户实例转换为JSON字符串
            tv_json.setText("JSON串内容如下:\n" + mJsonStr);
        });
        findViewById(R.id.btn_convert_json).setOnClickListener(v -> {
            // 把JSON串转换为UserInfo类型的对象
            UserInfo newUser = new Gson().fromJson(mJsonStr, UserInfo.class);
            String desc = String.format("\n\t姓名=%s\n\t年龄=%d\n\t身高=%d\n\t体重=%f",
                    newUser.name, newUser.age, newUser.height, newUser.weight);
            tv_json.setText("从JSON串解析而来的用户信息如下:" + desc);
        });
    }
}

运行App,先点击“原始JSON串”按钮,把用户对象转换为JSON字符串,此时JSON界面如下图所示,可见包含用户信息的JSON字符串。
在这里插入图片描述
接着点击“转换JSON串”按钮,将JSON字符串转换为用户对象,此时JSON界面如下图所示,可见用户对象的各字段值。
在这里插入图片描述

通过okhttp调用HTTP

尽管使用HttpURLConnection能够实现大多数的网络访问操作,但是它的用法实在繁琐,很多细节都要开发者关注,一不留神就可能导致访问异常。于是各种网络开源框架纷纷涌现,比如声明显赫的Apache的HttpClient、Square的okhttp。Android从9.0开始正式弃用HttpClient,使得okhttp成为App开发流行的网络框架。
因为okhttp属于第三方框架,所以使用之前要修改build.gradle.kts,增加下面一行依赖配置:

implementation("com.squareup.okhttp3:okhttp:4.9.3")

当然访问网络之前得先申请上网权限,也就是在AndroidManifest.xml里面补充以下权限:

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

除此之外,从Android 9开始默认只能访问https开头的安全地址,不能直接访问以http开头的网络地址。如果应用仍想访问http开头的普通地址,就是修改AndroidManifest.xml,给application节点添加如下属性,表示继续使用http明文地址:

android:usesCleartextTraffic="true"

okhttp的网络访问功能十分强大,单就HTTP接口调用而言,它就支持三种访问方式:GET方式的请求,表单格式的POST请求、JSON格式的POST请求,下面分别进行说明。

1.GET方式的请求

不管是GET方式还是POST方式,okhttp在访问网络时都离不开下面4个步骤:

  1. 使用OkHttpClient类创建一个okhttp客户端对象。创建客户端对象的示例代码如下:
OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
  1. 使用Request类创建一个GET和POST方式的请求结构。采取GET方式时调用get方法,采取POST方法时调用post方法。此外,需要指定本次请求的网络地址,还可以添加个性化HTTP头部信息。
    创建请求结构的示例代码如下:
// 创建一个GET方式的请求结构
Request request = new Request.Builder()
        //.get() // 因为OkHttp默认采用get方式,所以这里可以不调get方法
        .header("Accept-Language", "zh-CN") // 给http请求添加头部信息
        .header("Referer", "https://finance.sina.com.cn") // 给http请求添加头部信息
        .url(URL_STOCK) // 指定http请求的调用地址
        .build();
  1. 调用步骤1中客户端对象的newCall方法,方法参数为步骤2中的请求结构,从而创建Call类型的调用对象。创建调用对象的实例代码如下:
Call call = client.newCall(request); // 根据请求结构创建调用对象
  1. 调用步骤3中Call对象的enqueue方法,将本次请求加入HTTP访问的执行队列中,并编写请求失败与请求成功两种情况的处理代码。加入执行队列的示例代码如下:
// 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
call.enqueue(new Callback() {
    @Override
    public void onFailure(Call call, IOException e) { // 请求失败
        // 回到主线程操纵界面
        runOnUiThread(() -> tv_result.setText("调用股指接口报错:"+e.getMessage()));
    }

    @Override
    public void onResponse(Call call, final Response response) throws IOException { // 请求成功
        String resp = response.body().string();
        // 回到主线程操纵界面
        runOnUiThread(() -> tv_result.setText("调用股指接口返回:\n"+resp));
    }
});

综合上述4个步骤,接下来以查询上证指数为例,来熟悉okhttp的完整使用过程。上证指数的查询接口来自新浪网的证券板块,具体的接口调用代码如下:

// 发起GET方式的HTTP请求
private void doGet() {
    OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
    // 创建一个GET方式的请求结构
    Request request = new Request.Builder()
            //.get() // 因为OkHttp默认采用get方式,所以这里可以不调get方法
            .header("Accept-Language", "zh-CN") // 给http请求添加头部信息
            .header("Referer", "https://finance.sina.com.cn") // 给http请求添加头部信息
            .url(URL_STOCK) // 指定http请求的调用地址
            .build();
    Call call = client.newCall(request); // 根据请求结构创建调用对象
    // 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
    call.enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) { // 请求失败
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用股指接口报错:"+e.getMessage()));
        }

        @Override
        public void onResponse(Call call, final Response response) throws IOException { // 请求成功
            String resp = response.body().string();
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用股指接口返回:\n"+resp));
        }
    });
}

运行测试App,可观察到上证指数的查询结果如下图所示。
在这里插入图片描述

2.表单格式的POST请求

对于okhttp来说,POST方式与GET方式的调用过程大同小异,主要区别于如何让创建请求结构。除了通过post方法表示本次请求采取POST方式外,还要给post方法填入请求参数,比如表单格式的请求参数放在FormBody结构中,示例代码如下:

String username = et_username.getText().toString();
String password = et_password.getText().toString();
// 创建一个表单对象
FormBody body = new FormBody.Builder()
        .add("username", username)
        .add("password", password)
        .build();
// 创建一个POST方式的请求结构
Request request = new Request.Builder().post(body).url(URL_LOGIN).build();

以登录功能为例,用户在界面上输入用户名和密码,然后点击登录按钮时,App会把用户名和密码封装进FormBody结构后提交给后端服务器。采取表单格式的登录代码如下:

// 发起POST方式的HTTP请求(报文为表单格式)
private void postForm() {
    String username = et_username.getText().toString();
    String password = et_password.getText().toString();
    // 创建一个表单对象
    FormBody body = new FormBody.Builder()
            .add("username", username)
            .add("password", password)
            .build();
    OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
    // 创建一个POST方式的请求结构
    Request request = new Request.Builder().post(body).url(URL_LOGIN).build();
    Call call = client.newCall(request); // 根据请求结构创建调用对象
    // 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
    call.enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) { // 请求失败
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用登录接口报错:"+e.getMessage()));
        }

        @Override
        public void onResponse(Call call, final Response response) throws IOException { // 请求成功
            String resp = response.body().string();
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用登录接口返回:\n"+resp));
        }
    });
}

确保服务端的登录接口正常开启(点击查看服务端程序),并且手机和计算机连接同一个WiFi,再运行测试App。打开登录页面,填入登录信息然后点击“发起接口调用”按钮,接收到服务器端返回的数据,如下图所示,可见表单格式的POST请求被正常调用。
在这里插入图片描述

3.JSON格式的POST请求结果

由于表单格式不能传递复杂的数据,因此App在与服务端交互时经常使用JSON格式。设定好JSON串的字符编码后再放入RequestBody结构中,示例代码如下:

// 创建一个POST方式的请求结构
RequestBody body = RequestBody.create(jsonString, MediaType.parse("text/plain;charset=utf-8"));
Request request = new Request.Builder().post(body).url(URL_LOGIN).build();

仍以登录功能为例,App先将用户名和密码组装进JSON对象,再把JSON对象转为字符串,后续便是常规的okhttp调用过程了。采取JSON格式的登录代码示例如下:

// 发起POST方式的HTTP请求(报文为JSON格式)
private void postJson() {
    String username = et_username.getText().toString();
    String password = et_password.getText().toString();
    String jsonString = "";
    try {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("username", username);
        jsonObject.put("password", password);
        jsonString = jsonObject.toString();
    } catch (Exception e) {
        e.printStackTrace();
    }
    // 创建一个POST方式的请求结构
    RequestBody body = RequestBody.create(jsonString, MediaType.parse("text/plain;charset=utf-8"));
    OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
    Request request = new Request.Builder().post(body).url(URL_LOGIN).build();
    Call call = client.newCall(request); // 根据请求结构创建调用对象
    // 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
    call.enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) { // 请求失败
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用登录接口报错:"+e.getMessage()));
        }

        @Override
        public void onResponse(Call call, final Response response) throws IOException { // 请求成功
            String resp = response.body().string();
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用登录接口返回:\n"+resp));
        }
    });
}

同样确保服务端的登录接口正常开启(点击查看服务端程序),并且手机和计算机连接同一个WiFi,再运行测试该App。打开登陆界面,填入登录信息后点击“发起接口调用”按钮,接收到服务端返回的数据,如下图所示,可见JSON格式的POST请求被正常调用。
在这里插入图片描述

使用okhttp下载和上传文件

okhttp不但简化了HTTP接口的调用过程,连下载文件都变简单了。对于一般的文件下载,按照常规的GET方式调用流程,只要重写回调方法onResponse,在该方法中通过应答对象的body方法即可获得应答的数据包对象,调用数据包对象的string方法即可获得到文本形式的字符串,调用数据包对象的byteStream方法即可得到InputStream类型的输入流对象,从输入流就能读出原始的二进制数据。
以下载网络图片为例,位图工具BitmapFactory刚好提供了decodeStream方法,允许直接从输入流中解码获取位图对象。此时通过okhttp下载图片的示例代码如下:

private final static String URL_IMAGE = "https://img-blog.csdnimg.cn/2018112123554364.png";

// 下载网络图片
private void downloadImage() {
    tv_progress.setVisibility(View.GONE);
    iv_result.setVisibility(View.VISIBLE);
    OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
    // 创建一个GET方式的请求结构
    Request request = new Request.Builder().url(URL_IMAGE).build();
    Call call = client.newCall(request); // 根据请求结构创建调用对象
    // 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
    call.enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) { // 请求失败
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("下载网络图片报错:"+e.getMessage()));
        }

        @Override
        public void onResponse(Call call, final Response response) { // 请求成功
            InputStream is = response.body().byteStream();
            // 从返回的输入流中解码获得位图数据
            Bitmap bitmap = BitmapFactory.decodeStream(is);
            String mediaType = response.body().contentType().toString();
            long length = response.body().contentLength();
            String desc = String.format("文件类型为%s,文件大小为%d", mediaType, length);
            // 回到主线程操纵界面
            runOnUiThread(() -> {
                tv_result.setText("下载网络图片返回:"+desc);
                iv_result.setImageBitmap(bitmap);
            });
        }
    });
}

回到活动代码中调用downloadImage方法,再运行并测试App,可观察到图片下载结果如下图所示,可见网络图片成功下载并显示了出来。
在这里插入图片描述
当然,网络文件不只是图片,还有其他各式各样的文件,这些文件没有专门的解码工具,只能从输入流老老实实地读取字节数据。不过读取字节数据有个好处,就是能够根据自己读写的数据长度计算下载进度,特别是在下载大文件的时候,实际展示当前的下载进度非常有用。下面是通过okhttp下载普通文件的示例代码:

// 下载网络文件
private void downloadFile() {
    tv_progress.setVisibility(View.VISIBLE);
    iv_result.setVisibility(View.GONE);
    OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
    // 创建一个GET方式的请求结构
    Request request = new Request.Builder().url(URL_MP4).build();
    Call call = client.newCall(request); // 根据请求结构创建调用对象
    // 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
    call.enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) { // 请求失败
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("下载网络文件报错:"+e.getMessage()));
        }

        @Override
        public void onResponse(Call call, final Response response) { // 请求成功
            String mediaType = response.body().contentType().toString();
            long length = response.body().contentLength();
            String desc = String.format("文件类型为%s,文件大小为%d", mediaType, length);
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("下载网络文件返回:"+desc));
            String path = String.format("%s/%s.mp4",
                    getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString(),
                    DateUtil.getNowDateTime());
            // 下面从返回的输入流中读取字节数据并保存为本地文件
            try (InputStream is = response.body().byteStream();
                 FileOutputStream fos = new FileOutputStream(path)) {
                byte[] buf = new byte[100 * 1024];
                int sum=0, len=0;
                while ((len = is.read(buf)) != -1) {
                    fos.write(buf, 0, len);
                    sum += len;
                    int progress = (int) (sum * 1.0f / length * 100);
                    String detail = String.format("文件保存在%s。已下载%d%%", path, progress);
                    // 回到主线程操纵界面
                    runOnUiThread(() -> tv_progress.setText(detail));
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
}

回到活动代码调用downloadFile方法,再运行测试该App,可观察到文件下载结果如下图所示。
在这里插入图片描述
okhttp不仅让下载文件变简单了,还让上传文件变得更加灵活易用。修改个人资料上传头像图片、在朋友圈发动态视频等都用到了文件上传功能,并且上传文件常常带着文字说明,比如上传头像时可能一并修改了昵称、发布视频时附加了视频描述,甚至可能同时上传多个文件等。
像这种组合上传的业务场景,倘若使用HttpUTLConnection编码就难了,有了okhttp就好办多了。它引入分段结构MultipartyBody及其建造器,并提供了名为addFormDataPart的两种重载方法,分别适用于文本格式与文件格式的数据。带两个参数的addFormDataPart方法,它的第一个参数是字符串的键名,第二个参数是字符串的键值,该方法用来传递文本消息。带三个参数的addFormDataPart方法,它的第一个参数是文件类型,第二个参数是文件名,第三个参数是文件体。
举个带头像进行用户注册的例子,既要把用户和密码发送给服务端,也要把头像图片传给服务端,此时需要多次调用addFormDataPart方法,并通过POST方式提交数据。虽然存在文件上传的交互操作,但整体操作流程与POST方式调用接口保持一致,唯一啥区别在于请求结构由MultipartyBody生成,下面是上传文件之时根据MultipartyBody构建请求结构的代码模板:

// 创建分段内容的建造器对象
MultipartBody.Builder builder = new MultipartBody.Builder();
// 往建造器对象添加文本格式的分段数据
builder.addFormDataPart("username", username);
builder.addFormDataPart("password", password);
File file = new File(path); // 根据文件路径创建文件对象
// 往建造器对象添加图像格式的分段数据
builder.addFormDataPart("image", file.getName(),
        RequestBody.create(file, MediaType.parse("image/*")));
RequestBody body = builder.build(); // 根据建造器生成请求结构
// 创建一个POST方式的请求结构
Request request = new Request.Builder().post(body).url(URL_REGISTER).build();

合理的文件上传代码要求具备容错机制,譬如判断文本内容是否为空、不能上传空文件、支持上传多个文件等。综合考虑之后,重新编写文件上传部分的示例代码如下:

private List<String> mPathList = new ArrayList<>(); // 头像文件的路径列表

// 执行文件上传动作
private void uploadFile() {
    // 创建分段内容的建造器对象
    MultipartBody.Builder builder = new MultipartBody.Builder();
    String username = et_username.getText().toString();
    String password = et_password.getText().toString();
    if (!TextUtils.isEmpty(username)) {
        // 往建造器对象添加文本格式的分段数据
        builder.addFormDataPart("username", username);
        builder.addFormDataPart("password", password);
    }
    for (String path : mPathList) { // 添加多个附件
        File file = new File(path); // 根据文件路径创建文件对象
        // 往建造器对象添加图像格式的分段数据
        builder.addFormDataPart("image", file.getName(),
                RequestBody.create(file, MediaType.parse("image/*"))
        );
    }
    RequestBody body = builder.build(); // 根据建造器生成请求结构
    OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
    // 创建一个POST方式的请求结构
    Request request = new Request.Builder().post(body).url(URL_REGISTER).build();
    Call call = client.newCall(request); // 根据请求结构创建调用对象
    // 加入HTTP请求队列。异步调用,并设置接口应答的回调方法
    call.enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) { // 请求失败
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用注册接口报错:\n"+e.getMessage()));
        }

        @Override
        public void onResponse(Call call, final Response response) throws IOException { // 请求成功
            String resp = response.body().string();
            // 回到主线程操纵界面
            runOnUiThread(() -> tv_result.setText("调用注册接口返回:\n"+resp));
        }
    });
}

确保服务端的注册接口正常开启(点击查看服务端程序),并且手机和计算机连接同一个WiFi,再运行测试该App。打开初始的注册界面,如下图所示。
在这里插入图片描述
依次输入用户名称和密码,跳转到相册选择头像图片,然后点击“注册”按钮,接收到服务器的数据,如谢图所示,可见服务端正常收到了注册信息与头像图片。
在这里插入图片描述

图片加载

本节介绍App加载网络图片的相关技术:首先描述如何利用第三方的Glide库加载网络图片;然后阐述图片加载框架的三级缓存机制,以及如何有效地运用Glide地缓存功能;最后讲述如何使用Glide加载特殊图像(GIF动图、视频封面等)。

使用Glide加载网络图片

上一小节通过异步任务获取网络图片,尽管能够实现图片加载功能,但是编码过程仍显繁琐。如果方便而又快速地显示网络图片,一直是安卓网络编程的热门课题,前些年图片加载框架Piecasso、Fresco等大行其道,以至于谷歌也按耐不住开发了自己的Glide来源库。由于Android本身就是谷歌开发的,Glide与Android系出同门,因此Glide成为事实上的官方推荐图片加载框架。不过Glide并未集成到Android的SDK中,开发者需要另外给App工程导入Glide库,也就是修改模块的build.gradle.kts,在dependencies节点内部添加如下一行依赖库配置:

implementation("com.github.bumptech.glide:glide:4.13.0")

导包完成之后,即可在代码中正常使用Glide。当然Glide的用法确实简单,默认情况下只要以下这行代码就够了:

Glide.with(活动实例).load(网址字符串).into(图像视图);

可见Glide的图片加载代码至少需要3个参数,说明如下:

  1. 当前页面的活动实例,参数类型为Activity。如果是在页面代码内部调用,则填写this表示当前活动即可。
  2. 网络图片的谅解地址,以http或者https大头,参数类型为字符串。
  3. 准备显示网络图片的图像视图实例,参数类型为ImageView。

假设在Activity内部调用Glide,且图片链接放在mImageUrl,演示的图像视图名为iv_network,那么实际的Glide加载代码是下面这样的:

Glide.with(this).load(mImageUrl).into(iv_network);

如果不指定图像视图的缩放类型,Glide默认采用FIT_CENTER方式显示图片,相当于在load方法和into方法中间增加调用fitCenter方法,迹象如下代码这般:

// 显示方式为容纳居中fitCenter
Glide.with(this).load(mImageUrl).fitCenter().into(iv_network);

除了fitCenter方法,Glide还提供了centerCrop方法对应CENTER_CROP,提供了centerInside方法对应CENTER_INSIDE,其中增加centerCrop方法的加载代码如下:

// 显示方式为居中剪裁centerCrop
Glide.with(this).load(mImageUrl).centerCrop().into(iv_network);

增加centerInside方法的加载代码如下:

// 显示方式为居中入内centerInside
Glide.with(this).load(mImageUrl).centerInside().into(iv_network);

另外,Glide还支持圆形裁剪,也就是只显示图片中央的圆形区域,此时方法调用改成零零circleCrop,具体代码实例如下:

// 显示方式为圆形剪裁circleCrop
Glide.with(this).load(mImageUrl).circleCrop().into(iv_network);

以上四种显示效果如下图所示。
在这里插入图片描述
虽然Glide支持上述4种显示类型,但它无法设定FIT_XY对应的平铺方式,若想让图片平铺至充满整个图像视图,还得调用图像视图的setScaleType方法,将缩放类型设置为ImageView.ScaleType.FIT_XY
一旦把图像视图的缩放类型改为FIT_XY,则之前的4种显示方式也将呈现不一样的景象,缩放类型变更后的界面分别如下图所示。
在这里插入图片描述

利用Glide实现图片的三级缓存

图片加载框架之所以高效,是因为它不但封装了访问网络的步骤,而且引入了三级缓存机制。具体来说,是先到内存(运存)中查找图片,有找到就直接显示内存图片,没找到的话再去磁盘(闪存)查找图片;在磁盘能找到就直接显示磁盘图片,没找到的话再去请求网络;如此便形成“内存->磁盘->网络”的三级缓存,完整的缓存流程如下图:
在这里插入图片描述

对于Glide而言,默认已经开启了三级缓存机制,当然也可以根据实际情况另行调整。除此之外,Glide还提供了一些个性化的功能,方便开发者定制不同场景的需求。具体到编码上,则需想办法将个性化选项告知Glide,比如下面这段图片加载代码:

Glide.with(this).load(mImageUrl).into(iv_network);

可以拆分为以下两行代码:

// 构建一个加载网络图片的建造器
RequestBuilder<Drawable> builder = Glide.with(this).load(mImageUrl);
builder.into(iv_network);

原来load方法返回的是请求建造器,调用建造器对象的into方法,方能在图像视图上展示网络图片。除了into方法,建造器RequestBuilder还提供了apply方法,该方法表示启用指定的请求选项。于是添加了请求选项的完整代码示例如下:

// 构建一个加载网络图片的建造器
RequestBuilder<Drawable> builder = Glide.with(this).load(mImageUrl);
RequestOptions options = new RequestOptions(); // 创建Glide的请求选项
// 在图像视图上展示网络图片。apply方法表示启用指定的请求选项
builder.apply(options).into(iv_network);

可见请求选项为RequestOptions类型,详细的选项参数就交给它的下列方法了:

  • placeholder:设置加载开始的占位图。在得到网络图片之前,会先在图像视图上展现占位图。

  • error:设置发生错误的提示图。网络图片获取失败之时,会在图像视图上展现提示图。

  • override:设置图片的尺寸。注意该方法有多个重载方法,倘若调用只有一个参数的方法并设置Target.SIZE_ORIGINAL,表示展示原始图片;倘若调用拥有两个参数的方法,表示先将图片缩放到指定的宽度和高度,再展示缩放后的图片。

  • diskCacheStrategy:设置指定的缓存策略。各种缓存策略的取值见下表。
    | DiskCacheStrategy类的缓存策略 | 说明 |
    |–|–|
    | AUTOMATIC | 自动选择缓存策略 |
    | NONE | 不缓存图片 |
    | DATA | 只缓存原始图片 |
    | RESOURCE | 只缓存压缩后的图片 |
    | ALL | 同时缓存原始图片和压缩图片 |

  • skipMemoryCache:设置是否跳过内存(但不影响硬盘缓存)。为true表示跳过,为false则表示不跳过。

  • disallowHardwareConfig:关闭硬件加速,防止过大尺寸的图片加载报错。

  • fitCenter:保持图片的宽高比例并居中显示,图片需要顶到某个方向的边界但不能越过边界,对应缩放类型FIT_CENTER。

  • centerCrop:把排斥图片的宽高比例,充满整个图像视图,裁剪之后居中显示,对应缩放类型CENTER_CROP。

  • centerInside:保持图片的宽高比例,在图像视图内部居中显示,图片只能拉小不能拉大,对应缩放类型CENTER_INSIDE。

  • circleCrop:展示圆形裁剪之后的图片。

另外,Glide允许播放器加载过程的渐变动画,让图片从迷雾中逐渐变得清晰,有助于提高用户体验。这个渐变动画通过建造器的transition方法设置,调用代码示例如下:

// 设置时长3秒的渐变动画
builder.transition(DrawableTransitionOptions.withCrossFade(3000)); 

加载网络图片的渐变效果如下图所示。
在这里插入图片描述

使用Glide加载特殊图像

从Android 9.0开始增加了新的图像解码器ImageDecoder,该解码器支持直接读取GIF文件的图形数据,结合图形工具Animatable即可在图像视图上显示GIF动图。虽然通过ImageDecoder能够在界面上播放GIF动画,但是一方面实现代码有些臃肿,另一方面在Android 9.0之后才支持,显然不太好用。现在有了Glide,轻松加载GIF动图不在话下,简简单单只需下面一行代码:

Glide.with(this).load(R.drawable.happy).into(iv_cover);

使用Glide播放GIF动画的效果如下图所示:
在这里插入图片描述
除了支持GIF动画之外,Glide甚至还能自动加载视频封面,也就是把某个视频文件的首帧画面渲染到图像视图上。这个功能可谓是非常实在,先展示视频封面,等用户点击再开始播放,可以有效防止资源浪费。以加载本地视频的封面为例,首先到系统视频库中挑选某个视频,得到该视频的Uri对象后采用Glide加载,即可在图像上显示视频封面。视频挑选与封面加载代码示例如下:

// 注册一个善后工作的活动结果启动器,获取指定类型的内容
ActivityResultLauncher launcher = registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
    if (uri != null) { // 视频路径非空,则加载视频封面
        Glide.with(this).load(uri).into(iv_cover);
    }
});
findViewById(R.id.btn_local_cover).setOnClickListener(v -> launcher.launch("video/*"));

使用Glide加载视频封面的效果如下图:
在这里插入图片描述
Glide不仅能加载本地视频的封面,还能加载网络视频的封面。当然,由于下载网络视频很消耗带宽,因此要事先指定视频帧所处的时间点,这样Glide只会加载该位置的视频画面,无需下载整个视频。指定视频的时间点,用到了RequestOptions类的frameOf方法,具体的请求参数构建代码如下:

// 获取指定时间点的请求参数
private RequestOptions getOptions(int position) {
    // 指定某个时间位置的帧,单位微秒
    RequestOptions options = RequestOptions.frameOf(position*1000*1000);
    // 获取最近的视频帧
    options.set(VideoDecoder.FRAME_OPTION, MediaMetadataRetriever.OPTION_CLOSEST);
    // 执行从视频帧到位图对象的转换操作
    options.transform(new BitmapTransformation() {
        @Override
        protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) {
            return toTransform;
        }

        @Override
        public void updateDiskCacheKey(MessageDigest messageDigest) {
            try {
                messageDigest.update((getPackageName()).getBytes(StandardCharsets.UTF_8));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
    return options;
}

接着调用Glide的apply方法设置请求参数,并加载网络视频的封面图片,详细的加载代码示例如下:

// 加载第10秒处的视频画面
findViewById(R.id.btn_network_one).setOnClickListener(v -> {
    // 获取指定时间点的请求参数
    RequestOptions options = getOptions(10);
    // 加载网络视频的封面图片
    Glide.with(this).load(URL_MP4).apply(options).into(iv_cover);
});
// 加载第45秒处的视频画面
findViewById(R.id.btn_network_nine).setOnClickListener(v -> {
    // 获取指定时间点的请求参数
    RequestOptions options = getOptions(45);
    // 加载网络视频的封面图片
    Glide.with(this).load(URL_MP4).apply(options).into(iv_cover);
});

Glide加载网络视频封面的效果如下图所示。
在这里插入图片描述

即时通信

本节介绍App开发即时通信方面的几种进阶用法,内容包括:如何通过SocketIO在两台设备之间传输文本消息;如何通过Socket IO在两台设备之间传输图片消息;SocketIO的局限性和WebSocket协议,以及如何利用WebSocket更方便在设备之间传输各类消息。

通过SocketIO传输文本消息

虽然HTTP协议能够满足多数常见的接口交互,但是它属于短链接,每次调用完就自动断开连接,并且HTTP协议区分了服务端和客户端,双方的通信过程是单向的,只有客户端可以请求服务端,服务端无法向客户端推送消息。基于这些特点,HTTP协议仅能用于一次性的接口访问,而不适用于点对点的即时通信功能。
即时通信技术需要满足两方面的基本条件:一方面是长连接,以便在两台设备间持续通信,避免频繁的”连接-断开“再”连接-断开“如此反复而造成资源浪费;另一方面支持双向交流,既允许A设备主动向B设备发消息,又允许B设备主动向A设备发消息。这要求在套接字Socket层面进行通信,Socket连接一旦成功连上,便默认维持连接,直到有一方主动断开。而且Socket服务端支持向客户端的套接字推送消息,从而实现双向通信功能。
可是Java的Socket百年城比较繁琐,不仅要自行编写线程通信与IO处理的代码,还要自己定义数据包的内部格式以及编解码。为此,出现了第三方Socket通信框架SocketIO,该框架提供服务端和客户端的依赖包,大大简化了SocketIO,要先引入相关JAR包(点击查看服务端程序),接着编写如下的main方法监听文本发送事件:

public static void main(String[] args) {
    Configuration config = new Configuration();
    // 如果调用了setHostname方法,就只能通过主机名访问,不能通过IP访问
    //config.setHostname("localhost");
    config.setPort(9010); // 设置监听端口
    final SocketIOServer server = new SocketIOServer(config);
    // 添加连接连通的监听事件
    server.addConnectListener(client -> {
        System.out.println(client.getSessionId().toString()+"已连接");
    });
    // 添加连接断开的监听事件
    server.addDisconnectListener(client -> {
        System.out.println(client.getSessionId().toString()+"已断开");
    });
    // 添加文本发送的事件监听器
    server.addEventListener("send_text", String.class, (client, message, ackSender) -> {
        System.out.println(client.getSessionId().toString()+"发送文本消息:"+message);
        client.sendEvent("receive_text", "不开不开我不开,妈妈没回来谁来也不开。");
    });
    // 添加图像发送的事件监听器
    server.addEventListener("send_image", JSONObject.class, (client, json, ackSender) -> {
        String desc = String.format("%s,序号为%d", json.getString("name"), json.getIntValue("seq"));
        System.out.println(client.getSessionId().toString()+"发送图片消息:"+desc);
        client.sendEvent("receive_image", json);
    });

    server.start(); // 启动Socket服务
}

然后服务端执行main方法即可启动Socket服务进行监听。
在客户端继承SocketIO的话,要先修改build.gradle.kts,增加下面一行依赖配置:

implementation("io.socket:socket.io-client:1.0.1")

接着适用SocketIO提供的Socket工具完成消息的收发操作,Socket对象是由IO工具的socket方法获得的,它的常用方法分别说明如下:

  1. connect:建立Socket连接。
  2. connected:判断是否连上Socket。
  3. emit:向服务器提交指定事件的消息。
  4. on:开始监听服务器端推送的事件消息。
  5. off:取消监听服务端的推送的事件消息。
  6. disconnect:断开Socket连接。
  7. close:关闭Socket连接。关闭之后要重新获取新的Socket对象才能连接。

在两部手机之间Socket通信依旧区分发送方与接收方,且二者的消息收发通过Socket服务器中转。对于发送方的App来说,发消息的Socket操作流程:获取Socket对象->调用connect方法->调用emit方法往Socekt服务器发送消息。遂于接收方的App来说,收消息的Sokcet操作流程:获取Socket对象->调用connect方法->调用on方法从服务器接收消息。若想把Socket消息的收发功能集中在一个App上,让它既然充当发送方又充当接收方,则整理后的App消息收发流程如下图所示。
在这里插入图片描述
上图的实线表示代码的调用顺序,虚线表示异步的事件触发,例如用户的点击事件以及服务器的消息推送等。根据这个收发流程编写代码逻辑,具体实现代码如下:

public class SocketioTextActivity extends AppCompatActivity {
    private static final String TAG = "SocketioTextActivity";
    private EditText et_input; // 声明一个编辑框对象
    private TextView tv_response; // 声明一个文本视图对象
    private Socket mSocket; // 声明一个套接字对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_socketio_text);
        et_input = findViewById(R.id.et_input);
        tv_response = findViewById(R.id.tv_response);
        findViewById(R.id.btn_send).setOnClickListener(v -> {
            String content = et_input.getText().toString();
            if (TextUtils.isEmpty(content)) {
                Toast.makeText(this, "请输入聊天消息", Toast.LENGTH_SHORT).show();
                return;
            }
            mSocket.emit("send_text", content); // 往Socket服务器发送文本消息
        });
        initSocket(); // 初始化套接字
    }

    // 初始化套接字
    private void initSocket() {
        // 检查能否连上Socket服务器
        SocketUtil.checkSocketAvailable(this, NetConst.BASE_IP, NetConst.BASE_PORT);
        try {
            String uri = String.format("http://%s:%d/", NetConst.BASE_IP, NetConst.BASE_PORT);
            mSocket = IO.socket(uri); // 创建指定地址和端口的套接字实例
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
        mSocket.connect(); // 建立Socket连接
        // 等待接收传来的文本消息
        mSocket.on("receive_text", (args) -> {
            String desc = String.format("%s 收到服务端消息:%s",
                    DateUtil.getNowTime(), (String) args[0]);
            runOnUiThread(() -> tv_response.setText(desc));
        });
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mSocket.off("receive_text"); // 取消接收传来的文本消息
        if (mSocket.connected()) { // 已经连上Socket服务器
            mSocket.disconnect(); // 断开Socket连接
        }
        mSocket.close(); // 关闭Socket连接
    }
}

确保服务器的SocketServer正在运行(点击查看服务端代码),再运行测试该App,在编辑框输入待发送的文本,此时交互界面如下图所示。
在这里插入图片描述
接着点击“发送文本消息”按钮,向Socket服务器发送文本消息;随后接收到服务器推送的应答消息,应答内容展示在按钮下方,此时交互界面如下图所示,可见文本消息的收发流程成功走通。
在这里插入图片描述

通过SocketIO传输图片消息

上一小节借助SocketIO成功实现了文本消息的即时通信,然而文本内容只用到字符串,本来就比较简单。倘若让SocketIO实时传输图片,便步那么容易了。因为SocketIO不支持直接传输二进制数据,使得位图对象的字节数据无法作为emit方法的参数。除了字符串类型,SocketIO还支持JSONObject类型的数据,所以可以考虑利用JSON对象封装图像信息,把图像的字节数据通过BASE64编码成字符串保存起来。
鉴于JSON格式允许容纳多个字段,同时图片很有可能很大,因此建议将图片拆开分段传输,每段标明本次的分段序号、分段长度以及分段数据,由接收方在收到后重新拼成完整的图像。为此需要将原来的Socket收发过程改造一番,使之支持图片数据的即时通信,改造步骤说明如下。

  1. 给服务端的Socket监听程序添加以下代码,表示新增图像发送事件:
// 添加图像发送的事件监听器
server.addEventListener("send_image", JSONObject.class, (client, json, ackSender) -> {
    client.sendEvent("receive_image", json);
});
  1. 在App模块中定义一个图像分段结构,用于存放分段名称、分段数据、分段序号、分段长度等信息,该结构的关键代码如下:
public class ImagePart {
    private String name; // 分段名称
    private String data; // 分段数据
    private int seq; // 分段序号
    private int length; // 分段长度

    public ImagePart(String name, String data, int seq, int length) {
        this.name = name;
        this.data = data;
        this.seq = seq;
        this.length = length;
    }
}
  1. 回到App的活动代码,补充实现图像的分段传输功能。先将位图数据转为字节数组,再将字节数组分段编码为BASE64字符串,再组装成JSON对象传给Socket服务器。发送图像的示例代码如下:
private int mBlock = 50*1024; // 每段的数据包大小
// 分段传输图片数据
private void sendImage() {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    // 把位图数据压缩到字节数组输出流
    mBitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
    byte[] bytes = baos.toByteArray();
    int count = bytes.length/mBlock + 1;
    // 下面把图片数据经过BASE64编码后发给Socket服务器
    for (int i=0; i<count; i++) {
        String encodeData = "";
        if (i == count-1) { // 是最后一段图像数据
            int remain = bytes.length % mBlock;
            byte[] temp = new byte[remain];
            System.arraycopy(bytes, i*mBlock, temp, 0, remain);
            encodeData = Base64.encodeToString(temp, Base64.DEFAULT);
        } else { // 不是最后一段图像数据
            byte[] temp = new byte[mBlock];
            System.arraycopy(bytes, i*mBlock, temp, 0, mBlock);
            encodeData = Base64.encodeToString(temp, Base64.DEFAULT);
        }
        // 往Socket服务器发送本段的图片数据
        ImagePart part = new ImagePart(mFileName, encodeData, i, bytes.length);
        SocketUtil.emit(mSocket, "send_image", part); // 向服务器提交图像数据
    }
}
  1. 除了要实现发送方的图像发送功能,还需实现接收方的图像接收功能。先从服务器获取各段图像数据,等所有分段都接收完毕再按照分段序号依次凭借图像的字节数组,再从拼接好的字节数组解码得到位图对象,接收图像的示例代码如下:
private String mLastFile; // 上次的文件名
private int mReceiveCount; // 接收包的数量
private byte[] mReceiveData; // 收到的字节数组
// 接收对方传来的图片数据
private void receiveImage(Object... args) {
    JSONObject json = (JSONObject) args[0];
    ImagePart part = new Gson().fromJson(json.toString(), ImagePart.class);
    if (!part.getName().equals(mLastFile)) { // 与上次文件名不同,表示开始接收新文件
        mLastFile = part.getName();
        mReceiveCount = 0;
        mReceiveData = new byte[part.getLength()];
    }
    mReceiveCount++;
    // 把接收到的图片数据通过BASE64解码为字节数组
    byte[] temp = Base64.decode(part.getData(), Base64.DEFAULT);
    System.arraycopy(temp, 0, mReceiveData, part.getSeq()*mBlock, temp.length);
    // 所有数据包都接收完毕
    if (mReceiveCount >= part.getLength()/mBlock+1) {
        // 从字节数组中解码得到位图对象
        Bitmap bitmap = BitmapFactory.decodeByteArray(mReceiveData, 0, mReceiveData.length);
        String desc = String.format("%s 收到服务端消息:%s", DateUtil.getNowTime(), part.getName());
        runOnUiThread(() -> { // 回到主线程展示图片与描述文字
            tv_response.setText(desc);
            iv_response.setImageBitmap(bitmap);
        });
    }
}

在App代码中记得调用Socket对象的on方法,这样App才能正常接收服务器传来的图像数据。下面是on方法的调用代码:

// 等待接收传来的图片数据
mSocket.on("receive_image", (args) -> receiveImage(args));

完成上述几个步骤之后,确保服务器的SocketServer正在运行(点击查看服务器端代码),再运行测试该App,从系统相册中选择待发送的图片,此时交互界面如下图所示。
在这里插入图片描述
接着点击“发送图片”按钮,向Socket服务器发送图片消息;随后接收到服务器推送的应答消息,应答消息内容显示再按钮下方(包含文本和图片),此时交互界面如下图所示。可见图片消息发送流程成功完成。
在这里插入图片描述

利用WebSocket传输消息

在前面两小节中,文本与图片的即时通信都可以由SocketIO实现,看似它要统一即时通信了,可是深究起来会发现SocektIO存在很多局限,包括但不限以下几点:

  1. SocketIO不能直接传输字节数据,只能重新编码成字符串(比如BASE64)后再传输,造成了额外的系统开销。
  2. SokcetIO不能保证前后发送的数据被接收到时仍然是同样顺序,如果业务要求实现分段数据的有序性,开发者就得自己采取某种机制确保这种有序性。
  3. SocketIO服务器只有一个main程序,不可避免地会产生性能瓶颈。倘若有许多通信请求奔涌过来,一个main程序很难应对。

为了解决上述几点问题,业界提出了一种互联网时代的Socket协议,名叫WebSocket。它支持在TCP连接上进行全双工通信,这个协议在2011年被定义为互联网的标准之一,并纳入HTML5的规范体系。相对于传统的HTTP与Socket来说,WebSocket具备以下几点优势:

  1. 实时性更强,无须轮询即可实时获得对方设备的消息推送。
  2. 利用率更高,连接创建之后,基于相同的控制协议,每次交互的数据包头较小,节省了数据处理的开销。
  3. 功能更强大,WebSocket定义了二进制帧,使得传输二进制的字节数组十分容易。
  4. 扩展更方便,WebSocekt接口被托管在普通的Web服务至上,跟着Web服务扩容方便,有效规避了性能瓶颈。

WebSocket不仅拥有如此丰富的特性,而且用起来也特别简单。先说服务器的WebSocekt编程,除了引入它的依赖包javaee-api-8.0.1.jar,就只需添加如下的服务器代码

@ServerEndpoint("/testWebSocket")
public class WebSocketServer {
    // 存放每个客户端对应的WebSocket对象
    private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<WebSocketServer>();
    private Session mSession; // 当前的连接会话

    // 连接成功后调用
    @OnOpen
    public void onOpen(Session session) {
        System.out.println("WebSocket连接成功");
        this.mSession = session;
        webSocketSet.add(this);
    }

    // 连接关闭后调用
    @OnClose
    public void onClose() {
        System.out.println("WebSocket连接关闭");
        webSocketSet.remove(this);
    }

    // 连接异常时调用
    @OnError
    public void onError(Throwable error) {
        System.out.println("WebSocket连接异常");
        error.printStackTrace();
    }

    // 收到客户端消息时调用
    @OnMessage
    public void onMessage(String msg) throws Exception {
        System.out.println("接收到客户端消息:" + msg);
        for(WebSocketServer item : webSocketSet){
            item.mSession.getBasicRemote().sendText("我听到消息啦“"+msg+"”");
        }
    }
}

接着启动服务器Web工程,便能通过形如http://192.168.10.121:8000/HttpServer/testWebSocket这样的地址访问WebSocket。
再说App端的WebSocket编程,由于WebSocket协议尚未纳入JDK,因此要引入它所依赖的JAR包tyrus-standalone-client-1.17.jar。代码方面则需要自定义客户端的连接任务,注意给任务类添加注解@ClientEndpoint,表示该类属于WebSocket的客户端任务。任务内部需要重写onOpen(连接成功后调用)、processMessage(收到服务端消息时调用)、processError(收到服务端错误时调用)三个方法,还得定义一个向服务端发消息方法,消息内容支持文本与二进制两种格式。下面是处理客户端消息交互工作的示例代码:

@ClientEndpoint
public class AppClientEndpoint {
    private final static String TAG = "AppClientEndpoint";
    private Activity mAct; // 声明一个活动实例
    private OnRespListener mListener; // 消息应答监听器
    private Session mSession; // 连接会话

    public AppClientEndpoint(Activity act, OnRespListener listener) {
        mAct = act;
        mListener = listener;
    }

    // 向服务器发送请求报文
    public void sendRequest(String req) {
        Log.d(TAG, "发送请求报文:"+req);
        try {
            if (mSession != null) {
                RemoteEndpoint.Basic remote = mSession.getBasicRemote();
                remote.sendText(req); // 发送文本数据
                // remote.sendBinary(buffer); // 发送二进制数据
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 连接成功后调用
    @OnOpen
    public void onOpen(final Session session) {
        mSession = session;
        Log.d(TAG, "成功创建连接");
    }

    // 收到服务端消息时调用
    @OnMessage
    public void processMessage(Session session, String message) {
        Log.d(TAG, "WebSocket服务端返回:" + message);
        if (mListener != null) {
            mAct.runOnUiThread(() -> mListener.receiveResponse(message));
        }
    }

    // 收到服务端错误时调用
    @OnError
    public void processError(Throwable t) {
        t.printStackTrace();
    }

    // 定义一个WebSocket应答的监听器接口
    public interface OnRespListener {
        void receiveResponse(String resp);
    }
}

回到App的活动代码,依次执行下述步骤就能向WebSocket服务器发送消息:获取WebSocket容器->连接WebSocekt服务器->调用WebSocket任务的发送方法。其中前两步涉及的初始化代码如下:

// 初始化WebSocket的客户端任务
private void initWebSocket() {
    // 创建文本传输任务,并指定消息应答监听器
    mAppTask = new AppClientEndpoint(this, resp -> {
        String desc = String.format("%s 收到服务端返回:%s",
                DateUtil.getNowTime(), resp);
        tv_response.setText(desc);
    });
    // 获取WebSocket容器
    WebSocketContainer container = ContainerProvider.getWebSocketContainer();
    try {
        URI uri = new URI(SERVER_URL); // 创建一个URI对象
        // 连接WebSocket服务器,并关联文本传输任务获得连接会话
        Session session = container.connectToServer(mAppTask, uri);
        // 设置文本消息的最大缓存大小
        session.setMaxTextMessageBufferSize(1024 * 1024 * 10);
        // 设置二进制消息的最大缓存大小
        //session.setMaxBinaryMessageBufferSize(1024 * 1024 * 10);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

因为WebSocket接口任为网络操作,所以必须在分线程中初始化WebSocekt,启动初始化线程的代码如下:

new Thread(() -> initWebSocket()).start(); // 启动线程初始化WebSocket客户端

同理,发送WebSocket消息也要在分线程中操作,启动消息发送线程的代码如下:

new Thread(() -> mAppTask.sendRequest(content)).start(); // 启动线程发送文本消息

最后确保后端的Web服务正在运行(点击查看服务端代码),再运行测试该App,在编辑框输入待发送的文本,此时交互界面如下图所示。
在这里插入图片描述
接着点击“发送WEBSOCKET消息”按钮,向WebSocket服务器发送文本消息;随后接收到服务器推送的应答消息,应答内容显示在按钮下方,此时监护界面如下图所示。
在这里插入图片描述

工程源码

文章涉及所有代码可点击工程源码下载。