前端基于WebRTC实现音视频通话
背景
随着互联网技术的飞速发展,实时音视频通话已经成为在线教育、远程办公、社交媒体等领域的核心且常用的功能。WebRTC(Web Real-Time Communication)作为一项开放的实时通信标准,为开发者提供了快速构建实时音视频通话系统的能力。
应用场景
- 点对点视频聊天:如 微信视频 等实时视频通话应用。
- 多人视频会议:企业级多人视频会议系统,如飞书、钉钉、腾讯会议等。
- 在线教育:如腾讯课堂、网易云课堂等。
- 直播:游戏直播、课程直播等。
P2P通信原理
P2P 通信即点对点通信。
P2P连接需要进行内网穿透是因为在现代网络环境中,许多设备位于私有网络(如家庭网络)后面,这些设备使用了NAT(Network Address Translation)来共享公共IP地址。NAT会阻止直接从外部访问这些设备,因此需要通过内网穿透技术来克服这一障碍,实现设备之间的直接通信。大多数客户端设备(如智能手机、电脑、智能家居设备等)都连接到了一个局域网(LAN)中,而这个局域网通常由路由器或交换机提供网络连接。在这种局域网环境中,每个设备都被分配了一个私有IP地址,这些私有IP地址只在局域网内部有效,无法直接从公共互联网访问到。
要实现两个客户端的实时音视频通信,并且这两个客户端可能处于不同网络环境,使用不同的设备,都需要解决哪些问题?
主要是下面这 3 个问题:
- 如何发现对方?
- 不同的音视频编解码能力如何沟通?
- 如何联系上对方?
下面我们将逐个讨论这 3 个问题。
如何发现对方?
在 P2P 通信的过程中,双方需要交换一些元数据比如媒体信息、网络数据等等信息,我们通常称这一过程叫做“信令(signaling)”。
对应的服务器即“信令服务器 (signaling server)”,通常也有人将之称为“房间服务器”,因为它不仅可以交换彼此的媒体信息和网络信息,同样也可以管理房间信息。
比如:
1)通知彼此 who 加入了房间;2)who 离开了房间 3)告诉第三方房间人数是否已满是否可以加入房间。
为了避免出现冗余,并最大限度地提高与已有技术的兼容性,WebRTC 标准并没有规定信令方法和协议。在本课程中会使用websocket来搭建一个信令服务器
不同的音视频编解码能力如何沟通?
不同浏览器对于音视频的编解码能力是不同的。
比如: 以日常生活中的例子来讲,小李会讲汉语和英语,而小王会讲汉语和法语。为了保证双方都可以正确的理解对方的意思,最简单的办法即取他们都会的语言,也就是汉语来沟通。
在 WebRTC 中:有一个专门的协议,称为 Session Description Protocol(SDP),可以用于描述上述这类信息。
因此:参与音视频通讯的双方想要了解对方支持的媒体格式,必须要交换 SDP 信息。而交换 SDP 的过程,通常称之为媒体协商。
如何联系上对方?
其实就是网络协商的过程,即参与音视频实时通信的双方要了解彼此的网络情况,这样才有可能找到一条相互通讯的链路。
正常的设备都是局域网内的私有IP设备,不知道双方的互联网IP地址就无法建立连接。
理想的网络情况是每个客户端都有自己的私有公网 IP 地址,这样的话就可以直接进行点对点连接。实际上呢,出于网络安全和其他原因的考虑,大多数客户端之间都是在某个局域网内,需要网络地址转换(NAT)。
在 WebRTC 中我们使用 ICE 机制建立网络连接。ICE 协议通过一系列的技术(如 STUN、TURN 服务器)帮助通信双方发现和协商可用的公共网络地址,从而实现 NAT 穿越。
ICE 的工作原理如下:
- 首先,通信双方收集本地网络地址(包括私有地址和公共地址)以及通过 STUN 和 TURN 服务器获取的候选地址。
- 接下来,双方通过信令服务器交换这些候选地址。
- 通信双方使用这些候选地址进行连接测试,确定最佳的可用地址。
- 一旦找到可用的地址,通信双方就可以开始实时音视频通话。

