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

本章介绍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个步骤:

  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方法,还有其他的调度方法,常用的几个方法分别说明如下:

  • 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串的内容可以梳理出它的基本格式定义,详细说明如下:

  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服务器发送文本消息;随后接收到服务器推送的应答消息,应答内容显示在按钮下方,此时监护界面如下图所示。
在这里插入图片描述

工程源码

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/718370.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

全平台无水印下载软件【电脑版】

支持抖音&#xff0c;快手&#xff0c;小红书&#xff0c;电脑PC端使用。 链接&#xff1a;https://pan.baidu.com/s/1969HwHNyqYL_GJtB0n0G_w?pwd2sjn 提取码&#xff1a;2sjn

RedHat9 | Web服务配置与管理(Apache)

一、实验环境 1、Apache服务介绍 Apache服务&#xff0c;也称为Apache HTTP Server&#xff0c;是一个功能强大且广泛使用的Web服务器软件。 起源和背景 Apache起源于NCSA httpd服务器&#xff0c;经过多次修改和发展&#xff0c;逐渐成为世界上最流行的Web服务器软件之一。…

yolov5-7.0更改resnet主干网络

参考链接 ClearML教程:https://blog.csdn.net/qq_40243750/article/details/126445671 b站教学视频&#xff1a;https://www.bilibili.com/video/BV1Mx4y1A7jy/spm_id_from333.788&vd_sourceb52b79abfe565901e6969da2a1191407 开始 github地址:https://github.com/z106…

【机器学习300问】121、RNN是如何生成文本的?

当RNN模型训练好后&#xff0c;如何让他生成一个句子&#xff1f;其实就是一个RNN前向传播的过程。通常遵循以下的步骤。 &#xff08;1&#xff09;初始化 文本生成可以什么都不给&#xff0c;让他生成一首诗。首先&#xff0c;你需要确定采样的起始点。这可以是一个特殊的开…

CAD二次开发(9)- CAD中对象的实时选择

1. 点的拾取 有时候我们需要在CAD画布上实时选取起始点和结束点&#xff0c;然后绘制出来一条直线。实现如下&#xff1a; public void getPoint(){var doc Application.DocumentManager.MdiActiveDocument;var editor doc.Editor;var docDatabase doc.Database;PromptPoi…

中国银行信息科技运营中心、软件中心春招笔试测评面试体检全记录

本文介绍2024届春招中&#xff0c;中国银行下属各部门统一笔试&#xff0c;以及信息科技运营中心与软件中心各自的面试&#xff0c;以及编程能力测评、体检等相关环节的具体流程、相关信息等。 2024年04月投递了中国银行的信息科技类岗位&#xff0c;一共投递了4个岗位&#xf…

API接口设计的艺术:如何提升用户体验和系统性能

在数字时代&#xff0c;API接口的设计对于用户体验和系统性能有着至关重要的影响。良好的设计可以显著提升应用程序的响应速度、可靠性和易用性。以下是几个关键点&#xff0c;帮助改善API接口的设计&#xff1a; 1. 理解并定义清晰的要求 用户研究&#xff1a;与最终用户进行…

python 集合

文章目录 一、什么是集合1.1 创建集合的方式1.2 集合的增删改查操作1.2.1 集合的元素删除操作1.2.2 集合的元素修改操作 1.3 集合中运算符的使用 一、什么是集合 集合&#xff1a; 用来存储数据&#xff0c;和字典一样&#xff0c;都是用 {}表示&#xff0c;只是集合中的数据是…

java中的ThreadLocal

ThreadLocal是线程局部变量&#xff0c;同一份变量在每一个线程中都保存一份副本&#xff0c;彼此线程之间操作互不影响 测试ThreadLocal package com.alibaba.fescar.core.protocol.test;public class TestThreadLocal {private static ThreadLocal<Integer> threadLoc…

泛微开发修炼之旅--17基于Ecology短信平台,实现后端自定义二开短信发送方案及代码示例

文章链接&#xff1a;17基于Ecology短信平台&#xff0c;实现后端自定义二开短信发送方案及代码示例

