P2P通信核心技术解析与实战应用经验分享
我的写法,亲测靠谱
做 P2P 通信这几年,我踩过不少坑,也总结出一套自己用起来最顺手的写法。WebRTC 看似简单,但真要跑通、稳定、兼容性好,细节特别多。下面这个基础连接流程,是我目前项目里最常用的骨架代码,亲测在 Chrome、Edge、Safari(iOS 15+)上都能跑:
// 创建 PeerConnection 实例
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:global.stun.twilio.com:3478' }
]
});
// 收到 ICE 候选时,立刻发给对方
pc.onicecandidate = (event) => {
if (event.candidate) {
signalSend({ type: 'candidate', candidate: event.candidate });
}
};
// 接收远端流(旧版 API,注意兼容)
pc.onaddstream = (event) => {
remoteVideo.srcObject = event.stream;
};
// 新版推荐用 track
pc.ontrack = (event) => {
if (remoteVideo.srcObject !== event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
}
};
// 发起方:创建 offer
async function createOffer() {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalSend({ type: 'offer', sdp: offer });
}
// 接收方:收到 offer 后创建 answer
async function handleOffer(offer) {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalSend({ type: 'answer', sdp: answer });
}
这里有几个关键点我必须强调:
- ICE 服务器至少配两个:Google 的 STUN 虽然免费,但国内有时不稳定,加个 Twilio 的备用更保险。
- ontrack 和 onaddstream 都要处理:虽然 onaddstream 已废弃,但 Safari 15 之前只支持它,不加会黑屏。
- addTrack 必须在 setLocalDescription 之前调用:否则 SDP 里没媒体信息,对方收不到流。
这几种错误写法,别再踩坑了
我见过太多人栽在这些地方,自己也折腾过好几次,血泪教训:
错误一:把 offer/answer 当成一次性的,不处理重连
很多人以为连接成功就万事大吉,结果网络抖动一下,ICE 断了,整个通信就挂了。正确的做法是监听 iceconnectionstatechange,状态变成 disconnected 或 failed 时主动重连:
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
// 这里可以触发重连逻辑,比如重新发起 offer
console.log('连接断开,准备重连...');
reconnect();
}
};
错误二:candidate 全部收集完再发
早期有些教程教人等 iceGatheringState === 'complete' 再发 candidate,这在 NAT 类型复杂的环境下会卡住,因为 gathering 可能永远不完。我的经验是:**来一个发一个**,信令服务器只要按顺序转发就行,WebRTC 会自动处理。
错误三:忽略跨域和 HTTPS 限制
WebRTC 在 Chrome 里要求页面必须是 HTTPS(localhost 除外),而且 getUserMedia 会被浏览器拦截。有次我在本地测试没问题,部署到 HTTP 服务器就完全没反应,折腾半天才发现是协议问题。上线前务必检查!
错误四:SDP 直接 JSON.stringify 传输
SDP 里可能包含换行符和特殊字符,直接当 JSON 字段传容易出错。稳妥做法是 base64 编码,或者确保信令通道能正确处理多行文本。我吃过亏,对方收到的 SDP 少了两行,连接直接失败。
实际项目中的坑
除了代码层面,项目落地时还有不少“非技术”但很致命的问题:
1. 信令服务器的设计
P2P 只负责媒体传输,信令(offer/answer/candidate)还得靠你自己搭通道。我一开始用 WebSocket + 简单 room 机制,结果用户量一上来就乱发消息。后来改成带 session ID 的定向转发,每个连接只收自己的信令,才稳定下来。信令这块千万别偷懒,否则调试起来像捉鬼。
2. 移动端 Safari 的奇葩行为
iOS 上的 Safari 对 WebRTC 支持很怪:必须用户手势触发才能调用 getUserMedia,而且后台切出去再回来,stream 会静音。我们最后加了个“点击开始通话”的按钮,所有媒体操作都绑定在 click 事件里,才算绕过去。另外,iOS 14 以下基本别想用,直接降级成普通视频通话吧。
3. 带宽和编码问题
默认的 VP8 编码在低端安卓机上卡成幻灯片。后来我加了动态调整分辨率的逻辑:根据设备性能和网络状况,把 videoConstraints 里的 width/height 动态设为 640×480 或 320×240。虽然画质差了点,但至少能用。代码大概这样:
const constraints = {
video: {
width: { ideal: isLowEndDevice ? 320 : 1280 },
height: { ideal: isLowEndDevice ? 240 : 720 },
frameRate: { ideal: 15 }
},
audio: true
};
4. 安全别忽视
有人以为 P2P 就安全,其实信令通道如果被中间人劫持,完全可以伪造 offer 把你的流导到他服务器上。所以信令通信必须走 WSS(WebSocket Secure),而且最好加个简单的 token 验证。我试过在信令消息里加个 timestamp + HMAC,虽然不完美,但比裸奔强。
几个小技巧,提升体验
最后分享几个让 P2P 用起来更顺的 trick:
- 预加载 ICE 候选:在用户还没点“开始通话”时,就悄悄创建一个空的 PeerConnection,让它提前收集 candidate。等真正要通话时,第一波 candidate 能快 1~2 秒发出去,首帧时间明显缩短。
- 用 datachannel 传控制指令:除了音视频,我还用 RTCDataChannel 传一些小数据,比如“对方正在输入”“已读回执”。比走信令服务器快,而且天然和媒体流同步。
- 优雅降级:如果 P2P 连不上(比如双方都在对称 NAT 后),立刻切到中转服务器(TURN)。虽然要花钱,但总比功能不可用强。我配置了 coturn 作为 fallback,成功率从 70% 提升到 95% 以上。
以上是我踩坑后的总结,希望对你有帮助。P2P 通信没有银弹,很多问题得靠日志和耐心调试。我的方案也不是最优的,但胜在简单、稳定、能跑。有更优的实现方式欢迎评论区交流,一起少走点弯路。
