鸿蒙APP接纳WebSocket实现在线实时聊天

打印 上一主题 下一主题

主题 1005|帖子 1005|积分 3015

1. 案例情况:


  • 鸿蒙APP接纳ArkTS语法编写,API14情况,DevEco Studio 5.0.7.210编辑器开发
  • 后台接口基于SpringBoot,后台前端基于Vue开发
  • 核心技能接纳 WebSocket 举行通讯
2. 主要实现功能:


  • 实时聊天
  • 在线实时状态检测(后台断线,APP端可实时显示状态)
3. 运行实测结果图如下:


分析:

  • APP端和后台客服可以举行实时聊天
  • APP端顶部[在线客服]旁边有个绿色图标,表示连接正常,如果后台关闭了,则连接不正常,这个图标会立马变成灰色,后台服务规复正常后,该图标会立马变成绿色状态
  • 后台客服可以主动连接和断开连接
4. APP端代码如下:

  1. import webSocket from '@ohos.net.webSocket';
  2. import CommonConstants from '../../common/CommonConstants';
  3. import { tokenUtils } from '../../common/TokenUtils';
  4. import Logger from '../../common/utils/Logger';
  5. import { myTools } from '../../common/utils/MyTools';
  6. import { Header } from '../../component/Header';
  7. import { ChatModel } from '../../model/chat/ChatModel';
  8. //执行websocket通讯的对象
  9. let wsSocket = webSocket.createWebSocket()
  10. /**
  11. * 在线客服-页面
  12. */
  13. @Entry
  14. @Component
  15. struct ChatPage {
  16.   //当前登录人的用户ID
  17.   @State userId: number = -1;
  18.   //要发送的信息
  19.   @State sendMsg: string = ''
  20.   //ws服务端地址
  21.   @State wsServerUrl: string = "ws://" + CommonConstants.SERVER_IP + ":" + CommonConstants.SERVER_PORT + "/webSocket/"
  22.   //与后台 WebSocket 的连接状态
  23.   @State connectStatus: boolean = false
  24.   scroller: Scroller = new Scroller()
  25.   //是否绑定了事件处理程序
  26.   eventHandleBinded: boolean = false
  27.   @State intervalID: number = 0;
  28.   //消息集合
  29.   @State messageList: Array<ChatModel> = [];
  30.   //检查连接状态
  31.   checkStatus() {
  32.     if (!this.connectStatus) {
  33.       wsSocket.connect(this.wsServerUrl + this.userId)
  34.         .then((value) => {
  35.         })
  36.         .catch((e: Error) => {
  37.           this.connectStatus = false; //连接状态不可用
  38.         });
  39.     }
  40.     wsSocket.send('heartbeat')
  41.       .then((value) => {
  42.       })
  43.       .catch((e: Error) => {
  44.         this.connectStatus = false; //连接状态不可用
  45.       })
  46.   }
  47.   aboutToAppear(): void {
  48.     this.userId = tokenUtils.getUserInfo().id as number;
  49.     this.connect2Server();
  50.     //重复执行(此处注意:setInterval里面如果需要使用this的话,就必须使用匿名函数的写法,否则取不到值)
  51.     this.intervalID = setInterval(() => {
  52.       this.checkStatus();
  53.       Logger.debug('WebSocket连接状态=' + this.connectStatus)
  54.     }, 2000);
  55.   }
  56.   build() {
  57.     Row() {
  58.       Column() {
  59.         Stack() {
  60.           Header({ title: '在线客服', showBack: true, backgroundColorValue: '#ffffff' })
  61.           Image($r('app.media.svg_connectStatus'))
  62.             .fillColor(this.connectStatus ? '#1afa29' : '#cccccc')
  63.             .width(20)
  64.             .offset({ x: -75 })
  65.         }
  66.         //展示消息区域
  67.         Scroll(this.scroller) {
  68.           //展示消息
  69.           Column({ space: 30 }) {
  70.             ForEach(this.messageList, (item: ChatModel) => {
  71.               if (item.role == 'ai') {
  72.                 //客服展示在左侧
  73.                 Column({ space: 10 }) {
  74.                   //消息时间
  75.                   Row() {
  76.                     Text(item.createTime)
  77.                       .fontSize(11)
  78.                       .fontColor('#cccccc')
  79.                   }
  80.                   .padding({ left: 13 })
  81.                   .justifyContent(FlexAlign.Center)
  82.                   .width('100%')
  83.                   //消息和头像
  84.                   Row({ space: 5 }) {
  85.                     //头像
  86.                     Image(item.avatar)
  87.                       .width(45)
  88.                       .height(45)
  89.                       .borderRadius(3)
  90.                     //消息
  91.                     Text(item.text)
  92.                       .fontSize(14)
  93.                       .width('60%')
  94.                       .padding(12)
  95.                       .backgroundColor('#2c2c2c')
  96.                       .fontColor('#ffffff')
  97.                       .borderRadius(6)
  98.                   }
  99.                   .padding({ left: 13 })
  100.                   .justifyContent(FlexAlign.Start)
  101.                   .width('100%')
  102.                 }
  103.                 .width('100%')
  104.               } else {
  105.                 //用户自己展示在右侧
  106.                 Column({ space: 10 }) {
  107.                   //消息时间
  108.                   Row() {
  109.                     Text(item.createTime)
  110.                       .fontSize(11)
  111.                       .fontColor('#cccccc')
  112.                   }
  113.                   .padding({ right: 13 })
  114.                   .justifyContent(FlexAlign.Center)
  115.                   .width('100%')
  116.                   //消息和头像
  117.                   Row({ space: 5 }) {
  118.                     //消息
  119.                     Text(item.text)
  120.                       .fontSize(14)
  121.                       .width('60%')
  122.                       .padding(12)
  123.                       .backgroundColor('#1afa29')
  124.                       .fontColor('#141007')
  125.                       .borderRadius(6)
  126.                     //头像
  127.                     Image(item.avatar)
  128.                       .width(45)
  129.                       .height(45)
  130.                       .borderRadius(3)
  131.                   }
  132.                   .padding({ right: 13 })
  133.                   .justifyContent(FlexAlign.End)
  134.                   .width('100%')
  135.                 }
  136.                 .width('100%')
  137.               }
  138.             })
  139.           }
  140.           .width('100%')
  141.           .padding({ top: 20, bottom: 20 })
  142.         }
  143.         .align(Alignment.Top)
  144.         .layoutWeight(1)
  145.         .flexGrow(1)
  146.         .scrollable(ScrollDirection.Vertical)
  147.         .scrollBar(BarState.On)
  148.         .scrollBarWidth(5)
  149.         //发送消息输入框
  150.         Flex({ justifyContent: FlexAlign.End, alignItems: ItemAlign.Center }) {
  151.           TextInput({ text: this.sendMsg, placeholder: "请输入消息..." })
  152.             .flexGrow(1)
  153.             .borderRadius(1)
  154.             .onChange((value) => {
  155.               this.sendMsg = value
  156.             })
  157.           Button("发送", { type: ButtonType.Normal, stateEffect: true })
  158.             .enabled(this.connectStatus)
  159.             .width(90)
  160.             .fontSize(17)
  161.             .margin({ left: 5 })
  162.             .flexGrow(0)
  163.             .onClick(() => {
  164.               if (!this.sendMsg) {
  165.                 myTools.alertMsg('发送消息不能为空!');
  166.                 return;
  167.               }
  168.               this.sendMsg2Server()
  169.             })
  170.         }
  171.         .width('100%')
  172.         .padding(3)
  173.       }
  174.       .width('100%')
  175.       .justifyContent(FlexAlign.Start)
  176.       .height('100%')
  177.     }
  178.     .height('100%')
  179.     .padding({ top: CommonConstants.TOP_PADDING, bottom: CommonConstants.BOTTOM_PADDING })
  180.   }
  181.   //发送消息到服务端
  182.   sendMsg2Server() {
  183.     wsSocket.send(this.sendMsg)
  184.       .then((value) => {
  185.       })
  186.       .catch((e: Error) => {
  187.         this.connectStatus = false; //连接状态不可用
  188.       })
  189.     this.scroller.scrollEdge(Edge.Bottom);
  190.     this.sendMsg = ''; //清空消息
  191.   }
  192.   //连接服务端
  193.   connect2Server() {
  194.     this.bindEventHandle()
  195.     wsSocket.connect(this.wsServerUrl + this.userId)
  196.       .then((value) => {
  197.       })
  198.       .catch((e: Error) => {
  199.         this.connectStatus = false; //连接状态不可用
  200.       });
  201.   }
  202. }
