鸿蒙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端代码如下:

javascript 复制代码
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. 后台接口核心代码如下:

java 复制代码
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. 支持发送基本的表情

有兴趣的可以加入!

相关推荐
一只栖枝2 小时前
华为 HCIE 大数据认证中 Linux 命令行的运用及价值
大数据·linux·运维·华为·华为认证·hcie·it
zhanshuo5 小时前
在鸿蒙里优雅地处理网络错误:从 Demo 到实战案例
harmonyos
zhanshuo5 小时前
在鸿蒙中实现深色/浅色模式切换:从原理到可运行 Demo
harmonyos
whysqwhw11 小时前
鸿蒙分布式投屏
harmonyos
whysqwhw12 小时前
鸿蒙AVSession Kit
harmonyos
whysqwhw14 小时前
鸿蒙各种生命周期
harmonyos
whysqwhw15 小时前
鸿蒙音频编码
harmonyos
whysqwhw15 小时前
鸿蒙音频解码
harmonyos
whysqwhw15 小时前
鸿蒙视频解码
harmonyos
whysqwhw15 小时前
鸿蒙视频编码
harmonyos