You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

被叫方启动通话时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有媒体需求时才会启动,这里的需求包括:

  1. 添加了本地媒体轨道(音频/视频)
  2. 创建了数据通道
  3. 在创建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 });
});

验证修改

完成上述修改后,重新启动服务,测试流程:

  1. 被叫方启动直播
  2. 呼叫方点击"See lives"发送Offer
  3. 此时两端的onicecandidate事件应该会触发,日志中会出现"send:candidate"的记录,ICE Candidate开始收集并通过Socket传输

内容的提问来源于stack exchange,提问作者Trong Nguyen

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 06:58:14