P2P通信核心技术解析与实战应用经验分享

a'ゞ东芳 交互 阅读 2,381
赞 10 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

做 P2P 通信这几年,我踩过不少坑,也总结出一套自己用起来最顺手的写法。WebRTC 看似简单,但真要跑通、稳定、兼容性好,细节特别多。下面这个基础连接流程,是我目前项目里最常用的骨架代码,亲测在 Chrome、Edge、Safari(iOS 15+)上都能跑:

P2P通信核心技术解析与实战应用经验分享

// 创建 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,状态变成 disconnectedfailed 时主动重连:

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 通信没有银弹,很多问题得靠日志和耐心调试。我的方案也不是最优的,但胜在简单、稳定、能跑。有更优的实现方式欢迎评论区交流,一起少走点弯路。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
一爱娜
一爱娜 Lv1
这篇文章是我技术学习道路上的一个重要里程碑,帮我实现了认知的升级。
点赞 3
2026-02-25 19:25