复制代码
5. 后台接口核心代码如下:

  1. package cn.wujiangbo.WebSocket.server;
  2. import cn.hutool.core.util.ObjectUtil;
  3. import cn.wujiangbo.WebSocket.config.GetHttpSessionConfig;
  4. import cn.wujiangbo.WebSocket.pojo.ClientInfoEntity;
  5. import cn.wujiangbo.WebSocket.pojo.IM;
  6. import cn.wujiangbo.domain.app.AppUser;
  7. import cn.wujiangbo.service.app.AppUserService;
  8. import cn.wujiangbo.util.DateUtils;
  9. import cn.wujiangbo.util.SpringContextUtil;
  10. import com.aliyun.oss.ServiceException;
  11. import com.fasterxml.jackson.databind.ObjectMapper;
  12. import lombok.extern.slf4j.Slf4j;
  13. import org.springframework.stereotype.Component;
  14. import org.springframework.web.bind.annotation.CrossOrigin;
  15. import javax.annotation.PostConstruct;
  16. import javax.websocket.*;
  17. import javax.websocket.server.PathParam;
  18. import javax.websocket.server.ServerEndpoint;
  19. import java.io.IOException;
  20. import java.text.SimpleDateFormat;
  21. import java.time.LocalDateTime;
  22. import java.util.Date;
  23. import java.util.Iterator;
  24. import java.util.Map;
  25. import java.util.concurrent.ConcurrentHashMap;
  26. /**
  27. * <p>该类负责监听客户端的连接、断开连接、接收消息、发送消息等操作。</p>
  28. */
  29. @Slf4j
  30. @Component
  31. @CrossOrigin(origins = "*")
  32. @ServerEndpoint(value = "/webSocket/{userId}", configurator = GetHttpSessionConfig.class)
  33. public class WebSocketServer {
  34.     /**
  35.      * key:客户端连接唯一标识(用户ID)
  36.      * value:ClientInfoEntity
  37.      */
  38.     private static final Map<Long, ClientInfoEntity> uavWebSocketInfoMap = new ConcurrentHashMap<Long, ClientInfoEntity>();
  39.     //默认连接2小时
  40.     private static final int EXIST_TIME_HOUR = 2;
  41.     AppUserService appUserService;
  42.     //客服头像地址(替换成网络可访问的图片地址即可)
  43.     private String CUSTOMER_IAMGE = "";
  44.     /**
  45.      * 连接建立成功调用的方法
  46.      *
  47.      * @param session 第一个参数必须是session
  48.      * @param sec
  49.      * @param userId  代表客户端的唯一标识
  50.      */
  51.     @OnOpen
  52.     public void onOpen(Session session, EndpointConfig sec, @PathParam("userId") Long userId) {
  53.         if (uavWebSocketInfoMap.containsKey(userId)) {
  54.             throw new ServiceException("token已建立连接");
  55.         }
  56.         //把成功建立连接的会话在实体类中保存
  57.         ClientInfoEntity entity = new ClientInfoEntity();
  58.         entity.setUserId(userId);
  59.         entity.setSession(session);
  60.         //默认连接N个小时
  61.         entity.setExistTime(LocalDateTime.now().plusHours(EXIST_TIME_HOUR));
  62.         uavWebSocketInfoMap.put(userId, entity);
  63.         //之所以获取http session 是为了获取获取 httpsession 中的数据 (用户名/账号/信息)
  64.         System.out.println("WebSocket 连接建立成功,userId=: " + userId);
  65.     }
  66.     /**
  67.      * 当断开连接时调用该方法
  68.      */
  69.     @OnClose
  70.     public void onClose(Session session, @PathParam("userId") Long userId) {
  71.         // 找到关闭会话对应的用户 ID 并从 uavWebSocketInfoMap 中移除
  72.         if (ObjectUtil.isNotEmpty(userId) && uavWebSocketInfoMap.containsKey(userId)) {
  73.             uavWebSocketInfoMap.remove(userId);
  74.             System.out.println("WebSocket 连接关闭成功,userId=: " + userId);
  75.         }
  76.     }
  77.     /**
  78.      * 接受消息
  79.      * 这是接收和处理来自用户的消息的地方。我们需要在这里处理消息逻辑,可能包括广播消息给所有连接的用户。
  80.      */
  81.     @OnMessage
  82.     public void onMessage(Session session, @PathParam("userId") Long userId, String message) throws IOException {
  83.         log.info("接收到来自 [" + userId + "] 的消息:" + message);
  84.         //如果是心跳检测的话,直接返回success即可表示,后台服务是正常状态
  85.         if ("heartbeat".equals(message)) {
  86.             this.sendUserMessage(userId, "success");
  87.             return;
  88.         }
  89.         ClientInfoEntity entity = uavWebSocketInfoMap.get(userId);
  90.         if (entity == null) {
  91.             this.sendUserMessage(userId, "用户在线信息错误!");
  92.             return;
  93.         }
  94.         IM im = new IM();
  95.         if (userId != -1) {
  96.             appUserService = SpringContextUtil.getBean(AppUserService.class);
  97.             AppUser user = appUserService.getById(userId);
  98.             if (user == null) {
  99.                 this.sendUserMessage(userId, "用户信息不存在!");
  100.                 return;
  101.             }
  102.             im.setRole("user");//user表示APP用户发的消息
  103.             im.setUsername(user.getNickName());
  104.             im.setAvatar(user.getUserImg());
  105.         } else {
  106.             im.setRole("ai");//ai表示后台客服发的消息
  107.             im.setUsername("人工客服");
  108.             im.setAvatar(CUSTOMER_IAMGE);
  109.         }
  110.         im.setUid(userId);
  111.         im.setCreateTime(DateUtils.getCurrentDateString());
  112.         im.setText(message);
  113.         //只要接受到客户端的消息就进行续命(时间)
  114.         entity.setExistTime(LocalDateTime.now().plusHours(EXIST_TIME_HOUR));
  115.         uavWebSocketInfoMap.put(userId, entity);
  116.         String jsonStr = new ObjectMapper().writeValueAsString(im);  // 处理后的消息体
  117.         this.sendMessage(jsonStr);
  118.     }
  119.     /**
  120.      * 处理WebSocket中发生的任何异常。可以记录这些错误或尝试恢复。
  121.      */
  122.     @OnError
  123.     public void onError(Throwable error) {
  124.         log.error("报错信息:" + error.getMessage());
  125.         error.printStackTrace();
  126.     }
  127.     private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy:MM:dd hh:mm:ss");
  128.     /**
  129.      * 发送消息定时器
  130.      * 开启定时任务,每隔N秒向前台发送一次时间
  131.      */
  132.     @PostConstruct
  133. //    @Scheduled(cron = "0/59 * *  * * ? ")
  134.     public void refreshDate() {
  135.         //当没有客户端连接时阻塞等待
  136.         if (!uavWebSocketInfoMap.isEmpty()) {
  137.             //超过存活时间进行删除
  138.             Iterator<Map.Entry<Long, ClientInfoEntity>> iterator = uavWebSocketInfoMap.entrySet().iterator();
  139.             while (iterator.hasNext()) {
  140.                 Map.Entry<Long, ClientInfoEntity> entry = iterator.next();
  141.                 if (entry.getValue().getExistTime().compareTo(LocalDateTime.now()) <= 0) {
  142.                     log.info("WebSocket " + entry.getKey() + " 已到存活时间,自动断开连接");
  143.                     try {
  144.                         entry.getValue().getSession().close();
  145.                     } catch (IOException e) {
  146.                         log.error("WebSocket 连接关闭失败: " + entry.getKey() + " - " + e.getMessage());
  147.                     }
  148.                     //过期则进行移除
  149.                     iterator.remove();
  150.                 }
  151.             }
  152.             sendMessage(FORMAT.format(new Date()));
  153.         }
  154.     }
  155.     /**
  156.      * 群发信息的方法
  157.      *
  158.      * @param message 消息
  159.      */
  160.     public void sendMessage(String message) {
  161.         System.out.println("给所有APP用户发送消息:" + message + ",时间:" + DateUtils.getCurrentDateString());
  162.         //循环客户端map发送消息
  163.         uavWebSocketInfoMap.values().forEach(item -> {
  164.             //向每个用户发送文本信息。这里getAsyncRemote()解释一下,向用户发送文本信息有两种方式,
  165.             // 一种是getBasicRemote,一种是getAsyncRemote
  166.             //区别:getAsyncRemote是异步的,不会阻塞,而getBasicRemote是同步的,会阻塞,由于同步特性,第二行的消息必须等待第一行的发送完成才能进行。
  167.             // 而第一行的剩余部分消息要等第二行发送完才能继续发送,所以在第二行会抛出IllegalStateException异常。所以如果要使用getBasicRemote()同步发送消息
  168.             // 则避免尽量一次发送全部消息,使用部分消息来发送
  169.             item.getSession().getAsyncRemote().sendText(message);
  170.         });
  171.     }
  172.     /**
  173.      * 给指定用户发送消息
  174.      */
  175.     public void sendUserMessage(Long userId, String message) throws IOException {
  176.         System.out.println("给APP用户 [" + userId + "] 发送消息:" + message + ",时间:" + DateUtils.getCurrentDateString());
  177.         ClientInfoEntity clientInfoEntity = uavWebSocketInfoMap.get(userId);
  178.         if (clientInfoEntity != null && clientInfoEntity.getSession() != null) {
  179.             if (clientInfoEntity.getSession().isOpen()) {
  180.                 clientInfoEntity.getSession().getBasicRemote().sendText(message);
  181.             }
  182.         }
  183.     }
  184. }
复制代码

6. 规划

目前实现的功能非常有限,仅仅是一个基础的Demo,后面会基于这个出版,做一些迭代开发,规划如下:

  • 后台客服聊天页面,做一个APP端用户列表,可以选择和指定的用户聊天
  • APP端做一个好友列表,然后好友之间可以互相聊天
  • 支持发送基本的表情
有爱好的可以加入!


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

卖不甜枣

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表