16、点对点视频通话实战:完整的一对一视频通话流程、本地预览与远端显示、视频窗口布局
好,到了这一章,咱们终于要真刀真枪地写一个完整的点对点视频通话了。
前面几章我们聊了信令、ICE、SDP、媒体协商这些概念。说实话,光看理论容易晕。我自己刚学WebRTC那会儿,也是看了好几遍流程图,但一动手写代码就卡壳。
这一章,我们就来打通最后一公里。把本地摄像头打开,把画面传给对方,再把对方的画面显示出来。嗯,说白了就是——你看到我,我看到你。
16.1 整体流程回顾
先理一下思路。一个完整的点对点视频通话,核心就三步:
- 本地预览:打开摄像头,把画面显示在自己的页面上。
- 媒体协商:通过信令服务器交换SDP和ICE候选。
- 远端显示:把接收到的媒体流渲染到对方的视频标签里。
我习惯把这三步拆成两个阶段:
- 本地准备阶段:获取用户媒体设备,创建本地流,开始预览。
- 连接建立阶段:创建RTCPeerConnection,添加流,触发协商,等待连接。
你想想看,如果本地摄像头都没打开,后面的一切都是白搭。所以第一步,我们先搞定本地预览。
核心要点:本地预览用的是 getUserMedia,远端显示用的是 RTCPeerConnection 的 ontrack 回调。两者最终都绑定到 <video> 标签上。
16.2 本地预览:打开摄像头
获取本地媒体流,代码其实就几行。但坑不少。
// 获取本地视频流
async function startLocalPreview() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
const localVideo = document.getElementById('localVideo');
localVideo.srcObject = stream;
return stream;
} catch (err) {
console.error('获取媒体设备失败:', err);
// 我曾经遇到过用户拒绝授权的情况,页面直接白屏
// 所以一定要加错误处理
alert('请允许使用摄像头和麦克风');
}
}
这里有个细节:srcObject 直接赋值为 stream,不要用 src 属性。我见过有人写成 localVideo.src = URL.createObjectURL(stream),这在老版本浏览器里能用,但现在不推荐了。
小技巧:如果你只想预览,不想让音频回放造成啸叫,可以把本地视频标签的 muted 属性设为 true。嗯,这个我踩过坑,第一次测试时差点被自己的回声震聋。
16.3 创建 PeerConnection 并添加流
本地预览搞定后,就该创建连接了。这里我习惯把创建 RTCPeerConnection 封装成一个函数,方便复用。
function createPeerConnection(localStream) {
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const pc = new RTCPeerConnection(config);
// 添加本地流
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 监听远端流
pc.ontrack = (event) => {
const remoteVideo = document.getElementById('remoteVideo');
if (remoteVideo.srcObject !== event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
console.log('收到远端流');
}
};
// 监听ICE候选
pc.onicecandidate = (event) => {
if (event.candidate) {
// 通过信令发送给远端
sendMessage({ type: 'candidate', candidate: event.candidate });
}
};
return pc;
}
注意 ontrack 这个回调。它是在收到远端媒体流时触发的。我刚开始做的时候,以为要手动去拿流,其实不用。WebRTC 框架会自动把流送过来,你只需要把 event.streams[0] 赋值给视频标签就行。
注意:一定要在 addTrack 之后才触发协商。否则你创建的 Offer 里可能没有媒体信息,导致协商失败。我曾经因为这个排查了半天,最后发现是调用顺序错了。
16.4 发起呼叫与应答
发起方创建 Offer,接收方创建 Answer。这个流程我们之前讲过,这里直接上代码。
// 发起方:创建 Offer
async function call() {
const pc = createPeerConnection(localStream);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendMessage({ type: 'offer', sdp: offer });
}
// 接收方:收到 Offer 后创建 Answer
async function handleOffer(offer) {
const pc = createPeerConnection(localStream);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendMessage({ type: 'answer', sdp: answer });
}
这里有个关键点:setLocalDescription 和 setRemoteDescription 的顺序不能乱。发起方先设本地,再设远端;接收方先设远端,再设本地。说白了,就是谁先发起,谁先设本地。
16.5 视频窗口布局
技术流程走完了,咱们聊聊 UI。一个视频通话页面,至少要有两个视频窗口:本地预览和远端显示。
我常用的布局方案有两种:
| 布局方式 | 适用场景 | 特点 |
|---|---|---|
| 左右并排 | 桌面端、大屏 | 双方画面等大,适合正式会议 |
| 画中画 | 移动端、小屏 | 远端全屏,本地小窗悬浮 |
我个人比较推荐画中画布局。因为用户更关注对方的反应,自己的画面只需要偶尔瞄一眼确认摄像头没关就行。
下面是一个简单的画中画布局 CSS 示例:
.video-container {
position: relative;
width: 100%;
max-width: 800px;
margin: 0 auto;
}
#remoteVideo {
width: 100%;
height: auto;
border-radius: 8px;
background: #000;
}
#localVideo {
position: absolute;
bottom: 16px;
right: 16px;
width: 160px;
height: 120px;
border-radius: 8px;
border: 2px solid #fff;
object-fit: cover;
background: #333;
}
嗯,这里要注意:本地视频一般要设置 object-fit: cover,防止画面变形。我见过有人没设这个,结果本地画面被拉伸得跟哈哈镜似的。
16.6 完整流程的 SVG 图
说了这么多,咱们用一张图把整个流程串起来。
16.7 避坑指南
最后,分享几个我实际项目中遇到的坑:
- 摄像头权限被拒绝:一定要在
getUserMedia的 catch 里做提示,不要静默失败。 - 视频标签不显示:检查
srcObject是否赋值成功,以及视频标签有没有autoplay和playsinline属性。 - 远端画面卡顿:可能是 ICE 候选没交换完整。我建议在
oniceconnectionstatechange里打印状态,方便排查。 - 本地画面镜像:如果你想让本地预览看起来像镜子,可以给视频标签加
transform: scaleX(-1)。但注意,这个只影响显示,不影响发送出去的流。
个人习惯:我一般会在页面加载完成后,先调用一次 getUserMedia 做预览,等用户点击「开始通话」时再创建 PeerConnection。这样用户体验更流畅,不会出现点击后等半天才看到画面的情况。
好了,这一章的内容就到这里。代码不多,但每一步都关键。你只要把本地预览、PeerConnection 创建、媒体协商、视频渲染这四个环节串起来,一个完整的点对点视频通话就成型了。