前阵子开发一个内部即时通讯(IM)系统,需要将现有APP的功能移植到小程序平台。由于业务需求特殊,市面上现成的IM界面不能完全满足我们的需求,因此决定自主设计IM流程。该项目基于uniapp和vue2开发。
技术选型
经过评,我们选择了腾讯的IM SDK作为基础。虽然之前APP使用的是网易的解决方案,但考虑到小程序平台的特性,腾讯的SDK更为合适。鉴于我们有大量自定义界面的需求,我们采用了无 UI 的方案的SDK方案。
设计
整个 IM 串起来有初始化、登录、创建群(目前场景涉及到创建群)、聊天室、会话列表、音视频通话等几个场景。按照顺序一一介绍
初始化
安装对应的 SDK
npm install @tencentcloud/chat
// 发送图片、文件等消息需要腾讯云即时通信 IM 上传插件
npm install tim-upload-plugin --save
初始化之前需要拿到 SDKAppID 和 userSig 需要公司提前开通即时通讯的服务
当前的做法是 sdkAppId 写在前端,通过 userId 和 sdkAppId 调接口生成 userSig。进而创建 TencentCloudChat 实例,接下来利用实例进行一系列的操作,例如:注册 IM 上传插件,监听消息、登录等。代码如下:
export const initImChat = () => {
const userSig = uni.getStorageSync("userSig");
uni._$chat = TencentCloudChat.create(SDKOption);
uni._$chat.registerPlugin({ "tim-upload-plugin": TIMUploadPlugin });
uni._$chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
uni._$chat.on(TencentCloudChat.EVENT.TOTAL_UNREAD_MESSAGE_COUNT_UPDATED, onTotalUnreadMessageCountUpdated);
uni._$chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady);
login();
};
登录
export const login = () => {
const userSig = uni.getStorageSync("userSig");
const userID = uni.getStorageSync("userID");
uni._$chat
.login({ userID, userSig })
.then(function (imResponse) {
// ...
uni._isWeChatIMLogin = true;
})
.catch(function (imError) {
uni._isWeChatIMLogin = false;
});
};
创建群
创建群前端和后端都可以通过调 IM SDK 的方式来调用,目前一个群(内部场景)需要有上级、下级、客服、运营四个人,客服和运营需要在后台进行操作,所以创建群通过接口都形式进行创建
聊天室
聊天室是整个 IM 应该最复杂的部分。分为几块来讲,进入聊天室需要做的准备,消息类型、退出聊天室要做的处理。
进入聊天室
1、判断用户 IM SDK 是否登录成功,
-
成功:获取消息列表 getMessageList
-
失败:IM SDK 重新登录,再获取消息列表 getMessageList
2、录音初始化前判断是否需要授权,初始化录音
3、监听消息,监听到消息后,将消息 push 到 messageList 里面(这里接收到消息是所有的,所以接受到新的消息,需要做个处理,是当前群组的消息才 push 到消息列表里面)
消息展示
消息展示有几个需要注意的点:
- 消息滚动以及每次进入页面、发消息都需要滚动到最底部
- 消息滚动到最底部
- 下拉加载
- ios 移动端输入的时候顶部提示会被顶走
整个聊天室布局是三栏布局:上(提示消息展示)、中(消息列表)、下(输入操作相关)。布局使用 flex ,中间部分自动占据多余的空间(flex: 1)。
Tips: 中间滚动使用的 scroll-view 需要设置 height 才能滚动,在中间高度是自适应的情况下,scroll-view 需要包一层 wrapper,wrapper 的 height 设置为 1。
消息滚动、消息滚动到底部
消息滚动当初设计的时候在 mescroll-uni 和 scroll-view 之间做选择,mesrcoll-uni 性能更好,但是下拉加载历史消息的是和腾讯云 IM 请求的 api 不兼容。下拉加载消息实现很复杂;所以选用性能相对一般的 scroll-view。
| 对比项 | scroll-view | mescroll-uni |
|---|---|---|
| 滚动性能 | 一般 | 好 |
| 是否支持消息滚动到最底部 | 支持 | 支持 |
| 下拉消息加载 | 支持(实现简单) | 支持(和腾讯云搭配实现很复杂) |
个人还是比较倾向于使用 mescroll-uni,所以在实现消息下拉的时候花费了大量的时间,最终发现现有的 api 还是无法满足需求,退而求其次只能选用 scroll-view。scroll-view 下拉加载消息时,因为消息是从 0 - x, 从上到下排列展示的,所以历史消息进来当前消息的序号发生了变化,会使得页面闪一下,无法定位到原来消息的位置。 网上查了资料,使用反向滚动列表的方式可以解决。
// 讲整个列表进行 108 度反转
.scroll-wrapper {
height: 100%;
transform: rotateX(180deg);
// 消息是反向展示的,所以消息未占全首屏的时候位置会在下面,这个时候需要在列表外面再包一层
// 使用 justify-content: flex-end 来展示
.message-main {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
}
// 再将每个消息进行反转
.message-item {
transform: rotateX(180deg);
}
此时每个消息都是反转的,这个时候从源头上把消息做个 reverse 就是和原来一样的啦。每次发送消息的时候需要将 push 改为 unshift。每次获取消息都会把消息做个暂存放到 currentMsgList 里面,每次下拉获取历史消息的时候通过将拉去的历史消息和当前的 currentMsgList ,然后做个反转赋值给 messageList 就是展示的消息。这里在新消息来的时候也需要更新 currentMsgList ,不然在拉取历史消息的时候新消息会被遗漏。
消息滚动到底部:
因为消息是反向展示的,所以滚动到最底部实际就是滚动到最顶部。将 scrollTop 置为 0 就行。直接改为 0 可能存在不生效的情况,所以用 currentTop 给一个差值。这样每次都能生效
<scroll-view
v-if="messageList.length > 0"
id="scroll-message"
class="scroll-wrapper"
scroll-with-animation
scroll-y
:adjust-position="false"
:scroll-top="scrollTop"
upper-threshold="50"
@scroll="handleScroll"
@scrolltolower="getHistoryMessageList" // 反向滚动,下拉加载其实就是上拉加载
>
// ......
</scroll-view>
scrollToBottom(height) {
// 文本消息高度:60, 最大消息高度 280
this.scrollTop = this.currentTop;
this.$nextTick(() => {
this.scrollTop = 0;
this.currentTop = 0;
});
}
IOS 移动端输入的时候顶部提示会被顶走
每次键盘唤起的时候手动计算顶部消息的高度,将 messageList 的高度减去顶部的高度就可以了
initKeyBoard() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query
.select(".footer")
.boundingClientRect((data) => {
this.footerHeight = data.height;
})
.exec();
});
}
//获取焦点事件
onFocus(event) {
uni.onKeyboardHeightChange((res) => {
this.height = res.height - 26;
this.$refs.messageRef.scrollToBottom(this.height + this.footerHeight);
});
}
//失去焦点事件
onBlur() {
this.height = 0;
}
uni.onKeyboardHeightChange((res) => {
this.height = res.height - 26;
this.$refs.messageRef.scrollToBottom(this.height + this.footerHeight);
})
消息发送
消息有很多种类型:文字、语音、视频、图片、自定义消息。不同消息发送腾讯云有对应的 api 。每次发送消息前会创建一个消息实例,将消息实例加到当前的消息队列,在本地消息发送并展示的时候会友好很多,例如消息发送失败、或者消息的 loading 状态都可以在这一步做处理。
文字消息
handleText(msg) {
let message = uni._$chat.createTextMessage({
to: this.groupID,
conversationType: TencentCloudChat.TYPES.CONV_GROUP,
payload: {
text: msg,
},
});
this.getMessageAndSend(message);
}
语音消息
语音消息发送的 api 和其它消息是类似的。但是生成对应的语音消息需要做一些准备,需要授权、初始化实例子等,示例代码如下:
recordingInit() {
// 录音初始化前判断是否需要授权
authorizeRecord();
// 获取全局唯一的录音管理器 RecorderManager
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStart(() => {
this.showAudioModal = true;
this.startTimer();
});
this.recorderManager.onStop((res) => {
console.log("结束录音", res);
this.showAudioModal = false;
this.stopTimer();
if (res.duration < 1000) {
this.showAudioModal = true;
this.audioStatus = "short";
const timer = setTimeout(() => {
this.showAudioModal = false;
clearTimeout(timer);
}, 1000);
return;
}
if (this.audioStatus === "cancel") {
this.audioStatus = "";
return;
}
this.handleVoice(res);
// 可以在这里保存录音文件的信息
console.log("录音文件信息:", res);
});
// 处理错误
this.recorderManager.onError((res) => {
console.error("录音失败:", res);
});
},
startRecord() {
// 开始录音
this.recorderManager.start(recordParams);
this.audioStatus = "record";
},
cancelRecord() {
this.audioStatus = "cancel";
this.recorderManager.stop();
},
stopRecord() {
// 停止录音
this.recorderManager.stop();
},
startTimer() {
// 初始化录音时间
this.recordTime = 0;
// 开始计时
this.recordTimer = setInterval(() => {
this.recordTime++;
}, 1000);
},
stopTimer() {
// 停止计时器
clearInterval(this.recordTimer);
this.recordTime = 0;
},
发送语音消息
handleVoice(voiceFile) {
let message = uni._$chat.createAudioMessage({
to: this.groupID,
cloudCustomData: "unread",
conversationType: TIM_TYPES.CONV_GROUP,
payload: { file: voiceFile },
});
console.log("voice", message);
this.getMessageAndSend(message);
}
语音消息在聊天室展示的时候也有一些需要处理的逻辑,首先将每个音频消息通过唯一的 ID 绑定 ref,这样就可以通过 ref 操作不同的消息实例
- 未读消息连续播放;
- 当前消息是未读状态,点击播放的时候会收集当前消息之后的未读语音消息将消息放到一个数组里面,当前消息播放完成之后,在语音消息 end 事件调用 onNextVoiceMessagePlay 方法,把数据里面第一个消息拿出来播放,这个时候数组数量减一,当数组为空则停止。
// 收集未读的语音消息
onVoiceMessageClick(message) {
const reverseMessage = this.messageList.slice().reverse();
const index = reverseMessage.findIndex((item) => item.ID === message.ID);
this.audioPlayQueue = reverseMessage
.slice(index + 1)
.filter((msg) => msg.type === this.msgTypes.MSG_AUDIO && msg.cloudCustomData === "unread" && msg.flow === "in");
}
// 播放下一条消息
onNextVoiceMessagePlay() {
// 如果播放队列中还有未读的语音消息,自动开始播放队列中的下一个语音消息
if (this.audioPlayQueue.length > 0) {
const nextMessage = this.audioPlayQueue.shift();
// 开始播放下一个语音消息
this.$refs[`audio-${nextMessage.ID}`][0].togglePlay();
}
}
- 多个语音消息不能同时播放处理;当前消息正在播放的时候点击播放其它消息,需要将正在播放的消息停止;遍历消息列表找到除了当前消息外,正在播放的消息,将其停止。
pauseRestAudio(message) {
this.messageList
.filter((msg) => msg.type === this.msgTypes.MSG_AUDIO && msg.ID !== message.ID)
.forEach((msg) => {
const curRef = this.$refs[`audio-${msg.ID}`][0];
const isPlay = curRef.playing;
console.log("curRef", curRef);
if (isPlay) {
curRef.audio?.stop();
}
});
}
视频图片、文件消息(目前是 暂时只支持 PDF)
视频、图片和文件消息处理情况类似,视频、图片、文件消息腾讯不支持一次发送多个,所以在做的时候,会将发送的消息存到一个数组里面,循环遍历当前数组,然后调用对应的 api 一个一个的发送。
handleImage(type) {
// album: 从相册选择
// camera: 使用相机拍照
const sourceType = type === "image" ? "album" : "camera";
const _this = this;
uni.chooseMedia({ // 文件这步处理逻辑有点区别,使用 chooseMessageFile
// count: 1, // 只选一张,目前 SDK 不支持一次发送多张图片
mediaType: ["image"], // 图片
sizeType: ["original", "compressed"], // 可以指定是原图还是压缩图,默认二者都有
sourceType: [sourceType],
success: function (res) {
console.log("image res", res);
const { type, tempFiles } = res;
if (tempFiles.length > 1) {
tempFiles.forEach((file) => {
const timer = setTimeout(() => {
_this.createSingleFileMessage("image", {
type,
tempFiles: [file],
});
clearTimeout(timer);
}, 10);
});
} else {
_this.createSingleFileMessage("image", res);
}
},
});
}
createSingleFileMessage(type, res) {
const createFile = {
file: uni._$chat.createFileMessage,
image: uni._$chat.createImageMessage,
video: uni._$chat.createVideoMessage,
};
let message = createFile[type]({
to: this.groupID,
conversationType: TIM_TYPES.CONV_GROUP,
payload: { file: res },
onProgress: function (event) {
console.log("file uploading:", event);
},
});
// 2. 发送消息
this.getMessageAndSend(message);
},
自定义消息
自定义消息,在通话结束后发送通话时长的时候用到(当然还有其它的场景,只不过其它的场景目前是由接口完成的)
createCustomMessage(customData, extension) {
const message = uni._$chat.createCustomMessage({
to: this.groupID,
conversationType: TencentCloudChat.TYPES.CONV_GROUP,
payload: {
data: customData, // 这里加上需要发送的信息,展示的时候进行解析
description: "自定义消息",
extension,
},
});
this.getMessageAndSend(message);
}
消息发送统一处理逻辑
getMessageAndSend(message) {
// 真正发送前将消息加到当前消息列表
this.messages.unshift(message);
// 发送消息,将消息滚动到底部
this.$nextTick(() => {
this.$refs.messageRef.scrollToBottom();
});
uni._$chat
.sendMessage(message)
.then((res) => {
if (res.code === 0) {
console.log("发送成功", JSON.stringify(res.data.message));
this.updateMessage(res.data.message);
}
})
.catch((imError) => {
// 发送失败
console.warn("发送失败 error:", imError);
});
}
语音、视频通话
语音、视频通话使用的是腾讯的 TRTC SDK 来实现的,也是完全无 UI 的方式。这里开始比较难实现的是呼叫和接听。看了腾讯云文档,官方推荐用信令的方式来实现。相关示意图如下:
// 首先需要安装 trtc-wx-sdk,因为考虑到包体积的原因所以这里采用的是引入静态文件的方式
import TRTC from './static/trtc-wx'; // 静态文件引入
import TRTC from 'trtc-wx-sdk'; // 小程序构建npm引入
通话基本流程如下:
- 邀请者根据业务层生成的 roomID (roomID 必须是整数,使用字符串需要用另外的字段 roomIDStr,不然进入诊室会有问题)进入该 TRTC 房间,同时调用信令邀请接口 invite 发起音视频通话请求,并把 roomID 放到邀请接口的自定义字段中。
- 被邀请者邀请通过 addSignalingListener 监听收到TencentCloudChat.TSignaling.NEW_INVITATION_RECEIVED 邀请信令,并通过自定义数据拿到 roomID,界面开始响铃。
- 被邀请者处理邀请通知:
- 接听并当双方的音视频通道建立完成后,通话的双方都会接收到 TRTC SDK 的 onUserVideoAvailable 的事件通知,表示对方的视频画面已经拿到。此时双方用户均可以调用 TRTC SDK 接口 startRemoteView 展示远端的视频画面。远端的声音默认是自动播放的。
- 通话结束即某一方挂断电话,该用户退出 TRTC 房间。对方收到 TRTC SDK 的 onRemoteUserLeaveRoom 回调后计算通话总时长并再次发起一次邀请,此邀请的自定义数据中标明是结束通话并附带通话时长,方便 UI 界面做展示。
进入语音、视频房间
上级用户发起语音或者视频通话,下级用户接受邀请。过程如下:
上级先进入房间等待,进入房间前会做一些准备
this.TRTC = new TRTC();
this.pusher = this.TRTC.createPusher();
this.bindTRTCRoomEvent() // 注册事件、监听的用户行为、根据不同的行为作出不同的反应
this.enterRoom() // 进入房间会根据不用等场景开启麦克风和摄像头,然后开始推流
下级接受邀请,有两种情况第一种是直接接听这个时候准备工作和上级是一样的,示例代码同上。第二种情况,下级首先来到接听页面,这是时候只是一个 standby 并没有进入房间,点击“接听”按钮才会进入房间。
// 第二种情况
// 来到接听页面,初始化只会执行这三个步骤
this.TRTC = new TRTC();
this.pusher = this.TRTC.createPusher();
this.bindTRTCRoomEvent() // 注册事件、监听的用户行为、根据不同的行为作出不同的反应
// 点击“接听”按钮则会执行进入房间的方法
this.enterRoom()
通过初始化绑定的事件可以进行很多操作(例如判断电话是否接通、挂断;窗口切换等)
bindTRTCRoomEvent() {
const TRTC_EVENT = this.TRTC.EVENT;
// 初始化事件订阅
this.TRTC.on(TRTC_EVENT.LOCAL_JOIN, (event) => {
console.log("* room LOCAL_JOIN", event);
});
this.TRTC.on(TRTC_EVENT.LOCAL_LEAVE, (event) => {
console.log("* room LOCAL_LEAVE", event);
});
this.TRTC.on(TRTC_EVENT.ERROR, (event) => {
console.log("* room ERROR", event);
});
// 视频通话远程用户进入房间
this.TRTC.on(TRTC_EVENT.REMOTE_USER_JOIN, (event) => {
console.log("* room REMOTE_USER_JOIN", event);
});
// 远端用户退出
this.TRTC.on(TRTC_EVENT.REMOTE_USER_LEAVE, (event) => {
console.log("* room REMOTE_USER_LEAVE", event);
});
// 远端用户推送视频
this.TRTC.on(TRTC_EVENT.REMOTE_VIDEO_ADD, (event) => {
console.log("* room REMOTE_VIDEO_ADD", event);
const { player } = event.data;
// 开始播放远端的视频流,默认是不播放的
this.setPlayerAttributesHandler(player, {
muteVideo: false,
});
});
// 远端用户取消推送视频
this.TRTC.on(TRTC_EVENT.REMOTE_VIDEO_REMOVE, (event) => {
console.log("* room REMOTE_VIDEO_REMOVE", event);
const { player } = event.data;
this.setPlayerAttributesHandler(player, {
muteVideo: true,
});
});
// 远端用户推送音频
this.TRTC.on(TRTC_EVENT.REMOTE_AUDIO_ADD, (event) => {
console.log("* room REMOTE_AUDIO_ADD", event);
const { player } = event.data;
this.setPlayerAttributesHandler(player, {
muteAudio: false,
});
});
// 远端用户取消推送音频
this.TRTC.on(TRTC_EVENT.REMOTE_AUDIO_REMOVE, (event) => {
console.log("* room REMOTE_AUDIO_REMOVE", event);
const { player } = event.data;
this.setPlayerAttributesHandler(player, {
muteAudio: true,
});
});
this.TRTC.on(TRTC_EVENT.REMOTE_AUDIO_VOLUME_UPDATE, (event) => {
console.log("* room REMOTE_AUDIO_VOLUME_UPDATE", event);
const { playerList } = event.data;
this.playerList = playerList;
});
this.TRTC.on(TRTC_EVENT.LOCAL_AUDIO_VOLUME_UPDATE, (event) => {
const { pusher } = event.data;
this.pusher = pusher;
});
}
铃声
拨打和接听都需要提示的铃声让用户知道。通过全局定义 callAudioContext ,初始化后将该实例暴露,拨打或者接听的时候调用到实例的 play 方法。接通前当用户主动挂断、对方挂断。或者对方接听的时候都需要停止声音的播放,及调用 callAudioContext 实例的 stop 方法。
// 全局定义 callAudioContent 实例
globalData: {
// 。。。
callAudioContext: null,
}
// 创建内部音频上下文
getApp().globalData.callAudioContext = wx.createInnerAudioContext();
// 设置音频的源
getApp().globalData.callAudioContext.src = "https://files.yunqueyi.com/audio/mpeg/common/20240428102544506.mp3";
// 播放音频
getApp().globalData.callAudioContext.play();
// 停止音频播放
getApp().globalData.callAudioContext.stop();
语音、视频大小窗的切换
以视频为例,视频通话有小大窗的区别,接通前大窗的视频显示的是自己,接通后需要将摄像头进行一个切换,大窗显示对方,小窗显示自己(当前示意图是反的),点击小窗的时候需要将展示的镜头进行切换。
根据 css 属性 :class="{ large: isLocalLarge, small: !isLocalLarge } 来切换窗户的大小
// 切换窗口的方法
toggleSizeAndPosition(type) {
if (this.callType !== "video") {
return;
}
// 本地推流小窗情况
if (type === "pusher" && !this.isLocalLarge) {
this.isLocalLarge = !this.isLocalLarge;
}
// 远程推流小窗情况
if (type === "player" && this.isLocalLarge) {
this.isLocalLarge = !this.isLocalLarge;
}
},
接通后,即监听到 TRTC_EVENT.REMOTE_USER_JOIN 事件,则主动调 toggleSizeAndPosition 切换当前窗口为远程用户。
目前视频聊天小窗下,视频流也要推送。本来想利用小程序视频相关标签 live-pusher 和 live-player 的 picture-in-picture-mode 属性,这个是最好解决方案,简单效果又好。但是它需要路由 push 或者 pop 才会出现,而 IOS 环境下,离开当前的页面,当前页面会被卸载掉,视频流也就被强制中断了。无奈只能想其它的方案。
将整个接听、播打的页面写成一个全屏的,完全遮住当前页面的弹窗,接听的时候展示,最小化的时候讲全屏的位置和大小信息按照设计图进行一个修改,这样就可以实现小窗流的模式。不过这个模式也有一个缺点就是不能离开当前页面,离开当前页通话就会被挂断。
全局接听卡片的实现
卡片样式实现是比较简单的,这个就不多讲了。比较复杂的是如何在点击切换的时候每个页面都展示这个卡片。思路如下:
- 定义好全局接听卡片 call-pop
- 在 vue.config.js 文件里面进行一个全局的注册
module.exports = {
configureWebpack: {
// 。。。
},
chainWebpa
ck: (config) => {
config.module
.rule("vue")
.use("vue-loader")
.loader("vue-loader")
.tap((options) => {
const compile = options.compiler.compile;
options.compiler.compile = (template, ...args) => {
const t = menuCodeDtoList.filter((i) => {
return args[0].resourcePath.startsWith(i.fullPath);
})[0];
if (t) {
template = template.replace(
/[\s\S]+?<[\d\D]+?>/,
(_) => `${_}
// 全局注册 call-pop 卡片
<call-pop ref="call-pop"/>
`
);
}
return compile(template, ...args);
};
return options;
});
},
};
- 定义全局属性 callPopVisible 来控制弹窗 call-pop 的展示和隐藏。每次接收到信令邀请将 callPopVisible 设置成 true
- 每次进入一个新的页面都会根据 callPopVisible 来判断弹窗的展示和隐藏(因为首页的 menu 下的四个弹窗有缓存,它们不是每次都重新加载所以需要单独处理这几个页面)
// 全局定义
globalData: {
callPopVisible: false,
}
// 首页 menu 单独处理弹窗
export const hideOrHideCallPop = () => {
const visible = getApp().globalData.callPopVisible;
const currentPage = getCurrentPage();
const hasRoute = currentPage && currentPage.route;
if (hasRoute) {
visible ? currentPage.$vm.$refs["call-pop"].open() : currentPage.$vm.$refs["call-pop"].close();
}
};
- 接听或者挂断的时候将 callPopVisible 设置成 false
总结
整个 IM 相关流程基本是就是上面所写的,流程细节很多,尽管有腾讯云文档和相关的代码示例,最终实现自定义的 IM 相关还是有一定的挑战。细节很多,要做的体验好的话需要花费很大的精力。像消息滚动和视频通话都花费了不少功夫。
参考:
小程序scroll-view反向滑动(反向渲染列表)
juejin.cn/post/710528…
juejin.cn/post/732903…
cloud.tencent.com/document/pr…
cloud.tencent.com/document/pr…