Android 即时通信(一)SocketIO传输消息

一、简介
虽然HTTP协议能够满足多数常见的接口交互,但是他属于短连接,每次调用完就自动断开连接,并且HTTP协议区分了服务端和客户端,双方的通信过程是单向的,只有客户端可以请求服务端,服务端无法主动向客户端推送信息,所以它不适合点对点的即时通信功能

即时通信技术需要满足两方面的要求。一是长连接,以便在两台设备之间持续通信,避免频繁的连接断开操作,这样非常浪费资源。二是支持双向交流,既允许A设备主动向B设备发送消息,又允许B设备主动向A设备发送消息。

可是Java 的Socket编程比较繁琐,不仅要自行编写线程通信与IO处理的代码,还要自己定义数据包的内部格式以及解编码,为此出现了第三方的Socket通信框架SocketIO,该框架提供了服务端和客户端的依赖包,大大简化了Socket通信的开发工作量。
二、通过SocketIO传输文本消息
在服务端集成SocketIO,要先引入相关jar包(服务端程序),

netty-socketio-1.7.19.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,增加下面一行依赖配置:

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方法往Socket服务器发送消息。
对于接收方的App来说,收消息的Socket操作流程为:获取Socket对象→调用connect方法→调用on方法从服务器接收消息。
若想把Socket消息的收发功能集中在一个App,让它既充当发送方又充当接收方,则整理后的App消息收发流程如图【双向Socket通信的App消息收发流程】所示。

双向Socket通信的App消息收发流程.png

在上图【双向Socket通信的App消息收发流程】中的实线表示代码的调用顺序,虚线表示异步的事件触发,例如用户的点击事件以及服务器的消息推送等。根据这个收发流程编写代码逻辑,具体的实现代码如下:SocketioTextActivity

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,在编辑框输入待发送的文本,此时交互界面如【交互界面】图所示。

交互界面.png

三、通过SocketIO传输图片消息
倘若让SocketIO实时传输图片,便不那么容易了。因为SocketIO不支持直接传输二进制数据,使得位图对象的字节数据无法作为emit方法的输入参数。除了字符串类型,SocketIO还支持JSONObject类型的数据,所以可以考虑利用JSON对象封装图像信息,把图像的字节数据通过BASE64编码成字符串保存起来。
鉴于JSON格式允许容纳多个字段,同时图片有可能很大,因此建议将图片拆开分段传输,每段标明本次的分段序号、分段长度以及分段数据,由接收方在收到后重新拼接成完整的图像。为此需要将原来的Socket收发过程改造一番,使之支持图片数据的即时通信,改造步骤说明如下。首先给服务端的Socket侦听程序添加以下代码,表示新增图像发送事件:SocketServer

    // 添加图像发送的事件监听器
    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);
    });

接着在App模块中定义一个图像分段结构bean,用于存放分段名称、分段数据、分段序号、分段长度等信息,该结构的关键代码如下:

package com.example.network.bean;

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

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }

    public void setData(String data) {
        this.data = data;
    }

    public String getData() {
        return this.data;
    }

    public void setSeq(int seq) {
        this.seq = seq;
    }

    public int getSeq() {
        return this.seq;
    }

    public void setLength(int length) {
        this.length = length;
    }

    public int getLength() {
        return this.length;
    }

}

然后回到App的活动代码,补充实现图像的分段传输功能。先将位图数据转为字节数组,再将字节数组分段编码为BASE64字符串,再组装成JSON对象传给Socket服务器。发送图像的示例代码如下:SocketIoImageActivity

private int mBlock = 50 * 1024; // 每段的数据包大小
// 分段传输图片数据
private void sendImage() {
    Log.d(TAG, "----------sendImage---------");
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    // 把位图数据压缩到字节数组输出流
    mBitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
    byte[] bytes = baos.toByteArray();
    int count = bytes.length / mBlock + 1;
    Log.d(TAG, "sendTime length =" + bytes.length + ",count =" + count);
    // 下面把图片数据 经过Base64编码后发给Socket服务器
    for (int i = 0; i < count; i++) {
        Log.d(TAG, "sendImage i=" + 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(new Runnable() {
            @Override
            public void run() {
                tv_response.setText(desc);
                iv_response.setImageBitmap(bitmap);
            }
        });
    }
}

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

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

完成上述几个步骤之后,确保服务器的SocketServer正在运行(服务端程序SocketServer.java
),再运行并测试该App,从系统相册中选择待发送的图片,此时交互界面如图【上传图片】所示:

上传图片.png

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容