被叫方启动通话时RTC ICE Candidate收集未触发问题排查
WebRTC ICE Candidate未触发问题排查与解决
问题背景
实现流程:
- 被叫方启动直播并等待呼叫方加入
- 呼叫方加入后发送Offer,按标准WebRTC流程建立点对点连接
实际问题:日志中完全没有ICE Candidate触发的记录
预期结果:调用setLocalDescription()后,应该自动触发ICE Candidate收集流程
代码文件
server.js
const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const path = require('path'); const cors = require('cors'); const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: '*', // 生产环境建议指定具体域名以提升安全性 methods: ['GET', 'POST'] } }); app.use(cors()); app.use(express.static(path.join(__dirname, 'public'))); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/lives/:id', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'callee.html')); }); app.get('/view/:id', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'caller.html')); }); io.on('connection', (socket) => { console.log('New client connected:', socket.id); socket.on('start-lives', ({ role, roomId }) => { socket.data = { role, roomId }; socket.join(roomId); console.log("Owner connected"); }); socket.on('join', (data) => { socket.join(data.roomId); socket.to(data.roomId).emit('join', socket.id); console.log(`Client ${socket.id} joined room ${data.roomId}`); }); socket.on('offer', (data) => { socket.to(data.roomId).emit('offer', data.sdp); }); socket.on('answer', (data) => { console.log("transfer:answer"); socket.to(data.roomId).emit('answer', data.sdp); }); socket.on('candidate', (data) => { socket.to(data.roomId).emit('candidate', data.candidate); }); socket.on('disconnect', () => { console.log('Client disconnected:', socket.data); }); }); server.listen(8080, () => { console.log('Server listening on port 8080'); });
callee.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Livestream</title> <style> #lives-container { width: 50vw; } #lives-video { background: url('https://marketplace.canva.com/EAEyZPDsh-4/1/0/1600w/canva-dark-purple-futuristic-stream-starting-soon-twitch-background-9h5ocxbndVU.jpg') no-repeat center; width: 100%; } #btn-start-lives { display: block; margin-left: auto; } </style> </head> <body> <h1>Livestream</h1> <section id="lives-container"> <video id="lives-video" autoplay playsinline></video> <button id="btn-start-lives">Start</button> <button id="btn-end-lives">End</button> </section> <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script> <script> const socket = io('http://localhost:8080'); const livesVideo = document.getElementById('lives-video'); const btnStartLives = document.getElementById('btn-start-lives'); const btnEndLives = document.getElementById('btn-end-lives'); const roomId = window.location.pathname.split('/').pop(); let stream; let pc = new RTCPeerConnection(); pc.onicecandidate = event => { console.log("send:candidate"); if (event.candidate) { socket.emit('candidate', { roomId, candidate: event.candidate }); } } socket.on('offer', async (sdp) => { console.log("received:offer"); stream.getTracks().forEach(track => pc.addTrack(track, stream)); await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp })); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); console.log("send:answer"); socket.emit('answer', { roomId, sdp: answer.sdp }); }); socket.on('candidate', async (candidate) => { console.log("received:candidate"); try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { console.error('Error adding received ice candidate', e); } }); btnStartLives.addEventListener("click", async function () { stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); livesVideo.srcObject = stream; livesVideo.onloadedmetadata = () => { livesVideo.play(); }; socket.emit('start-lives', { role: 'owner', roomId }); }); btnEndLives.addEventListener("click", async function () { stream.getTracks().forEach(track => track.stop()); pc.close(); }); </script> </body> </html>
caller.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Livestream viewer</title> </head> <body> <section> <video id="remote-video"></video> <button id="btn-approval">See lives</button> </section> <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script> <script> const socket = io('http://localhost:8080'); const remoteVideo = document.getElementById('remote-video'); const btnApproval = document.getElementById('btn-approval'); const roomId = window.location.pathname.split('/').pop(); let pc = new RTCPeerConnection(); remoteVideo.onloadedmetadata = () => { remoteVideo.play(); } pc.onicecandidate = event => { console.log("send:candidate"); if (event.candidate) { socket.emit('candidate', { roomId, candidate: event.candidate }); } } pc.ontrack = event => { console.log("trigger:ontrack"); remoteVideo.srcObject = event.streams[0]; } btnApproval.addEventListener("click", async function () { // let stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); // stream.getTracks().forEach(track => pc.addTrack(track, stream)); socket.emit('join', { roomId }); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); console.log("send:offer"); socket.emit('offer', { roomId, sdp: offer.sdp }); this.remove(); }); socket.on('answer', async (sdp) => { console.log("received:answer"); await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp })); }); socket.on('candidate', async (candidate) => { console.log("received:candidate"); try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { console.error('Error adding received ice candidate', e); } }); </script> </body> </html>
问题原因
WebRTC的ICE Candidate收集只会在PeerConnection有媒体需求时才会启动,这里的需求包括:
- 添加了本地媒体轨道(音频/视频)
- 创建了数据通道
- 在创建Offer时明确指定需要接收远端媒体(
offerToReceiveVideo/offerToReceiveAudio)
你的Caller端代码中,注释掉了本地媒体轨道的添加,且创建Offer时没有指定接收远端媒体,导致PeerConnection没有任何媒体传输需求,因此不会触发ICE收集流程。
解决方案
修改Caller端的Offer创建逻辑,明确指定需要接收视频(因为是观看直播,只需要接收被叫方的视频流):
btnApproval.addEventListener("click", async function () { socket.emit('join', { roomId }); // 指定需要接收视频,触发ICE收集 const offer = await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: false }); await pc.setLocalDescription(offer); console.log("send:offer"); socket.emit('offer', { roomId, sdp: offer.sdp }); this.remove(); });
另外,建议Callee端在启动直播时就将媒体轨道添加到PeerConnection,而不是等到收到Offer再添加,这样流程更合理:
btnStartLives.addEventListener("click", async function () { stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); livesVideo.srcObject = stream; livesVideo.onloadedmetadata = () => { livesVideo.play(); }; // 启动直播时就添加轨道到PeerConnection stream.getTracks().forEach(track => pc.addTrack(track, stream)); socket.emit('start-lives', { role: 'owner', roomId }); });
验证修改
完成上述修改后,重新启动服务,测试流程:
- 被叫方启动直播
- 呼叫方点击"See lives"发送Offer
- 此时两端的
onicecandidate事件应该会触发,日志中会出现"send:candidate"的记录,ICE Candidate开始收集并通过Socket传输
内容的提问来源于stack exchange,提问作者Trong Nguyen
相关产品推荐
相关产品推荐

