当前位置: 首页 > article >正文

鸿蒙APP采用WebSocket实现在线实时聊天

1. 案例环境:

  1. 鸿蒙APP采用ArkTS语法编写,API14环境,DevEco Studio 5.0.7.210编辑器开发
  2. 后台接口基于SpringBoot,后台前端基于Vue开发
  3. 核心技术采用 WebSocket 进行通讯

2. 主要实现功能:

  1. 实时聊天
  2. 在线实时状态检测(后台断线,APP端可实时显示状态)

3. 运行实测效果图如下:

说明:

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

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,后面会基于这个出版,做一些迭代开发,规划如下:

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

有兴趣的可以加入!


http://www.kler.cn/a/584124.html

相关文章:

  • 队列的简单例题
  • 【故障处理系列--docker卷的挂载】
  • 我又又又又又又更新了~~纯手工编写C++画图,有注释~~~
  • vs code配置 c/C++
  • 第1关:整数对
  • 鸿蒙开发者社区资源的重要性
  • K8s 1.27.1 实战系列(九)Volume
  • 【Swift】面向协议编程之HelloWorld
  • 网络安全与七层架构
  • 【AIGC图生视频】蓝耘实践:通义万相2.1进阶玩法
  • 爬虫逆向:Unicorn 详细使用指南
  • 城市客运安全员适合哪几类人报考
  • 卷积神经网络(笔记03)
  • Android调试工具之ADB
  • WPF未来展望:紧跟技术发展趋势,探索新的可能性
  • Spring Boot 集成 Lua 脚本:实现高效业务逻辑处理
  • 抖音生活服务联动监管开展专项整治 济南66家违规餐饮商家下架
  • springboot websocket语音识别翻译
  • 代码随想录二刷|图论2
  • LVGL 中设置 UI 层局部透明,显示下方视频层