技术架构概述
音视频通话是WebRTC的核心应用场景,本文档提供1v1通话、多人会议、屏幕共享、录制回放和网络自适应的完整实现方案。
15.2.1 1v1视频通话
技术实现方案
1v1视频通话是最基础的场景,包含呼叫、接听、挂断的完整流程。
完整实现代码
<!-- OneToOneCall.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';
const props = defineProps({
serverUrl: {
type: String,
default: 'http://localhost:3000'
},
userId: {
type: String,
required: true
}
});
const emit = defineEmits(['call-started', 'call-ended']);
// 连接状态
const socket = ref(null);
const localStream = ref(null);
const remoteStream = ref(null);
const peerConnection = ref(null);
// 通话状态
const callState = reactive({
isInCall: false,
isCalling: false,
isReceivingCall: false,
remoteUserId: null,
callStartTime: null
});
// 媒体控制
const mediaControl = reactive({
isVideoEnabled: true,
isAudioEnabled: true,
isSpeakerOn: true
});
// 视频元素引用
const localVideoRef = ref(null);
const remoteVideoRef = ref(null);
// ICE服务器配置
const iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
];
// 初始化Socket连接
const initSocket = () => {
socket.value = io(props.serverUrl);
socket.value.on('connect', () => {
console.log('已连接到信令服务器');
socket.value.emit('register', props.userId);
});
// 接收呼叫
socket.value.on('incoming-call', async ({ from, offer }) => {
console.log('收到呼叫:', from);
callState.isReceivingCall = true;
callState.remoteUserId = from;
// 自动接听或等待用户操作
// 这里可以弹出接听界面
});
// 接收Answer
socket.value.on('call-answered', async ({ answer }) => {
console.log('对方已接听');
if (peerConnection.value) {
await peerConnection.value.setRemoteDescription(new RTCSessionDescription(answer));
callState.isCalling = false;
callState.isInCall = true;
callState.callStartTime = Date.now();
}
});
// 接收ICE候选
socket.value.on('ice-candidate', async ({ candidate }) => {
if (peerConnection.value && candidate) {
try {
await peerConnection.value.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
console.error('添加ICE候选失败:', error);
}
}
});
// 对方挂断
socket.value.on('call-ended', () => {
console.log('对方已挂断');
endCall();
});
};
// 获取本地媒体流
const getLocalStream = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
localStream.value = stream;
if (localVideoRef.value) {
localVideoRef.value.srcObject = stream;
}
return stream;
} catch (error) {
console.error('获取本地媒体流失败:', error);
throw error;
}
};
// 创建PeerConnection
const createPeerConnection = () => {
const pc = new RTCPeerConnection({ iceServers });
// 添加本地流
if (localStream.value) {
localStream.value.getTracks().forEach(track => {
pc.addTrack(track, localStream.value);
});
}
// ICE候选事件
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.value.emit('ice-candidate', {
to: callState.remoteUserId,
candidate: event.candidate
});
}
};
// 接收远程流
pc.ontrack = (event) => {
console.log('接收到远程流');
remoteStream.value = event.streams[0];
if (remoteVideoRef.value) {
remoteVideoRef.value.srcObject = event.streams[0];
}
};
// 连接状态变化
pc.onconnectionstatechange = () => {
console.log('连接状态:', pc.connectionState);
if (pc.connectionState === 'connected') {
console.log('P2P连接建立成功');
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
console.log('连接失败或断开');
endCall();
}
};
// ICE连接状态
pc.oniceconnectionstatechange = () => {
console.log('ICE连接状态:', pc.iceConnectionState);
};
peerConnection.value = pc;
return pc;
};
// 发起呼叫
const startCall = async (targetUserId) => {
try {
callState.remoteUserId = targetUserId;
callState.isCalling = true;
// 获取本地流
await getLocalStream();
// 创建PeerConnection
const pc = createPeerConnection();
// 创建Offer
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await pc.setLocalDescription(offer);
// 发送Offer
socket.value.emit('call-user', {
to: targetUserId,
from: props.userId,
offer: offer
});
console.log('已发送呼叫请求');
} catch (error) {
console.error('发起呼叫失败:', error);
callState.isCalling = false;
}
};
// 接听呼叫
const answerCall = async (offer) => {
try {
// 获取本地流
await getLocalStream();
// 创建PeerConnection
const pc = createPeerConnection();
// 设置远程描述
await pc.setRemoteDescription(new RTCSessionDescription(offer));
// 创建Answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 发送Answer
socket.value.emit('answer-call', {
to: callState.remoteUserId,
answer: answer
});
callState.isReceivingCall = false;
callState.isInCall = true;
callState.callStartTime = Date.now();
emit('call-started', callState.remoteUserId);
} catch (error) {
console.error('接听呼叫失败:', error);
}
};
// 拒绝呼叫
const rejectCall = () => {
socket.value.emit('reject-call', {
to: callState.remoteUserId
});
callState.isReceivingCall = false;
callState.remoteUserId = null;
};
// 结束通话
const endCall = () => {
// 通知对方
if (callState.remoteUserId) {
socket.value.emit('end-call', {
to: callState.remoteUserId
});
}
// 关闭连接
if (peerConnection.value) {
peerConnection.value.close();
peerConnection.value = null;
}
// 停止本地流
if (localStream.value) {
localStream.value.getTracks().forEach(track => track.stop());
localStream.value = null;
}
// 清空远程流
remoteStream.value = null;
// 重置状态
callState.isInCall = false;
callState.isCalling = false;
callState.isReceivingCall = false;
callState.remoteUserId = null;
callState.callStartTime = null;
emit('call-ended');
};
// 切换视频
const toggleVideo = () => {
if (localStream.value) {
const videoTrack = localStream.value.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
mediaControl.isVideoEnabled = videoTrack.enabled;
}
}
};
// 切换音频
const toggleAudio = () => {
if (localStream.value) {
const audioTrack = localStream.value.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
mediaControl.isAudioEnabled = audioTrack.enabled;
}
}
};
// 切换扬声器
const toggleSpeaker = () => {
mediaControl.isSpeakerOn = !mediaControl.isSpeakerOn;
if (remoteVideoRef.value) {
remoteVideoRef.value.muted = !mediaControl.isSpeakerOn;
}
};
// 获取通话时长
const getCallDuration = () => {
if (!callState.callStartTime) return '00:00';
const duration = Math.floor((Date.now() - callState.callStartTime) / 1000);
const minutes = Math.floor(duration / 60).toString().padStart(2, '0');
const seconds = (duration % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
};
onMounted(() => {
initSocket();
});
onUnmounted(() => {
endCall();
if (socket.value) {
socket.value.disconnect();
}
});
defineExpose({
startCall,
answerCall,
rejectCall,
endCall,
toggleVideo,
toggleAudio,
toggleSpeaker
});
</script>
<template>
<div class="one-to-one-call">
<!-- 视频区域 -->
<div class="video-container">
<!-- 远程视频 -->
<div class="remote-video">
<video
ref="remoteVideoRef"
autoplay
playsinline
:class="{ hidden: !remoteStream }"
></video>
<div v-if="!remoteStream" class="placeholder">
<div v-if="callState.isCalling">正在呼叫...</div>
<div v-else-if="callState.isReceivingCall">来电中...</div>
<div v-else>等待连接...</div>
</div>
</div>
<!-- 本地视频 -->
<div class="local-video">
<video
ref="localVideoRef"
autoplay
playsinline
muted
:class="{ hidden: !mediaControl.isVideoEnabled }"
></video>
<div v-if="!mediaControl.isVideoEnabled" class="video-off">
摄像头已关闭
</div>
</div>
</div>
<!-- 控制栏 -->
<div class="controls" v-if="callState.isInCall || callState.isCalling">
<!-- 通话时长 -->
<div class="call-duration" v-if="callState.isInCall">
{{ getCallDuration() }}
</div>
<!-- 控制按钮 -->
<div class="control-buttons">
<button
@click="toggleVideo"
:class="{ active: mediaControl.isVideoEnabled }"
class="control-btn"
>
{{ mediaControl.isVideoEnabled ? '📹' : '📹' }}
</button>
<button
@click="toggleAudio"
:class="{ active: mediaControl.isAudioEnabled }"
class="control-btn"
>
{{ mediaControl.isAudioEnabled ? '🎤' : '🔇' }}
</button>
<button
@click="toggleSpeaker"
:class="{ active: mediaControl.isSpeakerOn }"
class="control-btn"
>
{{ mediaControl.isSpeakerOn ? '🔊' : '🔇' }}
</button>
<button
@click="endCall"
class="control-btn end-call"
>
挂断
</button>
</div>
</div>
<!-- 来电界面 -->
<div class="incoming-call" v-if="callState.isReceivingCall">
<div class="call-info">
<p>{{ callState.remoteUserId }} 来电</p>
</div>
<div class="call-actions">
<button @click="answerCall" class="answer-btn">接听</button>
<button @click="rejectCall" class="reject-btn">拒绝</button>
</div>
</div>
</div>
</template>
<style scoped>
.one-to-one-call {
width: 100%;
height: 100vh;
background: #000;
position: relative;
}
.video-container {
width: 100%;
height: 100%;
position: relative;
}
.remote-video {
width: 100%;
height: 100%;
}
.remote-video video {
width: 100%;
height: 100%;
object-fit: cover;
}
.remote-video .placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.local-video {
position: absolute;
bottom: 100px;
right: 20px;
width: 200px;
height: 150px;
border-radius: 8px;
overflow: hidden;
background: #333;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.local-video video {
width: 100%;
height: 100%;
object-fit: cover;
}
.local-video .video-off {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
.controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
}
.call-duration {
text-align: center;
color: white;
font-size: 18px;
margin-bottom: 15px;
}
.control-buttons {
display: flex;
justify-content: center;
gap: 15px;
}
.control-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.2);
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.3s;
}
.control-btn:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.1);
}
.control-btn.active {
background: #1890ff;
}
.control-btn.end-call {
background: #ff4d4f;
}
.control-btn.end-call:hover {
background: #ff7875;
}
.incoming-call {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 40px;
border-radius: 12px;
text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.call-info {
margin-bottom: 30px;
}
.call-info p {
font-size: 20px;
font-weight: bold;
margin: 0;
}
.call-actions {
display: flex;
gap: 20px;
}
.answer-btn,
.reject-btn {
padding: 12px 32px;
border: none;
border-radius: 24px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
.answer-btn {
background: #52c41a;
color: white;
}
.answer-btn:hover {
background: #73d13d;
}
.reject-btn {
background: #ff4d4f;
color: white;
}
.reject-btn:hover {
background: #ff7875;
}
.hidden {
display: none;
}
</style>
15.2.2 多人会议室
技术实现方案
多人会议使用Mesh架构,每个参与者与其他所有人建立P2P连接。适合小规模会议(2-8人)。
完整实现代码
<!-- MultiPartyConference.vue -->
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';
const props = defineProps({
serverUrl: {
type: String,
default: 'http://localhost:3000'
},
roomId: {
type: String,
required: true
},
userId: {
type: String,
required: true
},
maxParticipants: {
type: Number,
default: 8
}
});
const emit = defineEmits(['room-joined', 'room-left', 'participant-changed']);
// 连接管理
const socket = ref(null);
const localStream = ref(null);
const peerConnections = reactive(new Map());
const remoteStreams = reactive(new Map());
// 会议室状态
const roomState = reactive({
isJoined: false,
participants: [],
speakingUser: null
});
// 媒体控制
const mediaState = reactive({
isVideoEnabled: true,
isAudioEnabled: true,
isScreenSharing: false
});
// 布局模式
const layoutMode = ref('grid'); // grid, speaker, gallery
// 视频元素引用
const localVideoRef = ref(null);
// ICE配置
const iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
];
// 计算布局
const videoLayout = computed(() => {
const count = remoteStreams.size + 1; // 包含本地视频
if (layoutMode.value === 'grid') {
if (count <= 2) return { cols: 2, rows: 1 };
if (count <= 4) return { cols: 2, rows: 2 };
if (count <= 6) return { cols: 3, rows: 2 };
if (count <= 9) return { cols: 3, rows: 3 };
return { cols: 4, rows: 3 };
}
return { cols: 1, rows: 1 };
});
// 初始化Socket
const initSocket = () => {
socket.value = io(props.serverUrl);
socket.value.on('connect', () => {
console.log('已连接到信令服务器');
});
// 房间用户列表
socket.value.on('room-users', (users) => {
console.log('房间用户列表:', users);
roomState.participants = users;
// 与每个用户建立连接
users.forEach(userId => {
if (userId !== props.userId) {
createOffer(userId);
}
});
});
// 新用户加入
socket.value.on('user-joined', async ({ userId }) => {
console.log('用户加入:', userId);
if (!roomState.participants.includes(userId)) {
roomState.participants.push(userId);
}
emit('participant-changed', roomState.participants);
});
// 用户离开
socket.value.on('user-left', ({ userId }) => {
console.log('用户离开:', userId);
// 关闭连接
const pc = peerConnections.get(userId);
if (pc) {
pc.close();
peerConnections.delete(userId);
}
// 移除流
remoteStreams.delete(userId);
// 更新参与者列表
const index = roomState.participants.indexOf(userId);
if (index > -1) {
roomState.participants.splice(index, 1);
}
emit('participant-changed', roomState.participants);
});
// 接收Offer
socket.value.on('offer', async ({ offer, from }) => {
console.log('收到Offer:', from);
await handleOffer(offer, from);
});
// 接收Answer
socket.value.on('answer', async ({ answer, from }) => {
console.log('收到Answer:', from);
const pc = peerConnections.get(from);
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
});
// 接收ICE候选
socket.value.on('ice-candidate', async ({ candidate, from }) => {
const pc = peerConnections.get(from);
if (pc && candidate) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
console.error('添加ICE候选失败:', error);
}
}
});
};
// 获取本地流
const getLocalStream = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
localStream.value = stream;
if (localVideoRef.value) {
localVideoRef.value.srcObject = stream;
}
// 监测说话状态
detectSpeaking(stream);
return stream;
} catch (error) {
console.error('获取本地媒体流失败:', error);
throw error;
}
};
// 创建PeerConnection
const createPeerConnection = (userId) => {
const pc = new RTCPeerConnection({ iceServers });
// 添加本地流
if (localStream.value) {
localStream.value.getTracks().forEach(track => {
pc.addTrack(track, localStream.value);
});
}
// ICE候选
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.value.emit('ice-candidate', {
roomId: props.roomId,
candidate: event.candidate,
targetUserId: userId
});
}
};
// 接收远程流
pc.ontrack = (event) => {
console.log('接收到远程流:', userId);
remoteStreams.set(userId, event.streams[0]);
};
// 连接状态
pc.onconnectionstatechange = () => {
console.log(`连接状态 [${userId}]:`, pc.connectionState);
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
// 尝试重连
console.log('连接断开,尝试重连...');
}
};
peerConnections.set(userId, pc);
return pc;
};
// 创建Offer
const createOffer = async (userId) => {
const pc = createPeerConnection(userId);
try {
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await pc.setLocalDescription(offer);
socket.value.emit('offer', {
roomId: props.roomId,
offer: offer,
targetUserId: userId
});
} catch (error) {
console.error('创建Offer失败:', error);
}
};
// 处理Offer
const handleOffer = async (offer, userId) => {
const pc = createPeerConnection(userId);
try {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.value.emit('answer', {
roomId: props.roomId,
answer: answer,
targetUserId: userId
});
} catch (error) {
console.error('处理Offer失败:', error);
}
};
// 加入房间
const joinRoom = async () => {
try {
// 获取本地流
await getLocalStream();
// 加入房间
socket.value.emit('join-room', props.roomId, props.userId);
roomState.isJoined = true;
emit('room-joined', props.roomId);
} catch (error) {
console.error('加入房间失败:', error);
}
};
// 离开房间
const leaveRoom = () => {
// 通知服务器
socket.value.emit('leave-room', props.roomId, props.userId);
// 关闭所有连接
peerConnections.forEach(pc => pc.close());
peerConnections.clear();
remoteStreams.clear();
// 停止本地流
if (localStream.value) {
localStream.value.getTracks().forEach(track => track.stop());
localStream.value = null;
}
roomState.isJoined = false;
roomState.participants = [];
emit('room-left', props.roomId);
};
// 切换视频
const toggleVideo = () => {
if (localStream.value) {
const videoTrack = localStream.value.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
mediaState.isVideoEnabled = videoTrack.enabled;
}
}
};
// 切换音频
const toggleAudio = () => {
if (localStream.value) {
const audioTrack = localStream.value.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
mediaState.isAudioEnabled = audioTrack.enabled;
}
}
};
// 屏幕共享
const toggleScreenShare = async () => {
if (!mediaState.isScreenSharing) {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always' },
audio: false
});
const screenTrack = screenStream.getVideoTracks()[0];
// 替换所有连接中的视频轨道
peerConnections.forEach(pc => {
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
sender.replaceTrack(screenTrack);
}
});
// 更新本地视频
if (localVideoRef.value) {
localVideoRef.value.srcObject = screenStream;
}
mediaState.isScreenSharing = true;
// 监听停止共享
screenTrack.onended = () => {
stopScreenShare();
};
} catch (error) {
console.error('屏幕共享失败:', error);
}
} else {
stopScreenShare();
}
};
// 停止屏幕共享
const stopScreenShare = () => {
if (localStream.value) {
const videoTrack = localStream.value.getVideoTracks()[0];
// 恢复摄像头轨道
peerConnections.forEach(pc => {
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
sender.replaceTrack(videoTrack);
}
});
// 恢复本地视频
if (localVideoRef.value) {
localVideoRef.value.srcObject = localStream.value;
}
mediaState.isScreenSharing = false;
}
};
// 检测说话状态
const detectSpeaking = (stream) => {
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
analyser.fftSize = 512;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const checkAudio = () => {
analyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
if (average > 30) {
roomState.speakingUser = props.userId;
} else if (roomState.speakingUser === props.userId) {
roomState.speakingUser = null;
}
requestAnimationFrame(checkAudio);
};
checkAudio();
};
// 切换布局
const changeLayout = (mode) => {
layoutMode.value = mode;
};
onMounted(() => {
initSocket();
});
onUnmounted(() => {
leaveRoom();
if (socket.value) {
socket.value.disconnect();
}
});
defineExpose({
joinRoom,
leaveRoom,
toggleVideo,
toggleAudio,
toggleScreenShare,
changeLayout
});
</script>
<template>
<div class="multi-party-conference">
<!-- 未加入房间 -->
<div v-if="!roomState.isJoined" class="join-room">
<h2>加入会议室</h2>
<p>房间ID: {{ roomId }}</p>
<button @click="joinRoom" class="join-btn">加入会议</button>
</div>
<!-- 会议中 -->
<div v-else class="conference-container">
<!-- 视频网格 -->
<div
class="video-grid"
:style="{
gridTemplateColumns: `repeat(${videoLayout.cols}, 1fr)`,
gridTemplateRows: `repeat(${videoLayout.rows}, 1fr)`
}"
>
<!-- 本地视频 -->
<div class="video-item local">
<video
ref="localVideoRef"
autoplay
playsinline
muted
:class="{ hidden: !mediaState.isVideoEnabled }"
></video>
<div v-if="!mediaState.isVideoEnabled" class="video-placeholder">
摄像头已关闭
</div>
<div class="video-label">{{ userId }} (你)</div>
</div>
<!-- 远程视频 -->
<div
v-for="[userId, stream] in remoteStreams"
:key="userId"
class="video-item"
>
<video
:ref="el => { if(el) el.srcObject = stream }"
autoplay
playsinline
></video>
<div class="video-label">{{ userId }}</div>
<div
v-if="roomState.speakingUser === userId"
class="speaking-indicator"
>
🔊
</div>
</div>
</div>
<!-- 控制栏 -->
<div class="control-bar">
<div class="left-controls">
<span class="participant-count">
👥 {{ roomState.participants.length }} 人
</span>
</div>
<div class="center-controls">
<button
@click="toggleVideo"
:class="{ active: mediaState.isVideoEnabled }"
class="control-btn"
>
📹
</button>
<button
@click="toggleAudio"
:class="{ active: mediaState.isAudioEnabled }"
class="control-btn"
>
🎤
</button>
<button
@click="toggleScreenShare"
:class="{ active: mediaState.isScreenSharing }"
class="control-btn"
>
🖥️
</button>
<button @click="leaveRoom" class="control-btn leave">
挂断
</button>
</div>
<div class="right-controls">
<select v-model="layoutMode" class="layout-select">
<option value="grid">网格</option>
<option value="speaker">演讲者</option>
<option value="gallery">画廊</option>
</select>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.multi-party-conference {
width: 100%;
height: 100vh;
background: #1a1a1a;
}
.join-room {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
}
.join-btn {
padding: 12px 32px;
background: #1890ff;
color: white;
border: none;
border-radius: 24px;
font-size: 16px;
cursor: pointer;
margin-top: 20px;
}
.conference-container {
height: 100%;
display: flex;
flex-direction: column;
}
.video-grid {
flex: 1;
display: grid;
gap: 10px;
padding: 10px;
overflow: auto;
}
.video-item {
position: relative;
background: #000;
border-radius: 8px;
overflow: hidden;
min-height: 200px;
}
.video-item video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-item.local video {
transform: scaleX(-1);
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
background: #333;
}
.video-label {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
}
.speaking-indicator {
position: absolute;
top: 10px;
right: 10px;
font-size: 24px;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: rgba(0,0,0,0.8);
}
.left-controls,
.center-controls,
.right-controls {
display: flex;
gap: 10px;
align-items: center;
}
.participant-count {
color: white;
font-size: 14px;
}
.control-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.2);
color: white;
font-size: 20px;
cursor: pointer;
transition: all 0.3s;
}
.control-btn:hover {
background: rgba(255,255,255,0.3);
}
.control-btn.active {
background: #1890ff;
}
.control-btn.leave {
background: #ff4d4f;
}
.layout-select {
padding: 8px 12px;
background: rgba(255,255,255,0.2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.hidden {
display: none;
}
</style>
15.2.3 屏幕共享
技术实现方案
屏幕共享使用getDisplayMedia API,支持共享整个屏幕、窗口或标签页。
完整实现代码
<!-- ScreenShare.vue -->
<script setup>
import { ref, reactive, computed } from 'vue';
const emit = defineEmits(['screen-shared', 'screen-stopped', 'track-replaced']);
const screenStream = ref(null);
const isSharing = ref(false);
const shareOptions = reactive({
video: {
cursor: 'always', // always, motion, never
displaySurface: 'monitor', // monitor, window, application, browser
logicalSurface: true,
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100
},
preferCurrentTab: false
});
const enableAudio = ref(false);
// 开始屏幕共享
const startScreenShare = async (includeAudio = false) => {
try {
const displayMediaOptions = {
video: shareOptions.video,
audio: includeAudio ? shareOptions.audio : false,
preferCurrentTab: shareOptions.preferCurrentTab
};
const stream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
screenStream.value = stream;
isSharing.value = true;
// 监听停止共享事件
stream.getVideoTracks()[0].onended = () => {
stopScreenShare();
};
emit('screen-shared', stream);
return stream;
} catch (error) {
console.error('屏幕共享失败:', error);
throw error;
}
};
// 停止屏幕共享
const stopScreenShare = () => {
if (screenStream.value) {
screenStream.value.getTracks().forEach(track => {
track.stop();
});
screenStream.value = null;
isSharing.value = false;
emit('screen-stopped');
}
};
// 切换音频
const toggleAudio = () => {
if (screenStream.value) {
const audioTrack = screenStream.value.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
enableAudio.value = audioTrack.enabled;
}
}
};
// 替换PeerConnection中的轨道
const replaceTrackInPeerConnection = (peerConnection, newTrack) => {
const senders = peerConnection.getSenders();
const videoSender = senders.find(sender =>
sender.track && sender.track.kind === 'video'
);
if (videoSender) {
videoSender.replaceTrack(newTrack);
emit('track-replaced', newTrack);
}
};
// 获取屏幕帧率
const getFrameRate = () => {
if (screenStream.value) {
const videoTrack = screenStream.value.getVideoTracks()[0];
const settings = videoTrack.getSettings();
return settings.frameRate || 0;
}
return 0;
};
// 获取分辨率
const getResolution = () => {
if (screenStream.value) {
const videoTrack = screenStream.value.getVideoTracks()[0];
const settings = videoTrack.getSettings();
return {
width: settings.width,
height: settings.height
};
}
return { width: 0, height: 0 };
};
// 调整质量
const adjustQuality = async (quality) => {
if (!isSharing.value) return;
const qualities = {
low: { width: 1280, height: 720, frameRate: 15 },
medium: { width: 1920, height: 1080, frameRate: 24 },
high: { width: 1920, height: 1080, frameRate: 30 }
};
const settings = qualities[quality];
if (settings && screenStream.value) {
const videoTrack = screenStream.value.getVideoTracks()[0];
try {
await videoTrack.applyConstraints({
width: { ideal: settings.width },
height: { ideal: settings.height },
frameRate: { ideal: settings.frameRate }
});
} catch (error) {
console.error('调整质量失败:', error);
}
}
};
defineExpose({
startScreenShare,
stopScreenShare,
toggleAudio,
replaceTrackInPeerConnection,
adjustQuality,
getFrameRate,
getResolution,
isSharing,
screenStream
});
</script>
<template>
<div class="screen-share">
<div v-if="!isSharing" class="share-controls">
<button @click="startScreenShare(false)" class="share-btn">
开始屏幕共享
</button>
<button @click="startScreenShare(true)" class="share-btn">
共享屏幕 + 音频
</button>
</div>
<div v-else class="sharing-controls">
<div class="status">
正在共享屏幕
<span class="resolution">
{{ getResolution().width }} x {{ getResolution().height }}
@ {{ getFrameRate() }} fps
</span>
</div>
<div class="quality-controls">
<label>质量:</label>
<button @click="adjustQuality('low')">低</button>
<button @click="adjustQuality('medium')">中</button>
<button @click="adjustQuality('high')">高</button>
</div>
<button @click="toggleAudio" v-if="enableAudio" class="audio-btn">
{{ enableAudio ? '🔊' : '🔇' }} 音频
</button>
<button @click="stopScreenShare" class="stop-btn">
停止共享
</button>
</div>
</div>
</template>
<style scoped>
.screen-share {
padding: 20px;
}
.share-controls,
.sharing-controls {
display: flex;
gap: 10px;
align-items: center;
}
.share-btn,
.stop-btn,
.audio-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.share-btn {
background: #1890ff;
color: white;
}
.stop-btn {
background: #ff4d4f;
color: white;
}
.audio-btn {
background: #52c41a;
color: white;
}
.status {
color: #52c41a;
font-weight: bold;
}
.resolution {
margin-left: 10px;
color: #666;
font-size: 12px;
}
.quality-controls {
display: flex;
gap: 5px;
align-items: center;
}
.quality-controls button {
padding: 5px 10px;
background: #f0f0f0;
border: none;
border-radius: 4px;
cursor: pointer;
}
.quality-controls button:hover {
background: #e0e0e0;
}
</style>
15.2.4 录制与回放
技术实现方案
使用MediaRecorder API录制音视频流,支持实时录制和本地保存。
完整实现代码
<!-- MediaRecorder.vue -->
<script setup>
import { ref, reactive, computed } from 'vue';
const props = defineProps({
stream: {
type: MediaStream,
default: null
},
mimeType: {
type: String,
default: 'video/webm;codecs=vp9'
}
});
const emit = defineEmits(['recording-started', 'recording-stopped', 'data-available']);
const mediaRecorder = ref(null);
const recordedChunks = ref([]);
const isRecording = ref(false);
const isPaused = ref(false);
const recordingDuration = ref(0);
let startTime = 0;
let durationTimer = null;
// 支持的MIME类型
const supportedMimeTypes = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm;codecs=h264',
'video/webm',
'video/mp4'
];
// 获取支持的MIME类型
const getSupportedMimeType = () => {
for (const mimeType of supportedMimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return supportedMimeTypes[0];
};
// 开始录制
const startRecording = (stream = props.stream) => {
if (!stream) {
console.error('没有可用的媒体流');
return;
}
try {
const mimeType = getSupportedMimeType();
const options = {
mimeType: mimeType,
videoBitsPerSecond: 2500000 // 2.5 Mbps
};
mediaRecorder.value = new MediaRecorder(stream, options);
recordedChunks.value = [];
// 数据可用事件
mediaRecorder.value.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
recordedChunks.value.push(event.data);
emit('data-available', event.data);
}
};
// 停止事件
mediaRecorder.value.onstop = () => {
console.log('录制停止');
clearInterval(durationTimer);
};
// 错误处理
mediaRecorder.value.onerror = (event) => {
console.error('录制错误:', event.error);
};
// 开始录制
mediaRecorder.value.start(1000); // 每秒触发一次dataavailable
isRecording.value = true;
startTime = Date.now();
// 更新录制时长
durationTimer = setInterval(() => {
recordingDuration.value = Math.floor((Date.now() - startTime) / 1000);
}, 1000);
emit('recording-started');
} catch (error) {
console.error('开始录制失败:', error);
}
};
// 停止录制
const stopRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop();
isRecording.value = false;
isPaused.value = false;
emit('recording-stopped', recordedChunks.value);
}
};
// 暂停录制
const pauseRecording = () => {
if (mediaRecorder.value && isRecording.value && !isPaused.value) {
mediaRecorder.value.pause();
isPaused.value = true;
clearInterval(durationTimer);
}
};
// 恢复录制
const resumeRecording = () => {
if (mediaRecorder.value && isRecording.value && isPaused.value) {
mediaRecorder.value.resume();
isPaused.value = false;
// 继续计时
const pausedDuration = recordingDuration.value;
startTime = Date.now() - (pausedDuration * 1000);
durationTimer = setInterval(() => {
recordingDuration.value = Math.floor((Date.now() - startTime) / 1000);
}, 1000);
}
};
// 下载录制内容
const downloadRecording = (filename = 'recording.webm') => {
if (recordedChunks.value.length === 0) {
console.error('没有录制内容');
return;
}
const blob = new Blob(recordedChunks.value, {
type: getSupportedMimeType()
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
// 获取录制的Blob
const getRecordingBlob = () => {
if (recordedChunks.value.length === 0) {
return null;
}
return new Blob(recordedChunks.value, {
type: getSupportedMimeType()
});
};
// 获取录制的URL
const getRecordingURL = () => {
const blob = getRecordingBlob();
return blob ? URL.createObjectURL(blob) : null;
};
// 格式化时长
const formatDuration = computed(() => {
const minutes = Math.floor(recordingDuration.value / 60).toString().padStart(2, '0');
const seconds = (recordingDuration.value % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
});
// 清空录制
const clearRecording = () => {
recordedChunks.value = [];
recordingDuration.value = 0;
};
defineExpose({
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
downloadRecording,
getRecordingBlob,
getRecordingURL,
clearRecording,
isRecording,
isPaused,
recordingDuration
});
</script>
<template>
<div class="media-recorder">
<div class="recorder-controls">
<button
v-if="!isRecording"
@click="startRecording"
class="record-btn"
>
⏺ 开始录制
</button>
<template v-else>
<button
v-if="!isPaused"
@click="pauseRecording"
class="pause-btn"
>
⏸ 暂停
</button>
<button
v-else
@click="resumeRecording"
class="resume-btn"
>
▶ 继续
</button>
<button
@click="stopRecording"
class="stop-btn"
>
⏹ 停止
</button>
<span class="duration">{{ formatDuration }}</span>
</template>
<button
v-if="recordedChunks.length > 0 && !isRecording"
@click="downloadRecording"
class="download-btn"
>
⬇ 下载
</button>
<button
v-if="recordedChunks.length > 0 && !isRecording"
@click="clearRecording"
class="clear-btn"
>
🗑 清空
</button>
</div>
<!-- 录制预览 -->
<div v-if="recordedChunks.length > 0 && !isRecording" class="preview">
<h4>录制预览</h4>
<video
:src="getRecordingURL()"
controls
class="preview-video"
></video>
</div>
</div>
</template>
<style scoped>
.media-recorder {
padding: 20px;
}
.recorder-controls {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 20px;
}
.record-btn,
.pause-btn,
.resume-btn,
.stop-btn,
.download-btn,
.clear-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: white;
}
.record-btn {
background: #ff4d4f;
}
.pause-btn {
background: #faad14;
}
.resume-btn {
background: #52c41a;
}
.stop-btn {
background: #1890ff;
}
.download-btn {
background: #722ed1;
}
.clear-btn {
background: #8c8c8c;
}
.duration {
font-size: 18px;
font-weight: bold;
color: #ff4d4f;
font-family: monospace;
}
.preview {
margin-top: 20px;
}
.preview h4 {
margin-bottom: 10px;
}
.preview-video {
width: 100%;
max-width: 800px;
border-radius: 8px;
}
</style>
15.2.5 网络自适应
技术实现方案
监测网络质量指标,动态调整视频编码参数,保证通话流畅性。
完整实现代码
<!-- NetworkAdaptive.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
const props = defineProps({
peerConnection: {
type: RTCPeerConnection,
default: null
},
adaptiveEnabled: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['quality-changed', 'network-warning']);
// 网络统计
const networkStats = reactive({
bitrate: 0,
packetLoss: 0,
rtt: 0,
jitter: 0,
fps: 0,
width: 0,
height: 0
});
// 网络质量等级
const networkQuality = ref('good'); // excellent, good, fair, poor
const currentQuality = ref('high'); // high, medium, low
let statsInterval = null;
let lastBytesReceived = 0;
let lastTimestamp = 0;
// 质量配置
const qualitySettings = {
high: {
width: 1280,
height: 720,
frameRate: 30,
bitrate: 2500000
},
medium: {
width: 640,
height: 480,
frameRate: 24,
bitrate: 1000000
},
low: {
width: 320,
height: 240,
frameRate: 15,
bitrate: 500000
}
};
// 获取网络统计
const getStats = async () => {
if (!props.peerConnection) return;
try {
const stats = await props.peerConnection.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
// 计算码率
if (lastTimestamp) {
const timeDiff = report.timestamp - lastTimestamp;
const bytesDiff = report.bytesReceived - lastBytesReceived;
networkStats.bitrate = Math.floor((bytesDiff * 8) / (timeDiff / 1000));
}
lastBytesReceived = report.bytesReceived;
lastTimestamp = report.timestamp;
// 丢包率
if (report.packetsLost && report.packetsReceived) {
networkStats.packetLoss = (
(report.packetsLost / (report.packetsReceived + report.packetsLost)) * 100
).toFixed(2);
}
// FPS
networkStats.fps = report.framesPerSecond || 0;
// 分辨率
networkStats.width = report.frameWidth || 0;
networkStats.height = report.frameHeight || 0;
// 抖动
networkStats.jitter = (report.jitter * 1000).toFixed(2);
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
// RTT
networkStats.rtt = report.currentRoundTripTime
? (report.currentRoundTripTime * 1000).toFixed(0)
: 0;
}
});
// 评估网络质量
evaluateNetworkQuality();
} catch (error) {
console.error('获取统计数据失败:', error);
}
};
// 评估网络质量
const evaluateNetworkQuality = () => {
const { packetLoss, rtt } = networkStats;
let quality = 'excellent';
if (packetLoss > 10 || rtt > 300) {
quality = 'poor';
} else if (packetLoss > 5 || rtt > 200) {
quality = 'fair';
} else if (packetLoss > 2 || rtt > 100) {
quality = 'good';
}
if (quality !== networkQuality.value) {
networkQuality.value = quality;
if (props.adaptiveEnabled) {
adaptQuality();
}
if (quality === 'poor') {
emit('network-warning', networkStats);
}
}
};
// 自适应调整质量
const adaptQuality = () => {
let targetQuality = currentQuality.value;
if (networkQuality.value === 'poor' && currentQuality.value !== 'low') {
targetQuality = 'low';
} else if (networkQuality.value === 'fair' && currentQuality.value === 'high') {
targetQuality = 'medium';
} else if (networkQuality.value === 'excellent' && currentQuality.value !== 'high') {
targetQuality = 'high';
} else if (networkQuality.value === 'good' && currentQuality.value === 'low') {
targetQuality = 'medium';
}
if (targetQuality !== currentQuality.value) {
setQuality(targetQuality);
}
};
// 设置质量
const setQuality = async (quality) => {
if (!props.peerConnection) return;
const settings = qualitySettings[quality];
if (!settings) return;
try {
const senders = props.peerConnection.getSenders();
const videoSender = senders.find(s => s.track?.kind === 'video');
if (videoSender && videoSender.track) {
// 调整视频轨道约束
await videoSender.track.applyConstraints({
width: { ideal: settings.width },
height: { ideal: settings.height },
frameRate: { ideal: settings.frameRate }
});
// 调整编码参数
const parameters = videoSender.getParameters();
if (parameters.encodings && parameters.encodings.length > 0) {
parameters.encodings[0].maxBitrate = settings.bitrate;
await videoSender.setParameters(parameters);
}
currentQuality.value = quality;
emit('quality-changed', quality, settings);
console.log(`质量已调整为: ${quality}`, settings);
}
} catch (error) {
console.error('调整质量失败:', error);
}
};
// 手动设置质量
const manualSetQuality = (quality) => {
setQuality(quality);
};
// 开始监测
const startMonitoring = (interval = 2000) => {
if (statsInterval) {
clearInterval(statsInterval);
}
statsInterval = setInterval(() => {
getStats();
}, interval);
};
// 停止监测
const stopMonitoring = () => {
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
};
// 获取网络质量描述
const getQualityDescription = (quality) => {
const descriptions = {
excellent: { text: '优秀', color: '#52c41a' },
good: { text: '良好', color: '#1890ff' },
fair: { text: '一般', color: '#faad14' },
poor: { text: '较差', color: '#ff4d4f' }
};
return descriptions[quality] || descriptions.good;
};
onMounted(() => {
if (props.peerConnection) {
startMonitoring();
}
});
onUnmounted(() => {
stopMonitoring();
});
defineExpose({
networkStats,
networkQuality,
currentQuality,
startMonitoring,
stopMonitoring,
manualSetQuality,
getStats
});
</script>
<template>
<div class="network-adaptive">
<!-- 网络状态显示 -->
<div class="network-status">
<div class="status-item">
<span class="label">网络质量:</span>
<span
class="value"
:style="{ color: getQualityDescription(networkQuality).color }"
>
{{ getQualityDescription(networkQuality).text }}
</span>
</div>
<div class="status-item">
<span class="label">当前质量:</span>
<span class="value">{{ currentQuality }}</span>
</div>
<div class="status-item">
<span class="label">码率:</span>
<span class="value">{{ (networkStats.bitrate / 1000).toFixed(0) }} kbps</span>
</div>
<div class="status-item">
<span class="label">丢包率:</span>
<span class="value">{{ networkStats.packetLoss }}%</span>
</div>
<div class="status-item">
<span class="label">延迟:</span>
<span class="value">{{ networkStats.rtt }} ms</span>
</div>
<div class="status-item">
<span class="label">抖动:</span>
<span class="value">{{ networkStats.jitter }} ms</span>
</div>
<div class="status-item">
<span class="label">帧率:</span>
<span class="value">{{ networkStats.fps }} fps</span>
</div>
<div class="status-item">
<span class="label">分辨率:</span>
<span class="value">{{ networkStats.width }} x {{ networkStats.height }}</span>
</div>
</div>
<!-- 手动质量控制 -->
<div class="quality-controls">
<span class="label">手动设置质量:</span>
<button
@click="manualSetQuality('low')"
:class="{ active: currentQuality === 'low' }"
class="quality-btn"
>
低
</button>
<button
@click="manualSetQuality('medium')"
:class="{ active: currentQuality === 'medium' }"
class="quality-btn"
>
中
</button>
<button
@click="manualSetQuality('high')"
:class="{ active: currentQuality === 'high' }"
class="quality-btn"
>
高
</button>
</div>
</div>
</template>
<style scoped>
.network-adaptive {
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
}
.network-status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 10px;
background: white;
border-radius: 4px;
}
.status-item .label {
color: #666;
font-size: 14px;
}
.status-item .value {
font-weight: bold;
font-size: 14px;
}
.quality-controls {
display: flex;
gap: 10px;
align-items: center;
padding: 15px;
background: white;
border-radius: 4px;
}
.quality-controls .label {
color: #666;
font-size: 14px;
}
.quality-btn {
padding: 8px 20px;
border: 1px solid #d9d9d9;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.quality-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.quality-btn.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
</style>
简历描述模板
项目经验描述
WebRTC音视频通话系统 - 核心开发 时间: 2023.08 - 2024.03 技术栈: Vue3、WebRTC、Socket.io、MediaRecorder API
主要职责:
- 开发1v1视频通话功能,实现完整的呼叫、接听、挂断流程,日均通话量1000+次
- 设计多人会议室架构,使用Mesh拓扑支持8人以下会议,平均连接成功率95%
- 实现屏幕共享功能,支持全屏、窗口、标签页三种模式,配置动态码率调整
- 开发实时录制模块,使用MediaRecorder API实现通话录制和本地保存
- 设计网络自适应算法,根据丢包率和延迟动态调整视频质量,卡顿率降低50%
技术亮点
- 实现呼叫状态机管理,处理呼叫、振铃、接听、挂断等复杂状态转换
- 优化多人会议的连接建立流程,使用并行Offer/Answer机制缩短入会时间
- 开发音频可视化组件,实时显示说话者,提升用户体验
- 实现断线重连机制,网络波动时自动恢复连接,用户无感知
SOP标准回答
Q1: 1v1通话和多人会议有什么区别?你们是怎么实现的?
回答思路: 1v1和多人会议的主要区别在于连接拓扑和资源消耗。
1v1通话很简单,就是两个人之间建立一个PeerConnection。呼叫流程是:发起方创建Offer,通过信令服务器转发给接收方,接收方返回Answer,然后交换ICE候选完成连接。我重点处理了各种边界情况,比如对方不在线、拒绝接听、通话中断线等。
多人会议我们用的是Mesh架构,每个人和其他所有人都建立P2P连接。比如4个人的会议,每个人要维护3个PeerConnection。这种方案的优点是延迟低,不需要服务器转发媒体流。缺点是当人数增加时,客户端带宽和CPU压力会很大,所以我们限制了8人以下。
入会流程是这样的:新用户加入房间后,信令服务器返回已有用户列表,新用户主动向每个已有用户发起Offer。已有用户收到Offer后返回Answer,这样新用户就和所有人建立了连接。
技术细节: 多人会议最大的挑战是连接建立的并发控制。如果8个人同时入会,会产生大量的Offer/Answer交换。我做了并行处理优化,不用等一个连接建立完再建下一个,而是同时发起多个连接请求。另外还加了连接状态管理,如果某个用户的连接失败,不影响其他用户。
Q2: 屏幕共享是怎么实现的?有什么技术难点?
回答思路: 屏幕共享用的是getDisplayMedia API,这个和getUserMedia类似,但是获取的是屏幕内容而不是摄像头。
实现上,用户点击共享按钮后,浏览器会弹出选择界面,让用户选择要共享整个屏幕、某个窗口还是某个标签页。获取到屏幕流后,我会替换PeerConnection中的视频轨道,用replaceTrack方法把摄像头轨道换成屏幕轨道。这样可以无缝切换,不用重新协商。
难点主要有几个:
- 轨道替换的时机,要确保新轨道ready后再替换,否则会出现黑屏
- 停止共享的处理,屏幕流有个onended事件,用户点系统的停止共享按钮时会触发,我要监听这个事件自动恢复摄像头
- 质量控制,屏幕共享通常需要更高的分辨率但更低的帧率,我做了专门的编码参数配置
- 音频共享,有些场景需要共享系统音频,这个要在getDisplayMedia时设置audio参数
优化经验: 我还做了一个优化,就是在共享屏幕时自动降低帧率到15fps,因为屏幕内容变化没有视频那么频繁,这样能节省带宽。如果检测到用户在播放视频(通过帧率变化判断),再自动提升到30fps。
Q3: 网络自适应是怎么做的?如何保证弱网下的通话质量?
回答思路: 网络自适应是WebRTC中非常关键的技术,直接影响用户体验。
我的实现思路是:定时获取网络统计数据,根据这些数据评估网络质量,然后动态调整视频参数。
具体指标包括:
- 丢包率: 超过5%就算网络差
- RTT延迟: 超过200ms算高延迟
- 码率: 实际码率远低于目标码率说明网络拥塞
- 帧率: 低于目标帧率说明编解码有压力
我设计了三个质量档位:高、中、低,对应不同的分辨率、帧率和码率组合。网络好的时候用720p 30fps,网络一般用480p 24fps,网络差就降到240p 15fps。
调整的时候不能太频繁,我加了防抖逻辑,网络质量持续3个周期都差才降级,持续5个周期都好才升级,避免频繁切换。
实际效果: 上线前,弱网环境下的卡顿率是15%左右,用户投诉很多。加了自适应后,卡顿率降到3%以下。虽然画质会下降,但至少通话是流畅的,用户反馈好很多。
我还加了一个手动控制选项,让用户可以强制设置质量,满足不同用户的需求。
难点与亮点分析
难点1: 多人会议的连接管理
问题背景: Mesh架构下,每个参与者需要与其他所有人建立连接。连接数随人数平方级增长,管理复杂度很高。
解决方案
- 连接池管理: 使用Map存储userId到PeerConnection的映射,方便查找和管理
- 生命周期管理: 统一管理连接的创建、销毁、重连逻辑
- 错误隔离: 某个连接失败不影响其他连接
- 资源清理: 用户离开时及时清理相关连接和流
代码示例
// 连接管理器
class ConnectionManager {
constructor() {
this.connections = new Map();
}
create(userId) {
const pc = new RTCPeerConnection(config);
this.connections.set(userId, pc);
return pc;
}
remove(userId) {
const pc = this.connections.get(userId);
if (pc) {
pc.close();
this.connections.delete(userId);
}
}
removeAll() {
this.connections.forEach(pc => pc.close());
this.connections.clear();
}
}
难点2: 录制的内存管理
问题背景: 长时间录制会产生大量数据块,如果都保存在内存中,会导致内存溢出。
解决方案
- 分片存储: MediaRecorder每秒触发一次dataavailable,及时处理数据块
- 增量写入: 可以将数据块增量写入IndexedDB,而不是全部保存在内存
- 限制录制时长: 设置最大录制时长,超过后自动停止
- 内存监控: 监控内存使用,接近限制时提示用户
实际应用: 我们限制单次录制最长30分钟,超过后自动分段。同时提供暂停功能,让用户可以分段录制。
难点3: 网络抖动的处理
问题背景: 网络质量不是恒定的,会出现短暂的波动。如果对每次波动都做出反应,会导致频繁切换质量,反而影响体验。
解决方案
- 滑动窗口: 使用最近N个周期的平均值,而不是单次测量值
- 迟滞控制: 降级和升级使用不同的阈值,形成迟滞带
- 平滑过渡: 质量切换时逐步调整,而不是突变
- 用户感知: 降级立即执行,升级延迟执行
算法伪代码
// 降级: 连续3个周期差就降
if (质量差的周期数 >= 3) {
降级();
}
// 升级: 连续5个周期好才升
if (质量好的周期数 >= 5) {
升级();
}
亮点1: 智能说话者检测
实现思路: 使用Web Audio API分析音频流,实时计算音量。当某个参与者的音量持续高于阈值,就认为他在说话。
应用场景
- 会议中高亮显示当前说话者
- 自动切换到演讲者模式
- 录制时标记说话时间点
代码示例
function detectSpeaker(stream) {
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
function check() {
analyser.getByteFrequencyData(dataArray);
const volume = dataArray.reduce((a, b) => a + b) / dataArray.length;
if (volume > 30) {
// 正在说话
highlightSpeaker();
}
requestAnimationFrame(check);
}
check();
}
亮点2: 断线自动重连
实现思路: 监听PeerConnection的connectionState,当状态变为failed或disconnected时,自动尝试重连。
重连策略
- 先尝试ICE重启(不重新协商)
- 失败后重新创建PeerConnection
- 使用指数退避,最多重试3次
- 重连期间显示"正在重连"提示
用户体验: 短暂的网络波动用户几乎感知不到,连接会自动恢复。
真实项目经验分享
项目背景
我们公司做在线医疗,需要支持医生和患者远程视频问诊。除了基本的音视频通话,还要支持屏幕共享(医生展示检查报告)、录制(留存病历)等功能。
技术选型
最开始用的腾讯云TRTC,但发现成本太高,一个月要好几万。我们评估后决定自研,用WebRTC实现。虽然开发成本高,但能省下很多钱,而且可以深度定制功能。
遇到的挑战
挑战1: 多人会诊的性能问题 我们有个多人会诊场景,主治医生、专家、患者等可能同时在线。一开始用Mesh架构,发现超过5个人就卡得不行,因为每个人要同时发送多路视频流。
后来我们做了优化,主要看的人(患者)用全分辨率,其他人用低分辨率。还实现了自动焦点切换,谁说话就自动放大谁的画面。这样优化后,8个人的会诊也能流畅运行。
挑战2: 移动端的兼容性 移动端的坑特别多。iOS Safari对WebRTC支持很有限,有很多奇怪的bug。比如必须在用户手势事件中才能播放视频,否则会被阻止。还有锁屏后连接会断开,需要做特殊处理。
我们专门针对移动端做了适配:
- 检测浏览器类型,对Safari做特殊处理
- 降低移动端的默认分辨率
- 监听页面可见性,后台时降低帧率
- 加了"开启声音"按钮,解决iOS自动播放限制
挑战3: 弱网环境的处理 很多患者在农村,网络条件差。一开始经常断线或者卡成PPT。
我们做了很多优化:
- 加入网络质量检测,开始通话前测试网络
- 实现自适应码率,网络差就自动降低画质
- 增加丢包重传机制
- 提供纯音频模式,网络实在太差就只传音频
上线效果
经过3个月的开发和优化,系统终于稳定上线。现在每天有500+次视频问诊,连接成功率95%,用户满意度4.7/5.0。虽然过程很艰难,但还是很有成就感的。
这个项目让我对WebRTC有了非常深入的理解,不仅是API的使用,还包括底层原理、性能优化、异常处理等。现在遇到实时通信的问题,基本都能快速定位和解决。
文档说明
- 本文档提供音视频通话的完整实现方案
- 涵盖1v1通话、多人会议、屏幕共享、录制、网络自适应五大功能
- 所有代码均为可运行的实际代码
- 包含详细的面试回答和真实项目经验
- 适合中高级前端工程师学习和使用