WebRTC音视频通话Demo实战:信令、ICE穿透与媒体协商全解析
简介:针对Android平台的WebRTC音视频通话示例工程,面向需要快速实现P2P音视频通话功能或希望深入理解WebRTC技术栈的开发者。demo界面参考微信通话样式,覆盖视频预览、接听/挂断、静音、摄像头切换等常见通话场景,可作为二次开发的基础框架。压缩包共1460个文件,约94.92MB,以xml布局、java源码、class字节码、dex文件、so原生库、json配置为主,其中so库封装了WebRTC底层能力,java与xml对应业务逻辑和界面,适合在Android Studio中直接导入学习。资源已受到2040人关注,说明具备一定的参考价值。工程完整包含PeerConnectionFactory初始化、getUserMedia调用、RTCPeerConnection建立、SDP与ICE交换、信令处理等关键环节,并提供了可运行的APK和Gradle配置,便于对照源码理解音视频通话的完整流程,也可根据业务需要改造适配。
1. 从零搭一个WebRTC音视频通话Demo,到底要搞定哪些事?
先说结论:WebRTC这套东西,第一次跑通demo的时候你会觉得“怎么就这么点代码”,但实际上能跑通的背后,每一步都是坑。我见过很多人卡在“摄像头开了但对方看不到画面”“本机能通但换了两台电脑就不行”这种问题上,绝大多数原因不是API用错了,而是对WebRTC的工作机制没有一个完整的认识。
这篇文章我会从我自己搭一个WebRTC音视频通话Demo的完整过程出发,把核心概念、信令设计、媒体协商、ICE穿透、推流拉流这些环节全部拆开讲清楚。文章里给出的代码都是可以直接跑的级别,省略掉的部分我会明确告诉你需要补什么。适合刚接触WebRTC的客户端开发、前端工程师,以及想快速验证一套音视频方案的团队参考。
先理清一个基本认知:WebRTC不是“一个库”,它是一整套浏览器内置的实时通信能力。它解决的最核心问题,是在两个浏览器(或App)之间建立一条低延迟的音视频传输通道。注意,这里的重点是“建立通道”,至于你要传的是视频聊天、屏幕共享还是白板数据,那是上层应用自己决定的。
所以你要做的Demo,本质上就三件事: 采集媒体流、交换连接信息、传输音视频数据 。而这三件事里,只有第一件是浏览器帮你做好的,后面两件都需要你在应用层自己实现一部分逻辑。
2. Demo整体架构设计与方案选型
2.1 为什么需要一个信令服务器,它到底干什么用
很多人第一次写WebRTC demo时最大的疑惑是:为什么不能直接A浏览器调用B浏览器?原因很简单——WebRTC需要知道“对方在哪里”,但在连接建立之前,两个浏览器之间并没有任何已知的通路。有人会说“那我知道了对方IP,直接连不就行了”,问题在于互联网上绝大多数设备都在NAT后面,你拿到的是内网地址,光知道这个地址没有任何意义。
所以WebRTC引入了信令(Signaling)的概念。信令服务器的职责,就是在两个端之间传递三类信息:
- 会话描述协议(SDP) :包含媒体格式、编解码器、网络信息等参数的声明
- ICE候选(ICE Candidate) :每个端能使用的网络路径候选,包括内网IP、公网IP映射、中继地址等
- 会话控制消息 :比如“我加入房间了”“我挂断了”这类状态消息
信令的具体内容、格式、传递方式,WebRTC规范完全不管。你可以用WebSocket、可以用Socket.IO、可以用MQTT,甚至用HTTP轮询都行。WebRTC只定义了SDP和ICE的格式,传递过程是应用层的事。
我之前见过有人把信令服务器和媒体服务器混为一谈,这是两个完全不同的东西。信令服务器只传控制消息,不碰音视频数据;媒体服务器才是负责转发或混合音视频流的。一个纯P2P的WebRTC demo,完全可以只有信令服务器,不需要任何媒体服务器。
2.2 我为什么选这个技术组合
WebRTC的API在现代浏览器里基本都原生支持,所以客户端的Demo我用的是纯JavaScript加HTML,不引入任何框架。如果一开始就用React或Vue,很容易把问题复杂化——你分不清一个报错到底是WebRTC的问题还是框架生命周期的问题。
信令服务器我选了Node.js加Socket.IO。原因有三点:
- Socket.IO天然支持房间(Room)概念,方便做多人联调的扩展
- 事件驱动的模型和WebRTC的信令模型非常匹配
- 即使你不熟悉Node.js,照着本文的代码也能在十分钟内跑起来
如果你是想在移动端或者桌面端做Demo,只要理解了本文的原理,换成Android的Java/Kotlin或者iOS的Swift只是API形态不同,信令交互流程完全一致。
2.3 一次完整通话的建立流程,先看懂整体时序
在动手写代码之前,你必须先在心里有一张完整的时序图,否则你会不知道你写的每行代码在干什么。一次标准的WebRTC通话建立流程是这样的:
- A和B都连接到信令服务器,加入同一个房间
- A调用getUserMedia采集本地音视频,拿到MediaStream
- A创建RTCPeerConnection,把本地MediaStream的轨道(Track)添加进去
- A创建Offer(一个SDP描述),调用setLocalDescription设置到本地PeerConnection
- A通过信令服务器把Offer发给B
- B收到Offer,做三件事:设置远端描述(setRemoteDescription)、创建Answer、setLocalDescription
- B把Answer通过信令服务器发回给A
- 双方在setLocalDescription之后,浏览器会自动开始ICE收集,通过onicecandidate回调把候选信息发给对方
- 双方不断交换ICE候选,直到找到一条可用的传输路径
- 连接建立,媒体流开始传输。onaddstream或ontrack回调触发,把对方的流绑定到video标签上
这十步里,第4和第6步是最容易写错的,很多人会把setLocalDescription和setRemoteDescription的方法调用顺序搞混。记住一个口诀: 先set,再send;先receive,再set 。你本地的描述要先设置到自己的PeerConnection上,然后才发送给对方;你收到对方的描述,要先设置到自己的PeerConnection上,然后才回复。
3. 核心代码逐段拆解:从采集到播放
3.1 媒体采集:getUserMedia的参数选择有讲究
音视频通话的第一步是采集,这个接口看起来简单,但参数选择直接影响后面的效果和兼容性。我的Demo里用的是这样一组参数:
const constraints = {
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30, max: 30 }
}
};
const localStream = await navigator.mediaDevices.getUserMedia(constraints);
注意几个细节。音频的三个参数——回声消除、降噪、自动增益——我建议在采集时就显式打开,不要依赖浏览器的默认值。不同浏览器对默认值的处理差异很大,比如Chrome默认开启回声消除,但Safari在某些版本上如果没显式指定,回声问题会非常明显。
视频这边,用ideal而不是exact。exact表示强制要求,如果摄像头不支持这个分辨率会直接报OverconstrainedError;ideal表示首选,浏览器会根据实际硬件能力自动选择一个最接近的分辨率。对于Demo来说,一定用ideal,兼容性会好很多。
还有一点要注意,本地预览的video标签一定要设muted属性,否则你会听到自己说话的回声。这不是WebRTC的问题,是音频反馈导致的,但几乎每个第一次写WebRTC的人都会踩到这个。
3.2 创建PeerConnection:STUN服务器的必要性
RTCPeerConnection的配置是全网问得最多的问题之一,核心都集中在ICE服务器配置上:
const pcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'your-username',
credential: 'your-credential'
}
]
};
const pc = new RTCPeerConnection(pcConfig);
先说STUN。它的作用是帮你的设备发现自己经过NAT映射之后的公网地址。打个比方,你在一间大房子的某个房间里,STUN服务器就像是门口的对讲机,它能告诉你“从外面看你家的门牌号是多少”,但它不会帮你转接任何电话。
Demo阶段直接用谷歌的公共STUN服务器就好, stun:stun.l.google.com:19302 ,这个地址稳定运行了很多年。不过要注意,公共STUN服务器只适合开发和测试,生产环境一定要自建或者购买商业服务,否则一是不稳定,二是你的用户量大了会给别人造成负担。
再说TURN。它的作用是当P2P直连失败时,用一台服务器做媒体数据的中继转发。TURN服务器配置里需要username和credential,通常由TURN服务商提供或你自建时自己设置。在没有TURN服务器的情况下,如果两端的网络环境恰好无法建立直连,你的Demo就会卡死在“连接中”状态。很多人在公司网络环境或两个不同运营商网络之间测试时发现连不通,基本都是因为缺了TURN。
3.3 媒体协商与ICE处理:Demo里最核心的一段代码
媒体协商是整个WebRTC建立连接的灵魂。我给出一个完整的呼叫方(Offer端)核心代码:
// 呼叫方:创建Offer并发送
async function makeCall(targetUserId) {
// 添加本地音视频轨道到PeerConnection
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 监听ICE候选并发送给对方
pc.onicecandidate = (event) => {
if (event.candidate) {
signalingSocket.emit('candidate', {
target: targetUserId,
candidate: event.candidate
});
}
};
// 监听对方媒体流,绑定到video标签
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
// 创建Offer并设置到本地
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令服务器发送Offer
signalingSocket.emit('offer', {
target: targetUserId,
sdp: pc.localDescription
});
}
接听方的核心代码有一点需要特别注意:
// 接听方:收到Offer后创建Answer
signalingSocket.on('offer', async (data) => {
// 先把收到的SDP设置到远端描述
await pc.setRemoteDescription(data.sdp);
// 添加本地音视频轨道(注意:接听方也要添加)
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 创建Answer并设置到本地
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 发送Answer给呼叫方
signalingSocket.emit('answer', {
target: data.from,
sdp: pc.localDescription
});
});
我再强调一下这里的顺序问题。很多人在接听方这边会把addTrack放在setRemoteDescription之前,这在现代浏览器上通常也能工作,但不符合规范推荐的顺序。规范要求的是:先设置远端描述,再添加轨道,再创建Answer。因为createAnswer需要知道对端支持哪些编解码器,这个信息是从远端描述里来的。轨道添加的顺序影响的是媒体协商时本地是否有可用的媒体流。
ICE候选的处理相对简单,但有一个很容易忽视的细节—— ICE候选可能在Offer/Answer交换完成之前就已经到达对方 。所以你在处理candidate事件时,要先判断remoteDescription是否已经设置:
signalingSocket.on('candidate', async (data) => {
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(data.candidate);
} else {
// 缓存起来,等setRemoteDescription之后再添加
pendingCandidates.push(data.candidate);
}
});
如果你不做这个判断,很可能会遇到 Failed to set remote answer sdp 或 Error adding ICE candidate 这类报错。这类问题排查起来比较费劲,因为报错信息并不会直接告诉你“你收到ICE候选时远端描述还没设置”。我在下面排查章节还会再讲这个。
3.4 视频渲染与连接状态监听
视频画面的渲染是我觉得很多人会忽略的一个环节。你只需要两行代码:
remoteVideo.srcObject = event.streams[0];
remoteVideo.play();
但要注意的是, ontrack 回调可能在连接建立过程中触发多次,因为音频轨道和视频轨道是分别到达的。在某些浏览器上,你会发现这个回调先触发一次拿到音频流,再触发一次拿到视频流,如果你在回调里直接赋值srcObject,会把后到的视频流覆盖掉前面已设置的流。稳妥的做法是维护一个MediaStream实例,把到达的轨道用addTrack加进去:
const remoteStream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0].getTracks().forEach(track => {
remoteStream.addTrack(track);
});
remoteVideo.srcObject = remoteStream;
};
另外强烈建议在Demo里加上连接状态的监听,这个对调试帮助极大:
pc.onconnectionstatechange = () => {
console.log('连接状态:', pc.connectionState);
if (pc.connectionState === 'failed') {
// 触发重连逻辑或提示用户
}
};
pc.oniceconnectionstatechange = () => {
console.log('ICE状态:', pc.iceConnectionState);
};
生产级的代码里,这些状态的变化应该和UI做完整联动,但在Demo里,你至少要把它们输出到控制台。因为WebRTC的很多问题都是“看起来没反应,实际上某个中间状态出了问题”,有了这些日志,你就能定位问题发生在哪个环节。
4. 信令服务器:用一个Node.js服务撑起整个Demo
4.1 Socket.IO信令服务的核心实现
信令服务器的代码其实很短,因为它的职责就是“转发”和“通知”。下面是我的Demo里使用的最简版本:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
const rooms = new Map();
io.on('connection', (socket) => {
console.log('用户已连接:', socket.id);
// 加入房间
socket.on('join', ({ roomId }, callback) => {
const clients = rooms.get(roomId) || [];
if (clients.length >= 2) {
callback({ code: 1, msg: '房间已满' });
return;
}
socket.join(roomId);
if (!rooms.has(roomId)) {
rooms.set(roomId, []);
}
rooms.get(roomId).push(socket.id);
socket.roomId = roomId;
// 通知房间内其他用户
socket.to(roomId).emit('peer-joined', { peerId: socket.id });
callback({ code: 0, peerId: socket.id });
});
// 转发Offer/Answer/ICE候选
socket.on('offer', ({ target, sdp }) => {
socket.to(target).emit('offer', { from: socket.id, sdp });
});
socket.on('answer', ({ target, sdp }) => {
socket.to(target).emit('answer', { from: socket.id, sdp });
});
socket.on('candidate', ({ target, candidate }) => {
socket.to(target).emit('candidate', { from: socket.id, candidate });
});
// 挂断与断开
socket.on('hangup', ({ target }) => {
socket.to(target).emit('peer-hangup', { from: socket.id });
});
socket.on('disconnect', () => {
if (socket.roomId) {
socket.to(socket.roomId).emit('peer-left', { peerId: socket.id });
const clients = rooms.get(socket.roomId);
if (clients) {
const idx = clients.indexOf(socket.id);
if (idx > -1) clients.splice(idx, 1);
}
}
});
});
server.listen(3000, () => {
console.log('信令服务器运行在:http://localhost:3000');
});
这段代码里我用Map来存储每个房间的用户列表,逻辑上相当于一个极简的“聊天室”。注意我限制每个房间最多两个用户,因为这是1对1通话Demo。如果你想扩展成多人会议,就需要换成网格架构(Mesh),让每个端都和另外几个端分别建立PeerConnection,复杂度会指数级上升。
4.2 为什么Socket.IO这类库适合做信令而不是传媒体
这里我想强调一个核心认知。很多人刚开始接触WebRTC时会有一个疑问:既然Socket.IO能传数据,为什么不用它直接把音视频数据传过去?答案是 性能和延迟 。Socket.IO的数据传输走的是WebSocket或HTTP长轮询,每一帧都包含大量协议头开销,而且经过服务器中转,延迟比P2P的UDP传输高一个数量级。音视频通话对延迟极其敏感,300毫秒以上的延迟人就能明显感觉到不自然,所以媒体数据必须走专用的RTCPeerConnection通道,信令服务器只能做“牵线搭桥”的工作。
我见过一些半懂不懂的方案,用WebSocket直接传base64编码的音视频帧,那延迟和带宽消耗是灾难级别的。WebRTC的设计哲学就是信令与媒体分离,各走各的道。
4.3 公网部署时的注意点
Test阶段在本地跑,信令服务器监听localhost就够了。但如果你想让两台不同网络环境下的电脑通话(多数情况下这才是你测试的目的),你需要把信令服务器部署到一台公网服务器上,并把前端的WebSocket连接地址改成服务器的地址。
这个环节有三个常见的坑:
- 部署环境有没有放行端口 :很多云服务器默认只开放80/443,如果你的Node服务跑在3000端口,需要在防火墙和安全组里放行。
- HTTPS/WSS问题 :getUserMedia接口在非安全上下文(也就是HTTP)下是不可用的,Chrome会直接报错。所以如果信令服务器是用HTTP跑的,前端页面也得通过HTTP访问,而用户摄像头可能无法调用。稳妥方案是给域名配一个HTTPS证书,或者用内网穿透工具把本地服务暴露成HTTPS地址。
- ws和wss的跨域问题 :Socket.IO默认允许跨域,但如果你自己配置了CORS策略,需要注意跨域时的握手方式要匹配。
5. 推流与拉流:理解Demo背后的数据通道机制
5.1 基于WebRTC的“推流”到底是什么意思
在WebRTC的语境下,推流和拉流这两个词和传统RTMP/HTTP-FLV直播里的含义是有区别的。传统直播中,推流是主播端把媒体数据上传到服务器,拉流是观众端从服务器获取数据。但在WebRTC的P2P架构里,没有中心化的服务器来存储和分发媒体流,所以推流拉流更多是指 谁主动发起媒体数据的传输 。
在本文的1对1Demo里,通话双方既在推流也在拉流——你把本地采集的媒体流通过PeerConnection推送出去,同时也从PeerConnection接收对端的媒体流。用WebRTC的行话来说,这是双向的“推拉流一体”。
如果你想做的是类似直播场景的“单向推流”,比如一个主播端把画面推给多个观众端,实际上WebRTC的网格架构也能做,只是N个观众就需要N个PeerConnection,对主播端的上行带宽要求很高。这也是为什么正规的WebRTC直播方案通常会引入SFU(选择性转发单元)服务器,由SFU接收主播的一路流,然后按需转发给观众。SFU属于媒体服务器范畴,纯P2P的Demo用不到,但如果你将来要扩展,这个概念必须知道。
5.2 数据通道:除了音视频,你还能传什么
WebRTC除了音视频轨道,还提供了一个DataChannel,可以在这个对等连接上直接传输任意数据——文本消息、文件分片、游戏状态同步等等。DataChannel在Demo里虽然不必须,但我强烈建议你顺手加一个,因为它是验证“连接已经真正建立”的最直观方式。
// 创建DataChannel(呼叫方)
const dataChannel = pc.createDataChannel('chat', {
reliable: true
});
dataChannel.onopen = () => {
console.log('数据通道已打开');
};
dataChannel.onmessage = (event) => {
console.log('收到消息:', event.data);
};
// 发送消息的示例
function sendMessage(msg) {
if (dataChannel && dataChannel.readyState === 'open') {
dataChannel.send(msg);
}
}
注意DataChannel必须在发起方调用createOffer之前创建。如果发起方在createOffer之后才创建,这个通道可能不会被加入到已协商的SDP中,导致对端无法识别这个通道。接听方需要监听ondatachannel事件来获取这个通道:
pc.ondatachannel = (event) => {
const channel = event.channel;
channel.onopen = () => { /* ... */ };
channel.onmessage = (event) => { /* ... */ };
};
6. 弱网卡顿:Demo跑通之后必须面对的问题
6.1 你的Demo能跑通,不代表真正能用于生产
这是我最想强调的一点。一个Demo在局域网内或者网络状况良好的情况下跑通,是WebRTC开发中最不值得高兴的事。因为WebRTC真正难的地方——或者说真正体现技术含量的地方——是弱网环境下的表现。这不仅体现在实际通话质量上,也是面试时被问到的高频题。
弱网导致的卡顿主要有两种表现:
- 网络抖动(Jitter) :网络延迟出现剧烈波动,导致到达的数据包间隔不均匀。视频表现为画面一顿一顿,但不是持续性的。
- 丢包(Packet Loss) :数据包在传输过程中丢失,导致画面出现马赛克、花屏或者声音断续。WebRTC有丢包重传机制,但如果丢包率过高,重传也救不回来。
6.2 WebRTC内置了哪些弱网对抗机制
WebRTC在底层已经做了很多优化,理解这些有助于你判断问题出在哪一层:
- 拥塞控制 :WebRTC使用GCC(Google Congestion Control)算法,基于延迟和丢包率来动态调整发送码率。当网络变差时,它会主动降低视频分辨率或帧率。
- 丢包重传(NACK) :接收端发现丢了某个包,会发送NACK请求重传。但对于实时性要求高的音视频来说,重传有个时间窗,如果视频帧来不及在播放前到达,就只能丢弃。
- 前向纠错(FEC) :发送端在原始数据之外追加冗余包,接收端即使丢了一部分包也能通过冗余恢复出原始数据。代价是额外消耗带宽。
- Jitter Buffer :接收端有一个缓冲区,会暂存收到的数据包,等到足够的数据后再均匀地播放。缓冲区越大,抗抖动能力越强,但延迟也会越大。这是WebRTC内部自动管理的,上层无法直接配置。
这些机制在Demo里你不需要也不容易直接操作,但你可以通过浏览器的 getStats() 接口拿到详细指标,来判断你的网络状况下WebRTC做了什么调整:
setInterval(async () => {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
console.log('收到码率:', report.bytesReceived / 1024, 'KB/s');
console.log('丢包数:', report.packetsLost);
console.log('抖动:', report.jitter, 's');
}
});
}, 3000);
在我的实践经验里,如果丢包率超过2%,用户就能感觉到画质变差;超过5%,卡顿就会很明显。如果你在自己的Demo测试中发现卡顿,第一步就是先确认丢包率和码率变化趋势,再去考虑代码层面的优化。盲目改代码解决不了物理网络的限制。
7. 常见问题排查:我把踩过的坑都整理在这里
我整理了一份WebRTC Demo阶段最常遇到的问题速查表,都是我自己实际调试中遇到过的,具有很高的参考价值:
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| 摄像头打不开,控制台报NotFoundError | 页面在HTTP环境下运行,getUserMedia被浏览器禁用 | 改用HTTPS访问页面,或localhost本地访问 |
| 本地画面正常,远端黑屏 | 媒体协商成功但媒体流没有被正确绑定 | 检查ontrack回调里是否在多次触发时覆盖了srcObject,改用addTrack方式 |
| 连接状态一直new/checking | STUN/TURN配置缺失,或网络环境禁止UDP | 添加STUN服务器;跨网络测试时添加TURN服务器 |
| 报错Failed to set remote answer sdp | 对端描述设置顺序错误 | 确保先setRemoteDescription再createAnswer |
| Firefox/Chrome互通失败 | 不同浏览器的编解码器偏好不同 | 检查SDP中是否有共同的编解码器,必要时用offerToReceiveAudio/Video参数 |
| 通话有回声 | 本地预览video标签未设置muted | 给本地预览video加muted属性 |
| 报错Error adding ICE candidate | 收到candidate时remoteDescription还未设置 | 在setRemoteDescription之后添加ICE候选,或缓存待处理 |
| 能听到对方声音但画面卡死 | 视频帧率过高,网络带宽不足 | 调整getUserMedia的帧率约束,或依赖WebRTC自身的码率自适应 |
| 长时间后连接自动断开 | 网络切换导致IP变化,或NAT映射过期 | 监听iceconnectionstatechange实现重连机制 |
最后一个额外提醒:如果你在浏览器控制台看到类似 Failed to execute 'addIceCandidate' on 'RTCPeerConnection' 的报错,不要直接去查这个API的使用方法,先检查你自己代码里remoteDescription的设置时机。这是排查过最多人问的问题,百分之八十都是时序搞错了。
8. 跑通Demo之后,下一步怎么走
最后说点我自己的亲身体会。很多人跑通一个WebRTC Demo之后会有一种“就这么简单?”的错觉,然后直接开始写业务代码,结果遇到各种实际环境的问题又回来重新补基础。
我建议的路子是:Demo跑通之后,先做两件小事来加深理解。第一,用浏览器的开发者工具或者独立的WebRTC调试工具,把SDP打开看一下,里面每一行代表的是什么意思,最好搞清楚a=fmtp、a=ice-ufrag、a=fingerprint这些关键字段的作用。第二,把网络环境切换到不同的网络(比如手机热点、公司网络、跨运营商网络)反复测试,观察ICE候选的变化和连接建立的成功率。这两件事做完,你对WebRTC的理解会比看十篇教程都有用。
再往下走,如果你想把这个Demo做成真正可用的产品,需要考虑的事情就多了:TURN服务器的自建与维护、弱网下的码率策略调整、通话质量的监控上报、多人会议的整体架构选型(Mesh、SFU还是MCU)、移动端的适配与后台保活等等。但这些都是后话,先把Demo彻底跑通吃透,路才能走稳。
更多推荐

所有评论(0)