在 WebRTC 中网络信息通常用candidate来描述
针对上面三个问题的总结:就是通过 WebRTC 提供的 API 获取各端的媒体信息 SDP 以及 网络信息 candidate ,并通过信令服务器交换,进而建立了两端的连接通道完成实时视频语音通话。
常用的API
音视频采集getUserMedia
// 获取本地音视频流const getLocalStream = async () => { const stream = await navigator.mediaDevices.getUserMedia({ // 获取音视频流 audio: true, video: true }) localVideo.value!.srcObject = stream localVideo.value!.play() return stream} 核心对象 RTCPeerConnection
RTCPeerConnection 作为创建点对点连接的 API,是我们实现音视频实时通信的关键。
const peer = new RTCPeerConnection({ // iceServers: [ // { url: "stun:stun.l.google.com:19302" }, // 谷歌的公共服务 // { // urls: "turn:***", // credential: "***", // username: "***", // }, // ],}); 主要会用到以下几个方法:
媒体协商方法:
- createOffer
- createAnswer
- setLocalDesccription
- setRemoteDesccription
重要事件:
-
onicecandidate
-
onaddstream
-

整个媒体协商过程可以简化为三个步骤对应上述四个媒体协商方法:
- 呼叫端创建 Offer(createOffer)并将 offer 消息(内容是呼叫端的 SDP 信息)通过信令服务器传送给接收端,同时调用 setLocalDesccription 将含有本地 SDP 信息的 Offer 保存起来
- 接收端收到对端的 Offer 信息后调用 setRemoteDesccription 方法将含有对端 SDP 信息的 Offer 保存起来,并创建 Answer(createAnswer)并将 Answer 消息(内容是接收端的 SDP 信息)通过信令服务器传送给呼叫端
- 呼叫端收到对端的 Answer 信息后调用 setRemoteDesccription 方法将含有对端 SDP 信息的 Answer 保存起来
经过上述三个步骤,则完成了 P2P 通信过程中的媒体协商部分,实际上在呼叫端以及接收端调用setLocalDesccription 同时也开始了收集各端自己的网络信息(candidate),然后各端通过监听事件 onicecandidate 收集到各自的 candidate 并通过信令服务器传送给对端,进而打通 P2P 通信的网络通道,并通过监听 onaddstream 事件拿到对方的视频流进而完成了整个视频通话过程。
实践
项目搭建
前端项目
- 项目使用
vue3+ts,运行如下命令:
npm create vite@latest webrtc-client -- --template vue-ts - 并且引入
tailwindcss:
npm install -D tailwindcss postcss autoprefixernpx tailwindcss init -p - 在生成的
tailwind.config.js配置文件中添加所有模板文件的路径。
/** @type {import('tailwindcss').Config} */module.exports = { content: [ "./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [],} - 修改
style.css中的内容如下:
@tailwind base;@tailwind components;@tailwind utilities; - 自定义修改
App.vue中的内容如下:
<script lang="ts" setup>import { ref } from 'vue'const called = ref<boolean>(false) // 是否是接收方const caller = ref<boolean>(false) // 是否是发起方const calling = ref<boolean>(false) // 呼叫中const communicating = ref<boolean>(false) // 视频通话中const localVideo = ref<HTMLVideoElement>() // video标签实例,播放本人的视频const remoteVideo = ref<HTMLVideoElement>() // video标签实例,播放对方的视频// 发起方发起视频请求const callRemote = () => { console.log('发起视频');}// 接收方同意视频请求const acceptCall = () => { console.log('同意视频邀请');}// 挂断视频const hangUp = () => { console.log('挂断视频');}</script><template> <div class="flex items-center flex-col text-center p-12 h-screen"> <div class="relative h-full mb-4"> <video ref="localVideo" class="w-96 h-full bg-gray-200 mb-4 object-cover" ></video> <video ref="remoteVideo" class="w-32 h-48 absolute bottom-0 right-0 object-cover" ></video> <div v-if="caller && calling" class="absolute top-2/3 left-36 flex flex-col items-center"> <p class="mb-4 text-white">等待对方接听...</p> <img @click="hangUp" src="/refuse.svg" class="w-16 cursor-pointer" alt=""> </div> <div v-if="called && calling" class="absolute top-2/3 left-32 flex flex-col items-center"> <p class="mb-4 text-white">收到视频邀请...</p> <div class="flex"> <img @click="hangUp" src="/refuse.svg" class="w-16 cursor-pointer mr-4" alt=""> <img @click="acceptCall" src="/accept.svg" class="w-16 cursor-pointer" alt=""> </div> </div> </div> <div class="flex gap-2 mb-4"> <button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white" @click="callRemote" >发起视频</button> <button class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white" @click="hangUp" >挂断视频</button> </div> </div></template> 执行完上面的步骤就可以运行npm run dev来在本地启动项目了
后端项目
创建一个webrtc-server的文件夹,执行npm init ,一路回车即可,然后运行如下命令安装socket.io和nodemon:
npm install socket.io nodemon 创建index.js的文件,并添加如下内容:
const socket = require('socket.io');const http = require('http');const server = http.createServer()const io = socket(server, { cors: { origin: '*' // 配置跨域 }});io.on('connection', sock => { console.log('连接成功...') // 向客户端发送连接成功的消息 sock.emit('connectionSuccess');})server.listen(3000, () => { console.log('服务器启动成功');}); 在package.json中添加start命令,使用nodemon启动项目:
"scripts": { "test": "echo /"Error: no test specified/" && exit 1", "start": "nodemon index.js"}, 执行完后运行npm run start即在3000端口可启动node服务了
前端连接信令服务器
前端需要安装socket.io-client, 并连接信令服务器:
<script setup lang="ts"> // App.vue import { ref, onMounted, onUnmounted } from 'vue' import { io, Socket } from "socket.io-client"; // ... const socket = ref<Socket>() // Socket实例 onMounted(() => { const sock = io('localhost:3000'); // 对应服务的端口 // 连接成功 sock.on('connectionSuccess', () => { console.log('连接成功') }); socket.value = sock; }) // ...</script> 发起视频请求
角色:用户A–发起方,用户B–接收方
房间:类比聊天窗口
连接成功时加入房间:
// 前端代码const roomId = '001'sock.on('connectionSuccess', () => { console.log('连接服务器成功...'); sock.emit('joinRoom', roomId) // 前端发送加入房间事件})// 服务端代码sock.on('joinRoom', (roomId) => { sock.join(roomId) // 加入房间}) 用户A发起视频请求并通知用户B:
- 用户A发起视频请求,并且通过信令服务器通知用户B
// 发起方发起视频请求const callRemote = async () => { console.log('发起视频'); caller.value = true; calling.value = true; await getLocalStream() // 向信令服务器发送发起请求的事件 socket.value?.emit('callRemote', roomId)} - 用户B同意视频请求,并且通过信令服务器通知用户A
// 接收方同意视频请求const acceptCall = () => { console.log('同意视频邀请'); socket.value?.emit('acceptCall', roomId)} 开始交换 SDP 信息和 candidate 信息:
- 用户A创建创建RTCPeerConnection,添加本地音视频流,生成offer,并且通过信令服务器将offer发送给用户B
// 创建RTCPeerConnectionpeer.value = new RTCPeerConnection()// 添加本地音视频流peer.value.addStream(localStream.value)// 生成offerconst offer = await peer.value.createOffer({ offerToReceiveAudio: 1, offerToReceiveVideo: 1})console.log('offer', offer);// 设置本地描述的offerawait peer.value.setLocalDescription(offer);// 通过信令服务器将offer发送给用户Bsocket.value?.emit('sendOffer', { offer, roomId }) - 用户B收到用户A的offer
sock.on('sendOffer', (offer) => { if (called.value) { // 判断接收方 console.log('收到offer', offer); }}) - 用户B需要创建自己的RTCPeerConnection,添加本地音视频流,设置远端描述信息,生成answer,并且通过信令服务器发送给用户A
// 创建自己的RTCPeerConnectionpeer.value = new RTCPeerConnection()// 添加本地音视频流const stream = await getLocalStream()peer.value.addStream(stream)// 设置远端描述信息await peer.value.setRemoteDescription(offer);const answer = await peer.value.createAnswer()console.log(answer);await peer.value.setLocalDescription(answer);// 发送answer给信令服务器socket.value?.emit('sendAnswer', { answer, roomId }) - 用户A收到用户B的answer
sock.on('sendAnswer', (answer) => { if (caller.value) { // 判断是否是发送方 // 设置远端answer信息 peer.value.setRemoteDescription(answer); }}) - 用户A获取candidate信息并且通过信令服务器发送candidate给用户B
// 通过监听onicecandidate事件获取candidate信息peer.value.onicecandidate = (event: any) => { if (event.candidate) { console.log('用户A获取candidate信息', event.candidate); // 通过信令服务器发送candidate信息给用户B socket.value?.emit('sendCandidate', { roomId, candidate: event.candidate }) }} - 用户B添加用户A的candidate信息
// 添加candidate信息sock.on('sendCandidate', async (candidate) => { await peer.value.addIceCandidate(candidate);}) - 用户B获取candidate信息并且通过信令服务器发送candidate给用户A(如上)
peer.value.onicecandidate = (event: any) => { if (event.candidate) { console.log('用户B获取candidate信息', event.candidate); // 通过信令服务器发送candidate信息给用户A socket.value?.emit('sendCandidate', { roomId, candidate: event.candidate }) }} - 用户A添加用户B的candidate信息(如上)
// 添加candidate信息sock.on('sendCandidate', async (candidate) => { await peer.value.addIceCandidate(candidate);}) - 接下来用户A和用户B就可以进行P2P通信流
// 监听onaddstream来获取对方的音视频流peer.value.onaddstream = (event: any) => { calling.value = false; communicating.value = true; remoteVideo.value!.srcObject = event.stream remoteVideo.value!.play()} 挂断视频
// 挂断视频const hangUp = () => { console.log('挂断视频'); socket.value?.emit('hangUp', roomId)}// 状态复原const reset = () => { called.value = false caller.value = false calling.value = false communicating.value = false peer.value = null localVideo.value!.srcObject = null remoteVideo.value!.srcObject = null localStream.value = undefined} 拓展:peerjs
文档:https://peerjs.com/docs/#start
服务端实现
// 使用peer搭建信令服务器const { PeerServer } = require('peer');const peerServer = PeerServer({ port: 3001, path: '/myPeerServer' }); 前端实现
<script setup lang="ts">import { ref, onMounted } from 'vue'import { Peer } from "peerjs";const url = ref<string>()const localVideo = ref<HTMLVideoElement>()const remoteVideo = ref<HTMLVideoElement>()const peerId = ref<string>()const remoteId = ref<string>()const peer = ref<any>()const caller = ref<boolean>(false)const called = ref<boolean>(false)const callObj = ref<any>(false)onMounted(() => { // peer.value = new Peer({ // 连接信令服务器 host: 'localhost', port: 3001, path: '/myPeerServer' }); peer.value.on('open', (id: string) => { peerId.value = id }) // 接收视频请求 peer.value.on('call', async (call: any) => { called.value = true callObj.value = call });})// 获取本地音视频流async function getLocalStream(constraints: MediaStreamConstraints) { // 获取媒体流 const stream = await navigator.mediaDevices.getUserMedia(constraints) // 将媒体流设置到 video 标签上播放 localVideo.value!.srcObject = stream; localVideo.value!.play(); return stream}const acceptCalled = async () => { // 接收视频 const stream = await getLocalStream({ video: true, audio: true }) callObj.value.answer(stream); callObj.value.on('stream', (remoteStream: any) => { called.value = false // 将远程媒体流添加到 video 元素中 remoteVideo.value!.srcObject = remoteStream; remoteVideo.value!.play(); });}// 开启视频const callRemote = async () => { if (!remoteId.value) { alert('请输入对方ID') return } const stream = await getLocalStream({ video: true, audio: true }) // 将本地媒体流发送给远程 Peer const call = peer.value.call(remoteId.value, stream); caller.value = true call.on('stream', (remoteStream: any) => { caller.value = false // 将远程媒体流添加到 video 元素中 remoteVideo.value!.srcObject = remoteStream; remoteVideo.value!.play(); });}</script> Android端webRTC使用
音视屏通话逻辑梳理
1.登陆信令服务,建立socket连接,生成唯一userID,信令服务保存会话和ID的映射,保存在线用户的一些操作
2.A发起语音或视频请求,B接受邀请或拒绝,双发都进入同一个房间:
创建会话,即创建一个两个人或多个人的房间,将双方都放置到该房间,这些工作在服务器端处理
room = UUID.randomUUID().toString() + System.currentTimeMillis(); boolean b = gEngineKit.startOutCall(getApplicationContext(), room, targetId, audioOnly); mCurrentCallSession = new CallSession(context, room, audioOnly, mEvent); mCurrentCallSession.setTargetId(targetId); mCurrentCallSession.setIsComing(false); mCurrentCallSession.setCallState(EnumType.CallState.Outgoing); // 创建房间 mCurrentCallSession.createHome(room, 2); 发起方先请求创建房间
public void createRoom(String room, int roomSize) { if (webSocket != null) { webSocket.createRoom(room, roomSize, myId); } } 服务器端创建房间并直接将发起方加入到该房间,发送创建成功的消息给发起方:
private void createRoom(String message, Map<String, Object> data) { String room = (String) data.get("room"); String userId = (String) data.get("userID"); System.out.println(String.format("createRoom:%s ", room)); RoomInfo roomParam = rooms.get(room); // 没有这个房间 if (roomParam == null) { int size = (int) Double.parseDouble(String.valueOf(data.get("roomSize"))); // 创建房间 RoomInfo roomInfo = new RoomInfo(); roomInfo.setMaxSize(size); roomInfo.setRoomId(room); roomInfo.setUserId(userId); // 将房间储存起来 rooms.put(room, roomInfo); CopyOnWriteArrayList<UserBean> copy = new CopyOnWriteArrayList<>(); // 将自己加入到房间里 UserBean my = MemCons.userBeans.get(userId); copy.add(my); rooms.get(room).setUserBeans(copy); // 发送给自己 EventData send = new EventData(); send.setEventName("__peers"); Map<String, Object> map = new HashMap<>(); map.put("connections", ""); map.put("you", userId); map.put("roomSize", size); send.setData(map); System.out.println(gson.toJson(send)); sendMsg(my, -1, gson.toJson(send)); } } 发送offer邀请对方进入房间,
List<String> inviteList = new ArrayList<>(); inviteList.add(mTargetId); mEvent.sendInvite(mRoomId, inviteList, mIsAudioOnly); 发起方开启视频预览,关联相机画面到,本地的视屏流和音频流开启,在会话创建的时候就应该开启,PeerConnectionFactory是一个很核心关键的类,用于生成各种核心功能对象
public void createLocalStream() { // 音频 audioSource = _factory.createAudioSource(createAudioConstraints()); _localAudioTrack = _factory.createAudioTrack(AUDIO_TRACK_ID, audioSource); // 视频 if (!mIsAudioOnly) { // 这里对camera1 和camera2做了适配 captureAndroid = createVideoCapture(); surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", mRootEglBase.getEglBaseContext()); videoSource = _factory.createVideoSource(captureAndroid.isScreencast()); captureAndroid.initialize(surfaceTextureHelper, mContext, videoSource.getCapturerObserver()); //开启视屏流 captureAndroid.startCapture(VIDEO_RESOLUTION_WIDTH, VIDEO_RESOLUTION_HEIGHT, FPS); _localVideoTrack = _factory.createVideoTrack(VIDEO_TRACK_ID, videoSource); } } 服务器将邀请消息转发给被B邀请方,这里实际业务应该是全局都可以收到这个通知,所以考虑使用广播进行通知
public void onInvite(String room, boolean audioOnly, String inviteId, String userList) { Intent intent = new Intent(); intent.putExtra("room", room); intent.putExtra("audioOnly", audioOnly); intent.putExtra("inviteId", inviteId); intent.putExtra("userList", userList); intent.setAction(Consts.ACTION_VOIP_RECEIVER); intent.setComponent(new ComponentName(App.getInstance().getPackageName(), VoipReceiver.class.getName())); // 发送广播 App.getInstance().sendBroadcast(intent); } B在收到邀请的消息时就创建会话对象:
boolean b = SkyEngineKit.Instance().startInCall(App.getInstance(), room, inviteId, audioOnly); public boolean startInCall(Context context, final String room, final String targetId, final boolean audioOnly) { if (avEngineKit == null) { Log.e(TAG, "startInCall error,init is not set"); return false; } // 忙线中 if (mCurrentCallSession != null && mCurrentCallSession.getState() != EnumType.CallState.Idle) { // 发送->忙线中... Log.i(TAG, "startInCall busy,currentCallSession is exist,start sendBusyRefuse!"); mCurrentCallSession.sendBusyRefuse(room, targetId); return false; } this.isAudioOnly = audioOnly; // 初始化会话 mCurrentCallSession = new CallSession(context, room, audioOnly, mEvent); mCurrentCallSession.setTargetId(targetId); mCurrentCallSession.setIsComing(true); mCurrentCallSession.setCallState(EnumType.CallState.Incoming); // 开始响铃并回复 mCurrentCallSession.shouldStartRing(); mCurrentCallSession.sendRingBack(targetId, room); return true; } 进入呼叫界面,点击拒绝或者接受,接受后发送进入房间的消息给服务器,服务器添加用户到房间后发送进入成功的消息给本人,并且发送给所在房间的其他人新人进入的消息。
- SDP 和 candidate消息交换,建立数据连接通道
PeerConnection 该对象的用处就是与远端建立联接,并最终为双方通讯提供网络通道。这个在双方都确认进入房间之后就要创建,多人通话就创建多个。
被邀请人进入房间后,其他人会收到一条新人进入的消息.
Peer peer = new Peer(_factory, iceServers, userId, this);peer.setOffer(true); // add localStream List<String> mediaStreamLabels = Collections.singletonList("ARDAMS");if (_localVideoTrack != null) { peer.addVideoTrack(_localVideoTrack, mediaStreamLabels); }if (_localAudioTrack != null) { peer.addAudioTrack(_localAudioTrack, mediaStreamLabels); } // 添加列表peers.put(userId, peer); // createOfferpeer.createOffer(); 这里的peer.createOffer(); 就是开启交换的起点,交换的开启者应该是先进房间的人发起的,如果是一个多人的房间则新进来的人是没有建立连接的对象,其他已经在房间里建立连接的人都需要主动和他建立连接请求。
创建成功后会回调方法:
public void onCreateSuccess(SessionDescription origSdp) { Log.d(TAG, "sdp创建成功 " + origSdp.type); String sdpString = origSdp.description; final SessionDescription sdp = new SessionDescription(origSdp.type, sdpString); localSdp = sdp; setLocalDescription(sdp); } setLocalDescription设置成功后会调用onSetSuccess,在这个方法中发送SDP给对方,接着上面的场景就是B被呼叫方
public void onSetSuccess() { Log.d(TAG, "sdp连接成功 " + pc.signalingState().toString()); if (pc == null) return; // 发送者 if (isOffer) { if (pc.getRemoteDescription() == null) { Log.d(TAG, "Local SDP set succesfully"); if (!isOffer) { //接收者,发送Answer mEvent.onSendAnswer(mUserId, localSdp); } else { //发送者,发送自己的offer mEvent.onSendOffer(mUserId, localSdp); } } else { Log.d(TAG, "Remote SDP set succesfully"); drainCandidates(); } } else { if (pc.getLocalDescription() != null) { Log.d(TAG, "Local SDP set succesfully"); if (!isOffer) { //接收者,发送Answer mEvent.onSendAnswer(mUserId, localSdp); } else { //发送者,发送自己的offer mEvent.onSendOffer(mUserId, localSdp); } drainCandidates(); } else { Log.d(TAG, "Remote SDP set succesfully"); } } } 被叫方收到服务器消息处理:
public void receiveOffer(String userId, String description) { Peer peer = peers.get(userId); if (peer != null) { SessionDescription sdp = new SessionDescription(SessionDescription.Type.OFFER, description); peer.setOffer(false); peer.setRemoteDescription(sdp); peer.createAnswer(); } }/...... public void createAnswer() { if (pc == null) return; Log.d(TAG, "createAnswer"); //成功后会调用上面的success回调 发送answer给对方 pc.createAnswer(this, offerOrAnswerConstraint()); }// A就会收到answer,同时调用 peer.setRemoteDescription(sdp);
SDP交换完成后会调用onIceCandidate在这里把candidate信息发送给对方
mEvent.onSendIceCandidate(mUserId, candidate); 前端和移动端互通
双方的RTC实现是一样的,但是API和数据结构可能不同,前端的实现相对比较简洁。需要注意的是SDP和candidate的设置。
const candidate :RTCIceCandidateInit = ({ candidate: jsonObject.data.candidate, sdpMid: jsonObject.data.id, sdpMLineIndex: jsonObject.data.label, }); await peer.value.addIceCandidate(candidate); const des:RTCSessionDescriptionInit = { sdp: sdp, type: "offer", } // 设置远端描述信息 await peer.value.setRemoteDescription(des); 这两处的数据结构不同














