前序文章:
环信 uni-app Demo升级改造计划——Vue2迁移到Vue3(一)
环信即时通讯SDK集成——环信 uni-app-demo 升级改造计划——整体代码重构优化(二)
概述
在将声网 uni-app 音视频插件正式集成进入环信的 uni-app-demo 中,标志着本次升级改造至此基本告一段落。在第三期的升级改造中,主要工作为在 Demo 层形成一个较为容易拆分的有关音视频相关组件,力求第一:代码是否可读、第二:可以对参考源码的同学提供实例、第三:能够方便在脱离其他 IM 功能时,完成对音视频功能的复用。
同时也顺手针对 emChat 组件进行小范围重构,解决了 uni-app 在 App 以及小程序端,软键盘弹起消息列表不滚动以及软键盘遮挡功能栏问题。
下面我将尽可能详细描述一下本次针对音视频功能、以及消息列表重写的心路历程。
功能背景以及目的
有越来越多的用户在 IM 功能实现中不免向类似微信聊天的功能靠齐,除了日常 IM 功能中,也离不开音视频通话功能,因此需要在环信uni-app-demo
中增加实现音视频通话的示例代码,能够对想要实现音视频功能的用户形成可参考的 demo 代码,以及可复用的音视频功能模块组件。
前置准备
确认实现功能范围
接听呼叫(单聊一对一、群组多人音视频通话)且只支持 uni-app 原生端使用。
- 浏览声网音视频 uni-app 端相关文档,熟悉大致流程以及熟悉部分核心 API,跑通示例 Demo。
- 熟悉环信其他端
PCWeb端、安卓、iOS端
callKit 信令交互相关逻辑,确保实现 uni-app 所实现的音视频功能能够与其他端 Demo 进行互通。 - 了解
nvue
组件相关语法布局样式等与vue
的差异,推拉流视频容器仅支持在nvue
组件中进行使用。
实践见真章
Tip:以下展示代码因篇幅所限,均做了不同程度的删减保留了核心逻辑展示,详细代码文末会给出源码地址。
step1:在项目中集成音视频相关插件
Agora(声网)Demo 示例中有两个插件是必须要进行集成的,分别为Native原生插件
,Js插件
。
Agora-Demo 示例插件下载地址以及功能简介详见下方提供的链接。
具体插件的导入方式就不在本篇中详细介绍,上方插件下载地址中有提到插件导入方式,可以进行参阅。
特别注意:Agora-Uni-App JS 插件导入之后会在目录下生成一个package.json
文件,这个文件会与通过 npm 导入的easemob-websdk
的package.json
重合,因此 Demo 中只保留了easemob-demo
的package.json
step2: 设计搭建 emCallKit(音视频组件)逻辑结构
主体大致结构如下:
其中components/emCallKit
主要为核心 emCallKit 逻辑层代码,callKitManage
文件中主要包含对外发布订阅频道内时间逻辑代码,以及频道内信令发送代码。config
声网 AppId 配置。contants
文件夹音视频频道内常量、stores
频道内核心逻辑在此,利用 pinia 进行频道内状态管理。utils
工具方法,index.js
emCallkit 入口文件,该文件内挂载信令监听初始化频道内 IM Client。
而pages/emCallKitPages
则是频道内各个页面在此构造,alertScreen.vue
单人多人收到邀请弹出该页面,单人呼叫也使用该页面。inviteMembers.vue
多人邀请页面。multiCall.nvue
多人通话中页面。singleCall.nvue
单人通话中页面。
step3:实现单人音视频信令接收以及发送
在思考实现单人音视频拨打之前需要了解其他端已经实现的音视频时序,
以单人音视频呼叫为例:
Alice 为呼叫方 John 为接收方
可以看到与 http 的”握手“过程相似,需要经过几次确认,这样频繁的确认意义在于,能否保证通话状态的准确性,且有效防止在离线的情况下,上线无故触发已经失效的邀请弹窗。而上面的除了邀请的消息为一条普通文本消息,整个过程都是通过环信 IM 的CMD命令消息
实现,且每条消息信令中都有携带一些声网频道信息,比如频道名称,呼叫的类型等都是基于CMD命令消息
实现。
为了能够独立于 IM 功能之外去使用音视频插件,因此在书写时尽可能的与外层 IM Demo 中的逻辑分离开,比如 callKit 中有用到消息监听用来监听消息以及发送 im 消息,因此将实例化后的 websdk(暂称:EMClient
)传入到 emCallKit 中,并利用 websdk 支持多处挂载监听回调的特性,通过拿到传入EMClient.send
进行消息发送,并使用EMClient.addEventHandler
进行监听的挂载,便形成了如下缩减后的代码:
/* 频道信令发送 */
import useSendSignalMsgs from './callKitManage/useSendSignalMsgs';
let CallKitEMClient = null;
let CallKitCreateMsgFun = null;
export const useInitCallKit = () => {
//初始化EMClient之Callkit内
const setCallKitClient = (EMClient, CreateMsgFun) => {
CallKitEMClient = EMClient;
CallKitCreateMsgFun = CreateMsgFun;
mountSignallingListener();
};
//挂载Callkit信令相关监听
const mountSignallingListener = () => {
console.log('>>>>>>>callkit 监听已挂载');
CallKitEMClient.addEventHandler('callkitSignal', {
onTextMessage: (message) => {
const { ext } = message;
if (ext && ext?.action === CALL_ACTIONS_TYPE.INVITE)
handleCallKitInvite(message);
console.log('>>>>>收到文本信令消息', message);
},
onCmdMessage: (msg) => {
console.log('>>>>>收到命令信令消息', msg);
if (msg && msg?.action === CALL_ACTIONS_TYPE.RTC_CALL)
handleCallKitCommand(msg);
},
});
//处理收到为文本的邀请信息
const handleCallKitInvite = (msgBody) => {
console.log('>>>>>开始处理被邀请消息');
const { from, ext } = msgBody || {};
//邀请消息发送者为自己则忽略
if (from === CallKitEMClient.user) return;
};
//处理接收到通话交互过程的CMD命令消息
const handleCallKitCommand = (msgBody) => {
//多端状态下信令消息发送者为自己则忽略
if (msgBody.from === CallKitEMClient.user) return;
};
};
};
return {
CallKitEMClient,
CallKitCreateMsgFun,
setCallKitClient,
};
};
//外层调用初始化callKit频道
import { EMClient, EaseSDK } from './EaseIM';
/* callKit */
import { useInitCallKit } from '@/components/emCallKit';
const { setCallKitClient } = useInitCallKit();
setCallKitClient(EMClient, EaseSDK.message);
至此就可以做到了,在初始化的时候完成针对 callKit 监听的挂载,能够做到在 callKit 中单独接收 im 相关邀请消息以及信令。
下面解决 im 信令发的问题
如上面描述的 callKit 项目结构一致,在callKitManage
文件夹下新建useSendSignalMsgs.js
文件主要处理有关信令发送核心代码,从而解决信令的发送问题。
/* 用来发送所有频道内信令使用 */
import { CALL_ACTIONS_TYPE, MSG_TYPE } from '../contants';
import { useInitCallKit } from '../index.js';
const action = 'rtcCall';
const useSendSignalMsgs = () => {
const { CallKitEMClient, CallKitCreateMsgFun } = useInitCallKit();
//发送通知弹出待接听窗口信令
const sendAlertMsg = (payload) => {
const { from, ext } = payload;
const option = {
type: 'cmd',
chatType: 'singleChat',
to: from,
action: action,
ext: {
action: CALL_ACTIONS_TYPE.ALERT,
calleeDevId: CallKitEMClient.context.jid.clientResource,
callerDevId: ext.callerDevId,
callId: ext.callId,
ts: Date.now(),
msgType: MSG_TYPE,
},
};
console.log('>>>>>>>option', option);
const msg = CallKitCreateMsgFun.create(option);
// 调用 `send` 方法发送该透传消息。
CallKitEMClient.send(msg)
.then((res) => {
// 消息成功发送回调。
console.log('answer Success', res);
})
.catch((e) => {
// 消息发送失败回调。
console.log('anser Fail', e);
});
};
return {
sendAlertMsg,
};
};
export default useSendSignalMsgs;
//发送时调用
import useSendSignalMsgs from '../callKitManage/useSendSignalMsgs';
const { sendAnswerMsg } = useSendSignalMsgs();
const payload = {
targetId: from,
sendBody: ext,
};
sendAnswerMsg(payload, ANSWER_TYPE.BUSY);
到这里,关于 callKit 组件内的有关信令部分的核心代码的设计就此结束。
step4:搭建频道内管理相关代码
频道管理是必须要做的,试想一个小场景,张三正在与李四进行音视频通话,此时王五呼叫过来,如果不做什么状态的管理,收到王五的视频邀请就立马弹出了一个邀请弹窗,但是此时张三却已经在通话中了,那么从代码的角度讲这个已经算是一个较为严重的 Bug 了,因此我们必须要在频道中引入状态管理
这个概念,这个概念的实现即不是环信IM
层面,也不是声网RTC
,而是我们自己需要实现的一个状态,比如空闲、呼叫中、邀请中、通话中等等,我们需要抽象出来一个频道状态从而映射出用户在使用音视频通话功能中不同时期的情况,并且做出不同的逻辑层处理。
在引入状态管理的情况下,再去套用刚才的场景:
张三在收到李四的通话邀请时,张三本身为空闲状态,此时就可以回复给李四状态空闲可以通话,李四收到张三的回复后可以调起通话待接听界面,直到张三接听后双方可进入到频道中,正常进行通话功能的使用,此时王五呼叫张三,引领发出后,张三收到邀请信令,获取当前状态为通话中,则直接根据获取的状态判断直接回复BUSY
忙碌中,从而拒绝了王五的通话邀请。
可以看到引入了频道中的状态管理概念我们解决了音视频通话时避免状态混乱导致的一系列问题,下面可以看下示例代码。
import { defineStore } from 'pinia';
import useSendSignalMsgs from '../callKitManage/useSendSignalMsgs';
import createUid from '../utils/createUid';
const useAgoraChannelStore = defineStore('agoraChannelStore', {
state: () => ({
emClientInfos: {
apiUrl: '',
appKey: '',
loginUserId: '',
clientResource: '',
accessToken: '',
},
callKitStatus: {
localClientStatus: CALLSTATUS.idle, //callkit状态
channelInfos: {
channelName: '', //频道名
agoraChannelToken: '', //频道token
agoraUserId: '', //频道用户id,
callType: CALL_TYPES.SINGLE_VOICE, //0 语音 1 视频 2 多人音视频
callId: null, //会议ID
channelUsers: {}, //频道内用户
callerDevId: '', //主叫方设备ID
calleeDevId: '', //被叫方设备ID
callerIMName: '', //主叫方环信ID
calleeIMName: '', //被叫方环信ID
groupId: '', //群组ID
},
//被邀请对象 单人为string 多人为array
inviteTarget: null,
},
}),
actions: {
/* emClient */
initEmClientInfos(emClient) {
console.log('initEmClientInfos', emClient);
if (!emClient) return;
this.emClientInfos.apiUrl = emClient.apiUrl;
this.emClientInfos.appKey = emClient.appKey;
this.emClientInfos.loginUserId = emClient.user;
this.emClientInfos.accessToken = emClient.token;
this.emClientInfos.clientResource = emClient.clientResource;
},
/* CallKit status 管理 */
//初始化频道信息
initChannelInfos() {
this.callKitStatus.localClientStatus = CALLSTATUS.idle;
this.callKitStatus.channelInfos = {
channelName: '', //频道名
agoraChannelToken: '', //频道token
agoraUid: '', //频道用户id
callType: CALL_TYPES.SINGLE_VOICE, //0 语音 1 视频 2 多人音视频
callId: null, //会议ID
channelUsers: {}, //频道内用户
callerDevId: '', //主叫方设备ID
calleeDevId: '', //被叫方设备ID
confrontId: '', //要处理的目标ID
callerIMName: '', //主叫方环信ID
calleeIMName: '', //被叫方环信ID
groupId: '', //群组ID
};
this.callKitStatus.inviteTarget = null;
this.callKitTimer && clearTimeout(this.callKitTimer);
},
//更新localStatus
updateLocalStatus(typeCode) {
console.log('>>>>>开始变更本地状态为 typeCode', typeCode);
this.callKitStatus.localClientStatus = typeCode;
},
//更新频道信息
updateChannelInfos(msgBody) {
console.log('触发更新频道信息', msgBody);
const { from, to, ext } = msgBody || {};
const params = {
channelName:
ext.channelName || this.callKitStatus.channelInfos.channelName,
callId: ext.callId || this.callKitStatus.channelInfos.callId,
callType:
CALL_TYPE[ext.type] || this.callKitStatus.channelInfos.callType,
callerDevId: ext.callerDevId || 0,
calleeDevId: ext.calleeDevId,
callerIMName: from,
calleeIMName: to,
groupId: ext?.ext?.groupId ? ext.ext.groupId : '',
};
console.log('%c将要更新的信息内容为', 'color:red', params);
Object.assign(this.callKitStatus.channelInfos, params);
},
},
});
export default useAgoraChannelStore;
//频道状态使用以及变更示例代码
import useAgoraChannelStore from './stores/channelManger';
const { updateChannelInfos, updateLocalStatus } = agoraChannelStore;
const callKitStatus = computed(() => agoraChannelStore.callKitStatus);
上面示例代码,是针对频道内的状态管理演示代码,用到了 pinia 去进行状态存储以及管理,pinia 也支持在 nvue 页面中很方便的使用。
step5:关于 callKit 可视页面的处理
关于可视组件的处理是指的是,比如在收到邀请信息时需要弹出待接听页面
,那么我们就需要跳转至待接听页面,多人通话时我们需要邀请更多人加入会议,那么我们则需要弹出邀请页面
,单人以及多人通话中我们则需要跳转至实际需要显示通话双方音视频流的组件页面
,上面提到的几个页面就分别对应了:alertScreen.vue
、inviteMembers.vue
、multiCall.nvue
、singleCall.nvue
。
这些组件由于是页面级别的,因此在需要跳转至对应的页面时,不免需要进行 router 路由映射关系配置,因此我们需要在pages.json
中进行对应的页面地址配置,这里拿其中alertScreen.vue
做代码演示。
pages.json 配置
{
"path": "pages/emCallKitPages/alertScreen",
"style": {
"app-plus": {
"titleNView": false
}
}
}
跳转至待接听页面
import useCallKitEvent from '@/components/emCallKit/callKitManage/useCallKitEvent';
const { EVENT_NAME, CALLKIT_EVENT_CODE, SUB_CHANNEL_EVENT } = useCallKitEvent();
SUB_CHANNEL_EVENT(EVENT_NAME, (params) => {
const { type, ext, callType, eventHxId } = params;
console.log('>>>>>>订阅到callkit事件发布', params);
//弹出待接听事件
switch (type.code) {
case CALLKIT_EVENT_CODE.ALERT_SCREEN:
{
//跳转至待接听页面
uni.navigateTo({
url: '../emCallKitPages/alertScreen',
});
}
break;
default:
break;
}
});
从待接听页面选择接听后的跳转
在待接听页面,点击接听后,应该是怎样的逻辑处理?
const agreeJoinChannel = () => {
handleSendAnswerMsg(ANSWER_TYPE.ACCPET);
if (channelInfos.value.callType === CALL_TYPES.MULTI_VIDEO) {
uni.redirectTo({
url: '/pages/emCallKitPages/multiCall',
});
} else {
enterSingleCallPage();
}
};
const enterSingleCallPage = () => {
uni.redirectTo({
url: '/pages/emCallKitPages/singleCall',
});
};
可以看到上面的演示代码做了两种通话大类(单人、多人)不同的页面跳转。
下面我们看下通话中的视图页面是怎样的(singleCall为例)
,同样代码做了一部分的删减。
{{ callKitStatus.inviteTarget ||
callKitStatus.channelInfos.callerIMName }}
正在语音通话…
{{ formatTime }}
麦克风
扬声器
摄像头
挂断
核心的流展示则是 Agora-UniApp 原生插件提供的RtcSurfaceView
组件通过该组件进行本地流和远端流的展示。
在 nvue 组件中提几个点,可以关注一下。
- 安卓机型,在发布本地流之前需要拿到用户关于录音以及摄像头的授权,否则无法正常的进行推流展示。具体的授权 js 调用插件,关注
wa-permission
这个插件。 - 默认音视频通话会跟随系统息屏时间自动息屏,不希望息屏则可以调用 uni-app 提供的 api
uni.setKeepScreenOn({ keepScreenOn: true, });
- 引入原生插件后必须打包为自定义调试基座才可以看到具体的效果,否则不会展示画面。
到这里可视页面的相关代码以及所需配置介绍暂时告一段落。
下面再看下邀请相关逻辑。
step6:关于 callKit 邀请相关逻辑的介绍。
如果作为邀请方也就是音视频功能的发起方,我们如何使用 callKit 内的代码完成这一动作?
视频通话
语音通话
取消
在实际的 Demo 中增加了一个inviteAvcall.vue
组件在外层点击某个 icon 时展示该 Popup 组件,弹出视频邀请或音频邀请的选项。
效果如下:
点击时传入对应的类型邀请信令发送给要邀请的目标一条文本邀请信息。
而多人音视频模式下,邀请下则不需要弹出待接听页面,而是进入勾选要发送邀请信息的成员页面,发送邀请并创建频道并加入即可,就像这样。
const inviteAvcallComp = ref(null);
const selectAvcallType = () => {
closeAllModal();
if (injectChatType.value === 'groupChat') {
uni.navigateTo({
url: `/pages/emCallKitPages/inviteMembers?groupId=${injectTargetId.value}`,
});
} else {
inviteAvcallComp.value && inviteAvcallComp.value.openInvitePopup();
}
};