本章介绍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***方法,下面是这些消息操作方法的使用说明。
- obtainMessage:获取当前的消息对象。
- sendMessage:立即发送指定消息。
- sendMessageDelayed:延迟一段时间后发送指定消息。
- sendMessageAtTime:在设置的时间点发送指定消息。
- sendEmptyMessage:立即发送空消息。
- sendEmptyMessageDelayed:延迟一段时间后发送空消息。
- sendEmptyMessageAtTime:在设置的时间点发送空消息。
- removeMessages:从消息队列移除指定标识的消息。
- hasMessages:判断消息队列是否存在指定标识的消息。
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个步骤:
- 构建约束条件
该步骤说明在哪些情况下才能执行后台任务,也就是运行后台任务的前提条件,此时用到了约束工具Constraints。约束条件的构建代码如下:
// 1、构建约束条件
Constraints constraints = new Constraints.Builder()
//.setRequiresBatteryNotLow(true) // 设备电量充足
//.setRequiresCharging(true) // 设备正在充电
.setRequiredNetworkType(NetworkType.CONNECTED) // 已经连上网络
.build();
- 构建输入数据
该步骤把后台任务需要的参数封装到一个数据对象,此时用到了数据工具Data,构建输入数据的示例代码如下:
// 2、构建输入数据
Data inputData = new Data.Builder()
.putString("name", "小明")
.putInt("height", 180)
.putDouble("weight", 80)
.build();
- 构建工作请求
该步骤把约束条件、输入数据等请求内容组装起来。此时用到了工作请求工具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(); // 获取工作请求的编号
- 执行工作请求
该步骤生成工作管理器实例,并将步骤3的工作请求对象加入管理器的执行队列中,由管理器调度并执行请求任务,执行工作请求的实例代码如下:
// 4、执行工作请求
WorkManager workManager = WorkManager.getInstance(this);
workManager.enqueue(onceRequest); // 将工作请求加入执行队列
工作管理器不知拥有enqueue方法,还有其他的调度方法,常用的几个方法分别说明如下:
- enqueue:将工作请求加入执行队列中。
- cancelWorkById:取消指定编号(步骤3 getId方法返回workId)的工作。
- cancelAllWorkByTag:取消指定标签(步骤3设置的workTag)的所有工作。
- cancelAllWork:取消所有工作。
- getWorkInfoByIdLiveData:获取指定编号的工作信息。
鉴于后台任务是异步执行的,因此若想知晓工作任务的处理结果,就得调用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串的内容可以梳理出它的基本格式定义,详细说明如下:
- 整个JSON串由一对花括号包裹,并且内部的每个结构都以花括号包起来。
- 参数格式类似键值对,其中键名与键值以冒号分隔,形如“键名:键值”。
- 两个键值对之间以逗号分隔。
- 键名需要用双引号引起来,键值为数字的话则无需双引号,为字符串的话仍需双引号。
- 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常见的应用场合主要有下列两个:
- 将数据对象转换为JSON字符串。此时可调用Gson工具的toJson方法,把指定的数据对象转换为JSON字符串。
- 从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个步骤:
- 使用OkHttpClient类创建一个okhttp客户端对象。创建客户端对象的示例代码如下:
OkHttpClient client = new OkHttpClient(); // 创建一个okhttp客户端对象
- 使用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中客户端对象的newCall方法,方法参数为步骤2中的请求结构,从而创建Call类型的调用对象。创建调用对象的实例代码如下:
Call call = client.newCall(request); // 根据请求结构创建调用对象
- 调用步骤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个参数,说明如下:
- 当前页面的活动实例,参数类型为Activity。如果是在页面代码内部调用,则填写this表示当前活动即可。
- 网络图片的谅解地址,以http或者https大头,参数类型为字符串。
- 准备显示网络图片的图像视图实例,参数类型为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方法获得的,它的常用方法分别说明如下:
- connect:建立Socket连接。
- connected:判断是否连上Socket。
- emit:向服务器提交指定事件的消息。
- on:开始监听服务器端推送的事件消息。
- off:取消监听服务端的推送的事件消息。
- disconnect:断开Socket连接。
- 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收发过程改造一番,使之支持图片数据的即时通信,改造步骤说明如下。
- 给服务端的Socket监听程序添加以下代码,表示新增图像发送事件:
// 添加图像发送的事件监听器
server.addEventListener("send_image", JSONObject.class, (client, json, ackSender) -> {
client.sendEvent("receive_image", json);
});
- 在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;
}
}
- 回到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); // 向服务器提交图像数据
}
}
- 除了要实现发送方的图像发送功能,还需实现接收方的图像接收功能。先从服务器获取各段图像数据,等所有分段都接收完毕再按照分段序号依次凭借图像的字节数组,再从拼接好的字节数组解码得到位图对象,接收图像的示例代码如下:
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存在很多局限,包括但不限以下几点:
- SocketIO不能直接传输字节数据,只能重新编码成字符串(比如BASE64)后再传输,造成了额外的系统开销。
- SokcetIO不能保证前后发送的数据被接收到时仍然是同样顺序,如果业务要求实现分段数据的有序性,开发者就得自己采取某种机制确保这种有序性。
- SocketIO服务器只有一个main程序,不可避免地会产生性能瓶颈。倘若有许多通信请求奔涌过来,一个main程序很难应对。
为了解决上述几点问题,业界提出了一种互联网时代的Socket协议,名叫WebSocket。它支持在TCP连接上进行全双工通信,这个协议在2011年被定义为互联网的标准之一,并纳入HTML5的规范体系。相对于传统的HTTP与Socket来说,WebSocket具备以下几点优势:
- 实时性更强,无须轮询即可实时获得对方设备的消息推送。
- 利用率更高,连接创建之后,基于相同的控制协议,每次交互的数据包头较小,节省了数据处理的开销。
- 功能更强大,WebSocket定义了二进制帧,使得传输二进制的字节数组十分容易。
- 扩展更方便,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服务器发送文本消息;随后接收到服务器推送的应答消息,应答内容显示在按钮下方,此时监护界面如下图所示。
工程源码
文章涉及所有代码可点击工程源码下载。