图像分割——U-Net论文介绍+代码(PyTorch)

0、概要 原理大致介绍了一下&#xff0c;后续会不断精进改的更加详细&#xff0c;然后就是代码可以对自己的数据集进行一个训练&#xff0c;还会不断完善&#xff0c;相应其他代码可以私信我。 一、论文内容总结 摘要&#xff1a;人们普遍认为&#xff0c;深度网络成功需要数…

全面了解三大 AI 绘画:Midjourney、Stable Diffusion、DALL·E 的区别和特点

大家好&#xff0c;我是设计师阿威 在当前&#xff0c;比较流行的 AI 绘画软件主要有三个&#xff0c;分别是&#xff1a;StabilityAI 公司的 Stable Diffusion&#xff0c;OpenAI 公司的 DALLE2&#xff0c;以及更为大众所熟知的&#xff0c;Leap Motion公司创始人 David Hol…

大前端 业务架构 插件库 设计模式 属性 线程

大前端 业务架构 插件库 适配模式之(多态)协议1对多 抽象工厂模式 观察者模式 外观模式 装饰模式之参考catagory 策略模式 属性

单片机建立自己的库文件(4)

文章目录 前言一、新建自己的外设文件夹1.新建外设文件夹&#xff0c;做项目好项目文件管理2.将之前写的.c .h 文件添加到文件夹中 二、在软件中添加项目 .c文件2.1 编译工程保证没问题2. 修改项目列表下的名称 三、在软件项目中添加 .h文件路径四、实际使用测试总结 前言 提示…

性能测试、负载测试、压力测试、稳定性测试简单区分【超详细】

&#x1f345; 视频学习&#xff1a;文末有免费的配套视频可观看 &#x1f345; 点击文末小卡片 &#xff0c;免费获取软件测试全套资料&#xff0c;资料在手&#xff0c;涨薪更快 性能测试是一个总称&#xff0c;可细分为性能测试、负载测试、压力测试、稳定性测试。 性能测试…

大量用户中招,远控木马已经潜伏各类在线会议平台

从 2023 年 12 月开始&#xff0c;研究人员发现有攻击者创建虚假 Skype、Google Meet 和 Zoom 网站来进行恶意软件传播。攻击者为安卓用户投递 SpyNote 远控木马&#xff0c;为 Windows 用户投递 NjRAT 和 DCRAT 远控木马。 攻击行动概述 攻击者在单个 IP 地址上部署了所有的虚…

LabVIEW电表改装与校准仿真系统

LabVIEW开发的电表改装与校准仿真实验平台不仅简化了传统的物理实验流程&#xff0c;而且通过虚拟仿真提高了实验的效率和安全性。该平台通过模拟电表改装与校准的各个步骤&#xff0c;允许学生在没有实际硬件的情况下完成实验&#xff0c;有效地结合了理论学习和实践操作。 项…

RAG未来的出路

总有人喊RAG已死,至少看目前不现实。 持这个观点的人,大多是Long context派,老实说,这派人绝大多数不甚理解长上下文的技术实现点,就觉得反正context越长,越牛B,有点饭圈化 ,当然我并不否认长上下文对提升理解力的一些帮助,就是没大家想的那么牛B而已(说个数据,达到…

Hazelcast 分布式缓存 在Seatunnel中的使用

1、背景 最近在调研seatunnel的时候&#xff0c;发现新版的seatunnel提供了一个web服务&#xff0c;可以用于图形化的创建数据同步任务&#xff0c;然后管理任务。这里面有个日志模块&#xff0c;可以查看任务的执行状态。其中有个取读数据条数和同步数据条数。很好奇这个数据…

Playwright鼠标悬浮元素定位方法

优点&#xff1a;你把鼠标点烂&#xff0c;把它从20楼丢下去&#xff0c;元素定位就在那&#xff0c;他不动&#xff0c;我说的偶像&#xff01; F12打开浏览器的调试页面 点击源代码Sources 右侧找到事件监听器断点&#xff08;Event Listener breakpoints&#xff09;&#…