鸿蒙APP采用WebSocket实现在线实时聊天
1. 案例环境:
- 鸿蒙APP采用ArkTS语法编写,API14环境,DevEco Studio 5.0.7.210编辑器开发
- 后台接口基于SpringBoot,后台前端基于Vue开发
- 核心技术采用 WebSocket 进行通讯
2. 主要实现功能:
- 实时聊天
- 在线实时状态检测(后台断线,APP端可实时显示状态)
3. 运行实测效果图如下:
说明:
- APP端和后台客服可以进行实时聊天
- APP端顶部[在线客服]旁边有个绿色图标,表示连接正常,如果后台关闭了,则连接不正常,这个图标会立马变成灰色,后台服务恢复正常后,该图标会立马变成绿色状态
- 后台客服可以主动连接和断开连接
4. APP端代码如下:
import webSocket from '@ohos.net.webSocket';
import CommonConstants from '../../common/CommonConstants';
import { tokenUtils } from '../../common/TokenUtils';
import Logger from '../../common/utils/Logger';
import { myTools } from '../../common/utils/MyTools';
import { Header } from '../../component/Header';
import { ChatModel } from '../../model/chat/ChatModel';
//执行websocket通讯的对象
let wsSocket = webSocket.createWebSocket()
/**
* 在线客服-页面
*/
@Entry
@Component
struct ChatPage {
//当前登录人的用户ID
@State userId: number = -1;
//要发送的信息
@State sendMsg: string = ''
//ws服务端地址
@State wsServerUrl: string = "ws://" + CommonConstants.SERVER_IP + ":" + CommonConstants.SERVER_PORT + "/webSocket/"
//与后台 WebSocket 的连接状态
@State connectStatus: boolean = false
scroller: Scroller = new Scroller()
//是否绑定了事件处理程序
eventHandleBinded: boolean = false
@State intervalID: number = 0;
//消息集合
@State messageList: Array<ChatModel> = [];
//检查连接状态
checkStatus() {
if (!this.connectStatus) {
wsSocket.connect(this.wsServerUrl + this.userId)
.then((value) => {
})
.catch((e: Error) => {
this.connectStatus = false; //连接状态不可用
});
}
wsSocket.send('heartbeat')
.then((value) => {
})
.catch((e: Error) => {
this.connectStatus = false; //连接状态不可用
})
}
aboutToAppear(): void {
this.userId = tokenUtils.getUserInfo().id as number;
this.connect2Server();
//重复执行(此处注意:setInterval里面如果需要使用this的话,就必须使用匿名函数的写法,否则取不到值)
this.intervalID = setInterval(() => {
this.checkStatus();
Logger.debug('WebSocket连接状态=' + this.connectStatus)
}, 2000);
}
build() {
Row() {
Column() {
Stack() {
Header({ title: '在线客服', showBack: true, backgroundColorValue: '#ffffff' })
Image($r('app.media.svg_connectStatus'))
.fillColor(this.connectStatus ? '#1afa29' : '#cccccc')
.width(20)
.offset({ x: -75 })
}
//展示消息区域
Scroll(this.scroller) {
//展示消息
Column({ space: 30 }) {
ForEach(this.messageList, (item: ChatModel) => {
if (item.role == 'ai') {
//客服展示在左侧
Column({ space: 10 }) {
//消息时间
Row() {
Text(item.createTime)
.fontSize(11)
.fontColor('#cccccc')
}
.padding({ left: 13 })
.justifyContent(FlexAlign.Center)
.width('100%')
//消息和头像
Row({ space: 5 }) {
//头像
Image(item.avatar)
.width(45)
.height(45)
.borderRadius(3)
//消息
Text(item.text)
.fontSize(14)
.width('60%')
.padding(12)
.backgroundColor('#2c2c2c')
.fontColor('#ffffff')
.borderRadius(6)
}
.padding({ left: 13 })
.justifyContent(FlexAlign.Start)
.width('100%')
}
.width('100%')
} else {
//用户自己展示在右侧
Column({ space: 10 }) {
//消息时间
Row() {
Text(item.createTime)
.fontSize(11)
.fontColor('#cccccc')
}
.padding({ right: 13 })
.justifyContent(FlexAlign.Center)
.width('100%')
//消息和头像
Row({ space: 5 }) {
//消息
Text(item.text)
.fontSize(14)
.width('60%')
.padding(12)
.backgroundColor('#1afa29')
.fontColor('#141007')
.borderRadius(6)
//头像
Image(item.avatar)
.width(45)
.height(45)
.borderRadius(3)
}
.padding({ right: 13 })
.justifyContent(FlexAlign.End)
.width('100%')
}
.width('100%')
}
})
}
.width('100%')
.padding({ top: 20, bottom: 20 })
}
.align(Alignment.Top)
.layoutWeight(1)
.flexGrow(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.On)
.scrollBarWidth(5)
//发送消息输入框
Flex({ justifyContent: FlexAlign.End, alignItems: ItemAlign.Center }) {
TextInput({ text: this.sendMsg, placeholder: "请输入消息..." })
.flexGrow(1)
.borderRadius(1)
.onChange((value) => {
this.sendMsg = value
})
Button("发送", { type: ButtonType.Normal, stateEffect: true })
.enabled(this.connectStatus)
.width(90)
.fontSize(17)
.margin({ left: 5 })
.flexGrow(0)
.onClick(() => {
if (!this.sendMsg) {
myTools.alertMsg('发送消息不能为空!');
return;
}
this.sendMsg2Server()
})
}
.width('100%')
.padding(3)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.height('100%')
}
.height('100%')
.padding({ top: CommonConstants.TOP_PADDING, bottom: CommonConstants.BOTTOM_PADDING })
}
//发送消息到服务端
sendMsg2Server() {
wsSocket.send(this.sendMsg)
.then((value) => {
})
.catch((e: Error) => {
this.connectStatus = false; //连接状态不可用
})
this.scroller.scrollEdge(Edge.Bottom);
this.sendMsg = ''; //清空消息
}
//连接服务端
connect2Server() {
this.bindEventHandle()
wsSocket.connect(this.wsServerUrl + this.userId)
.then((value) => {
})
.catch((e: Error) => {
this.connectStatus = false; //连接状态不可用
});
}
}
5. 后台接口核心代码如下:
package cn.wujiangbo.WebSocket.server;
import cn.hutool.core.util.ObjectUtil;
import cn.wujiangbo.WebSocket.config.GetHttpSessionConfig;
import cn.wujiangbo.WebSocket.pojo.ClientInfoEntity;
import cn.wujiangbo.WebSocket.pojo.IM;
import cn.wujiangbo.domain.app.AppUser;
import cn.wujiangbo.service.app.AppUserService;
import cn.wujiangbo.util.DateUtils;
import cn.wujiangbo.util.SpringContextUtil;
import com.aliyun.oss.ServiceException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.CrossOrigin;
import javax.annotation.PostConstruct;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>该类负责监听客户端的连接、断开连接、接收消息、发送消息等操作。</p>
*/
@Slf4j
@Component
@CrossOrigin(origins = "*")
@ServerEndpoint(value = "/webSocket/{userId}", configurator = GetHttpSessionConfig.class)
public class WebSocketServer {
/**
* key:客户端连接唯一标识(用户ID)
* value:ClientInfoEntity
*/
private static final Map<Long, ClientInfoEntity> uavWebSocketInfoMap = new ConcurrentHashMap<Long, ClientInfoEntity>();
//默认连接2小时
private static final int EXIST_TIME_HOUR = 2;
AppUserService appUserService;
//客服头像地址(替换成网络可访问的图片地址即可)
private String CUSTOMER_IAMGE = "";
/**
* 连接建立成功调用的方法
*
* @param session 第一个参数必须是session
* @param sec
* @param userId 代表客户端的唯一标识
*/
@OnOpen
public void onOpen(Session session, EndpointConfig sec, @PathParam("userId") Long userId) {
if (uavWebSocketInfoMap.containsKey(userId)) {
throw new ServiceException("token已建立连接");
}
//把成功建立连接的会话在实体类中保存
ClientInfoEntity entity = new ClientInfoEntity();
entity.setUserId(userId);
entity.setSession(session);
//默认连接N个小时
entity.setExistTime(LocalDateTime.now().plusHours(EXIST_TIME_HOUR));
uavWebSocketInfoMap.put(userId, entity);
//之所以获取http session 是为了获取获取 httpsession 中的数据 (用户名/账号/信息)
System.out.println("WebSocket 连接建立成功,userId=: " + userId);
}
/**
* 当断开连接时调用该方法
*/
@OnClose
public void onClose(Session session, @PathParam("userId") Long userId) {
// 找到关闭会话对应的用户 ID 并从 uavWebSocketInfoMap 中移除
if (ObjectUtil.isNotEmpty(userId) && uavWebSocketInfoMap.containsKey(userId)) {
uavWebSocketInfoMap.remove(userId);
System.out.println("WebSocket 连接关闭成功,userId=: " + userId);
}
}
/**
* 接受消息
* 这是接收和处理来自用户的消息的地方。我们需要在这里处理消息逻辑,可能包括广播消息给所有连接的用户。
*/
@OnMessage
public void onMessage(Session session, @PathParam("userId") Long userId, String message) throws IOException {
log.info("接收到来自 [" + userId + "] 的消息:" + message);
//如果是心跳检测的话,直接返回success即可表示,后台服务是正常状态
if ("heartbeat".equals(message)) {
this.sendUserMessage(userId, "success");
return;
}
ClientInfoEntity entity = uavWebSocketInfoMap.get(userId);
if (entity == null) {
this.sendUserMessage(userId, "用户在线信息错误!");
return;
}
IM im = new IM();
if (userId != -1) {
appUserService = SpringContextUtil.getBean(AppUserService.class);
AppUser user = appUserService.getById(userId);
if (user == null) {
this.sendUserMessage(userId, "用户信息不存在!");
return;
}
im.setRole("user");//user表示APP用户发的消息
im.setUsername(user.getNickName());
im.setAvatar(user.getUserImg());
} else {
im.setRole("ai");//ai表示后台客服发的消息
im.setUsername("人工客服");
im.setAvatar(CUSTOMER_IAMGE);
}
im.setUid(userId);
im.setCreateTime(DateUtils.getCurrentDateString());
im.setText(message);
//只要接受到客户端的消息就进行续命(时间)
entity.setExistTime(LocalDateTime.now().plusHours(EXIST_TIME_HOUR));
uavWebSocketInfoMap.put(userId, entity);
String jsonStr = new ObjectMapper().writeValueAsString(im); // 处理后的消息体
this.sendMessage(jsonStr);
}
/**
* 处理WebSocket中发生的任何异常。可以记录这些错误或尝试恢复。
*/
@OnError
public void onError(Throwable error) {
log.error("报错信息:" + error.getMessage());
error.printStackTrace();
}
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy:MM:dd hh:mm:ss");
/**
* 发送消息定时器
* 开启定时任务,每隔N秒向前台发送一次时间
*/
@PostConstruct
// @Scheduled(cron = "0/59 * * * * ? ")
public void refreshDate() {
//当没有客户端连接时阻塞等待
if (!uavWebSocketInfoMap.isEmpty()) {
//超过存活时间进行删除
Iterator<Map.Entry<Long, ClientInfoEntity>> iterator = uavWebSocketInfoMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Long, ClientInfoEntity> entry = iterator.next();
if (entry.getValue().getExistTime().compareTo(LocalDateTime.now()) <= 0) {
log.info("WebSocket " + entry.getKey() + " 已到存活时间,自动断开连接");
try {
entry.getValue().getSession().close();
} catch (IOException e) {
log.error("WebSocket 连接关闭失败: " + entry.getKey() + " - " + e.getMessage());
}
//过期则进行移除
iterator.remove();
}
}
sendMessage(FORMAT.format(new Date()));
}
}
/**
* 群发信息的方法
*
* @param message 消息
*/
public void sendMessage(String message) {
System.out.println("给所有APP用户发送消息:" + message + ",时间:" + DateUtils.getCurrentDateString());
//循环客户端map发送消息
uavWebSocketInfoMap.values().forEach(item -> {
//向每个用户发送文本信息。这里getAsyncRemote()解释一下,向用户发送文本信息有两种方式,
// 一种是getBasicRemote,一种是getAsyncRemote
//区别:getAsyncRemote是异步的,不会阻塞,而getBasicRemote是同步的,会阻塞,由于同步特性,第二行的消息必须等待第一行的发送完成才能进行。
// 而第一行的剩余部分消息要等第二行发送完才能继续发送,所以在第二行会抛出IllegalStateException异常。所以如果要使用getBasicRemote()同步发送消息
// 则避免尽量一次发送全部消息,使用部分消息来发送
item.getSession().getAsyncRemote().sendText(message);
});
}
/**
* 给指定用户发送消息
*/
public void sendUserMessage(Long userId, String message) throws IOException {
System.out.println("给APP用户 [" + userId + "] 发送消息:" + message + ",时间:" + DateUtils.getCurrentDateString());
ClientInfoEntity clientInfoEntity = uavWebSocketInfoMap.get(userId);
if (clientInfoEntity != null && clientInfoEntity.getSession() != null) {
if (clientInfoEntity.getSession().isOpen()) {
clientInfoEntity.getSession().getBasicRemote().sendText(message);
}
}
}
}
6. 规划
目前实现的功能非常有限,仅仅是一个基础的Demo,后面会基于这个出版,做一些迭代开发,规划如下:
- 后台客服聊天页面,做一个APP端用户列表,可以选择和指定的用户聊天
- APP端做一个好友列表,然后好友之间可以互相聊天
- 支持发送基本的表情
有兴趣的可以加入!