从点对点视频通话到小型一对多直播,逐步加深了
webrtc
的了解和使用。现在再进一步,实现一个简单的多房间多用户的简单架构会议系统。
无论是点对点音视频还是直播,都是通过 P2P 的方式形成关联关系,而接下来的多对多会议也是一样,无非就是每个客户端都去和会议室中的每个用户建立关联,从而拿到对方的媒体信息。
过程分析
假设有A、B、C、D四个人需要参会,但是WebRTC
仅支持 P2P
,那么 A 如果要和剩下的三个人视频通话,就必须和他们三个人都建立关联,也就是形成A-B
,A-C
,A-D
的关联关系。
看上图所示,只有建立关联关系之后,A 才能将自己的视频流发给 B、C、D 三个人,同时也才能收到其余三个人的视频画面。但是注意看,A 和其余三个人形成视频通话,但是其余三人之间并没有相互关联。
也就是说,现在这个方案只是达成了 1 对多 的场景(前面所说的直播场景)。那么如何让其余三个人之间也形成互通呢?
同理,看上图的 B 和 A 一样重新建立关联关系,只不过变成 B 为主体,剩下的A、C、D为被关联对象进而形成B-A
,B-C
,B-D
的关联关系。但是这时你会发现,A-B 和 B-A 不是重复了吗? 这里就需要额外处理了,后面会提出解决方案。
重复A关联其他三个用户的过程,B,C,D都分别和其他用户建立起关联关系了。可以理解为就是多个一对多直播模式的关联过程,只是其中代码有些变化而已。
从WebRTC
的核心载体PeerConnection
上来讲,上述的关联关系代表的就是这个核心载体,一对关联关系代表一个核心对象,A-B
这一关系从代码层面上来表示,就是创建一个PeerConnection
对象,这个对象中维护的是 A 和 B 之间的sdp信令
和媒体信息
用图来表示就如下所示:
首先 A 客户端需要和其余三个客户端通信,按照上述关系和WebRTC
的会话流程,我们就需要创建三个PeerConnection
对象来作为会话信令的载体,然后客户端还需要储存下这三个变量。这里为了操作方便,我们将其放置在Map
数据结构中,key
按照用户的 ID 组合成变量名,那么 A 和 B、C、D 关联关系储存方式如下:
js
// A:10001 B:10002 C:10003 D:10004 分别代表四个客户端的用户ID
const RtcPcMaps = new Map()
const ABKeys = 10001-10002
const ACKeys = 10001-10003
const ADKeys = 10001-10004
RtcPcMaps.set(ABKeys , new PeerConnection()) //维护A-B关系
RtcPcMaps.set(ACKeys , new PeerConnection()) //维护A-C关系
RtcPcMaps.set(ADKeys , new PeerConnection()) //维护A-D关系
同理,B,C,D也应该是按照上述流程进行的。按照这个流程,就会出现上面所说的关联关系重复的问题。现在来思考下如何解决。
还是先再看一遍AB的关联过程,注意其中的一些小细节点:
- 首先 A 呼叫 B,呼叫之前我们一般通过实时通信协议
WebSocket
即可,让对方能收到信息。 - B 接受应答,A 和 B 均开始初始化
PeerConnection
实例,用来关联 A 和 B 的SDP
会话信息。 - A 调用
createOffer
创建信令,同时通过setLocalDescription
方法在本地实例PeerConnection
中储存一份(图中流程①)。 - 然后调用信令服务器将 A 的
SDP
转发给 B(图中流程②)。 - B 接收到 A 的
SDP
后调用setRemoteDescription
,将其储存在初始化好的PeerConnection
实例中(图中流程③)。 - B 同时调用
createAnswer
创建应答SDP
,并调用setLocalDescription
储存在自己本地PeerConnection
实例中(图中流程④)。 - B 继续将自己创建的应答
SDP
通过服务器转发给 A(图中流程⑤)。 - A 调用
setRemoteDescription
将 B 的SDP
储存在本地PeerConnection
实例(图中流程⑤)。 - 在会话的同时,从图中我们可以发现有个
ice candidate
,这个信息就是 ice 候选信息,A 发给 B 的 B 储存,B 发给 A 的 A 储存,直至候选完成。
注意看,首先A设置本地offer
,然后再将offer
发送给B。B接收到offer
之后将其设置为远程描述后,再创建answer
发送给A
。这里B
并没有先设置本地offer
,而是直接将接收到的offer
设置为远程描述 。也就是说,在AB关联过程中,有一方其实是不需要设置本地描述的(花了我一天才想明白!!!)。
可以思考如何利用这点,将A关联B和B关联A这两个PeerConnection
对象去掉一个。比如A已经发送offer
了,B就不需要再主动关联A了。如何才能判断A是否发送offer
了呢?
- 假设房间内只有A时,是不会进行任何操作的。
- 当B进来后,将B标记为新人。即A和B拿到房间内成员信息时,B对象的
isNew
为true
,而A对象的isNew
为false
。 - 对于A来说,B是新人,可以直接进行关联。此时A首先创建
offer
,设置为本地描述,然后发送offer
给B。对于B来说,他是新人,不需要进行任何操作,等着被关联就行。所以B不需要初始化,直接接收A的offer
,然后设置为远程描述,再创建answer
发送给A即可。到此,AB关联结束,只有A一方创建了PeerConnection
对象,就是页面中只有一个PeerConnection
对象。 - 此时再进来C,将C标记为新人。即ABC拿到房间内成员信息时,AB对象的
isNew
为false
,C对象的isNew
为true
。 - 对于AB来说,C是新人,可以直接进行关联。而对于C来说,他是新人,不需要进行任何操作,等着被关联就行。所以,AB分别创建一个
PeerConnection
对象。此时页面中有两个PeerConnection
对象,而房间内有ABC三个人。 - 再进来一个新人,过程同上。
将过程简化就是,房间内的成员依次关联新进来的成员,新进来的成员不需要任何操作,乖乖等着被关联就行。这样就很好的解决了重复关联的问题。如下图所示:
代码实现
根据上述过程描述,用以下代码进行表示:
- A进入会议:获取本地媒体流并将其展示在已初始化好的
DOM 节点
;初始化信令服务器连接,然后初始化房间,找出房间内的新人,发现新人是自己,不进行任何操作
linkSocket.value.on("roomUserList",
roomUserList.value = data.filter((e) => e.userId !== String(userId));
console.log("房间成员列表", data);
// 拿到房间成员列表后 初始化pc
newUser.value = data.find((e) => e.isNew) ?? {}; // 找到新用户
// 如果新用户是自己 则啥也不用干 等着被连接就行
// 不是自己 则要去连接新用户的pc
if (newUser.value?.userId !== userId) {
initMeetingRoomPc();
}
});
- B进入会议:获取本地媒体流并将其展示在已初始化好的
DOM 节点
;初始化信令服务器连接,然后初始化房间, 找出房间内的新人,发现新人是自己,不进行任何操作。但A发现了B为新人,则进行关联:
js
const initMeetingRoomPc = async () => {
if (!newUser.value) return;
const localUserId = props.userId;
const NUser = newUser.value;
const pcKey = localUserId + "-" + NUser?.userId;
let pc = RtcPcMaps.get(pcKey);
if (!pc) {
pc = new PeerConnection();
RtcPcMaps.set(pcKey, pc);
}
console.log(RtcPcMaps, "RtcPcMaps-----------------");
for (const track of localStream.value.getTracks()) {
const sender = pc.getSenders().find((s) => s.track === track);
if (sender) {
// 如果发送器已经存在,则更新发送器的轨道
sender.replaceTrack(track);
} else {
// 否则,添加新的轨道
pc.addTrack(track);
}
}
onPcEvent(pc, localUserId, NUser?.userId);
// 创建offer
const offer = await pc.createOffer();
// 设置本地描述
await pc.setLocalDescription(offer);
// 发送offer给被呼叫端
linkSocket.value.emit("offer", {
offer,
targetUserId: NUser?.userId,
userId: localUserId,
});
};
- B接收到
offer
,进行被动关联:
js
// 处理远端offer
const onRemoteOffer = async (fromUserId, offer) => {
const localUserId = props.userId;
const pcKey = fromUserId + "-" + localUserId;
let pc = new PeerConnection();
RtcPcMaps.set(pcKey, pc);
onPcEvent(pc, localUserId, fromUserId);
for (const track of localStream.value.getTracks()) {
const sender = pc.getSenders().find((s) => s.track === track);
if (sender) {
// 如果发送器已经存在,则更新发送器的轨道
sender.replaceTrack(track);
} else {
// 否则,添加新的轨道
pc.addTrack(track);
}
}
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
linkSocket.value.emit("answer", {
userId: localUserId,
targetUserId: fromUserId,
answer,
});
};
- C进入会议:获取本地媒体流并将其展示在已初始化好的
DOM 节点
;初始化信令服务器连接,然后初始化房间, 找出房间内的新人,发现新人是自己,不进行任何操作。但AB发现了C为新人,则进行关联,过程同上。 - 至此,房间内每个人都和其他人建立了关联关系,可以互相视频了。
项目操作
项目地址:
打开下面这个,
下一节写有关通话过程的相关功能