☰
WebRTC点对点视频通话实战:自建信令与TURN中继服务器
2026/10/20 3:11:31 网站建设 项目流程

1. 项目缘起与整体架构拆解

实时音视频通话在当下的应用场景里已经非常普遍,从在线协作、远程面试到在线教育、远程协助,几乎每个需要“面对面”沟通的场合都离不开它。但真正自己动手从零搭一套能跑通的点对点视频通话系统,很多人第一反应是“这玩意儿门槛太高”——要处理音视频采集、编解码、网络穿透、信令协商、中继转发,还要同时兼顾网页端和移动端。我当初动手做这个 Demo 的出发点很简单:把整条链路拆开,用最小的代码量把 WebRTC 的核心流程跑通,同时把中继服务器和信令服务器都自己搭一遍,搞清楚每个环节到底在干什么。

这个项目最终交付的是一套完整的、可复现的点对点视频通话 Demo,包含三个核心部分:网页端(浏览器)、安卓端(Android 原生),以及配套的信令服务器(Signaling Server)和中继服务器(ICE/TURN Server)。网页端和安卓端之间可以互相呼叫、建立连接、传输音视频流,信令服务器负责交换会话描述和网络候选信息,中继服务器负责在点对点直连失败时兜底转发媒体流。整套东西适合谁看?适合已经了解 JavaScript 或 Android 基础、想深入理解 WebRTC 连接建立全过程的开发者,也适合需要自己搭建一套内网或小规模公网通话系统的团队做技术验证。

为什么选择 WebRTC 而不是其他方案?核心原因在于它是浏览器原生支持的实时通信能力,不需要安装插件,API 标准化程度高,而且天然支持点对点传输,在理想网络条件下延迟可以压到几十毫秒。但 WebRTC 本身只规定了媒体传输和连接管理的部分,信令通道和中继服务需要开发者自己实现,这正是这个 Demo 要补齐的两块拼图。整个架构可以这样理解:两个客户端各自通过 WebSocket 连接到信令服务器,信令服务器帮它们交换 SDP(会话描述协议)和 ICE Candidate(网络候选地址),双方拿到这些信息后尝试建立点对点连接,如果直连不通,就走 TURN 服务器中继。这个设计的好处是职责清晰,信令服务器只做消息转发,不碰媒体数据,TURN 服务器只在必要时才介入,节省带宽成本。

在方案选型上,信令服务器我用了 Node.js + WebSocket 的组合,原因是轻量、上手快、和前端语言统一,调试起来方便。TURN 服务器用的是 coturn,这是目前开源社区里最成熟的中继服务器实现,支持 STUN 和 TURN 两种协议,配置灵活,社区文档也全。安卓端没有用 WebView 套网页,而是直接用原生 WebRTC Android SDK,这样能更好地控制音视频采集参数和硬件编码器,性能也更稳。网页端就是标准的 WebRTC API,配合 WebSocket 做信令。整个项目没有引入任何复杂的框架,目的是让代码逻辑尽可能透明,方便读者对照着改。

2. 信令服务器与中继服务器的核心细节

2.1 信令服务器到底在交换什么

很多人第一次接触 WebRTC 会有一个误区,以为 WebRTC 自己就能完成所有事情。实际上 WebRTC 只负责媒体层面的连接建立和传输,它不知道对方在哪里,也不知道对方的媒体能力是什么。这些信息必须通过一个额外的通道来交换,这个通道就是信令服务器。在这个 Demo 里,信令服务器基于 WebSocket 实现,每个客户端连接上来之后会注册一个唯一的用户标识,服务器维护一个在线用户列表。当 A 想呼叫 B 时,A 发送一个 offer 消息,服务器根据目标标识把消息转发给 B;B 收到后生成 answer 回传;双方在连接过程中还会不断产生 ICE Candidate,这些候选地址也需要通过信令服务器互相交换。

具体来说,信令消息的类型包括:register(注册用户)、offer(发起会话描述)、answer(应答会话描述)、candidate(网络候选地址)、hangup(挂断)、reject(拒绝)。每条消息都带有一个from和to字段,服务器只做路由转发,不解析 SDP 内容。这样做的好处是信令服务器逻辑极简,压力小,而且不涉及媒体数据,安全边界清晰。在实际部署时,信令服务器可以放在公网,也可以放在内网,只要两个客户端都能访问到就行。

注意:信令服务器的用户标识建议用随机生成的字符串或者业务系统里的用户 ID,不要直接用明文手机号或邮箱,避免隐私泄露。

2.2 TURN 服务器为什么必须自己搭

WebRTC 的理想情况是点对点直连,但现实网络环境里,很多设备处于 NAT 后面,尤其是移动网络和公司内网,直连成功率并不高。STUN 服务器可以帮助客户端发现自己公网映射地址,但当 NAT 类型是对称型时,STUN 就无能为力了,这时候必须靠 TURN 服务器中继转发。coturn 的安装和配置并不复杂,但有几个关键参数必须配对。首先是listening-port,默认 3478,用于 STUN/TURN 的 UDP 和 TCP 监听;其次是tls-listening-port,默认 5349,用于 TLS 加密的 TURN 连接;然后是realm,这个相当于服务器域名标识,客户端配置里必须一致;最后是user和password,用于 TURN 鉴权。

coturn 的配置文件通常放在/etc/turnserver.conf,一个最小可用的配置大概是这样:

listening-port=3478 tls-listening-port=5349 realm=yourdomain.example.com server-name=yourdomain.example.com lt-cred-mech user=testuser:testpassword cert=/etc/ssl/certs/turn.crt pkey=/etc/ssl/private/turn.key no-multicast-peers no-cli

其中lt-cred-mech表示使用长期凭证机制,user行定义了一个静态账号。生产环境建议用动态凭证或者数据库鉴权,但 Demo 阶段静态账号足够。启动 coturn 之后,可以用turnutils_uclient工具做连通性测试,确认中继功能正常。这里有一个容易踩的坑:如果服务器有防火墙,除了 3478 和 5349,还需要放行一个 UDP 端口范围,用于中继媒体流,默认是 49152 到 65535,这个范围可以在配置文件里用min-port和max-port调整。

2.3 ICE 框架与候选地址的收集逻辑

ICE(Interactive Connectivity Establishment)是 WebRTC 连接建立的核心框架,它的工作流程可以拆成三步:收集候选地址、交换候选地址、连通性检查。候选地址分三种类型:主机候选(本机网卡地址)、服务器反射候选(通过 STUN 发现的公网映射地址)、中继候选(通过 TURN 分配的中继地址)。客户端会同时收集这三种候选,然后通过信令服务器交换给对方。双方拿到候选列表后,会按照优先级从高到低尝试连接,主机候选优先级最高,中继候选最低。连通性检查用的是 STUN Binding 请求,双方互相发送探测包,谁先收到对方的响应,谁就确定了一条可用路径。

在实际调试中,我建议把 ICE 的日志级别调高,这样能看到每个候选的收集状态和连通性检查结果。Chrome 浏览器可以在chrome://webrtc-internals里看到详细的 ICE 状态变化,安卓端可以通过PeerConnection.Observer的onIceCandidate和onIceConnectionChange回调来观察。如果发现连接一直卡在checking状态,大概率是候选地址交换不完整,或者 TURN 服务器配置有问题。这时候可以先用trickle ICE测试工具单独验证 STUN/TURN 是否可用,排除服务器侧的问题。

3. 网页端与安卓端的实操实现

3.1 网页端从零建立连接的关键步骤

网页端的实现可以拆成五个步骤:获取本地媒体流、创建 PeerConnection、交换 SDP、交换 ICE Candidate、处理远端流。第一步用navigator.mediaDevices.getUserMedia获取摄像头和麦克风权限,拿到 MediaStream 之后先绑定到本地 video 标签预览。第二步创建RTCPeerConnection实例,配置里传入 ICE 服务器列表,包括 STUN 和 TURN 的地址、用户名、凭证。第三步是呼叫方调用createOffer生成 offer,设置本地描述,然后通过 WebSocket 发给对方;被叫方收到 offer 后调用setRemoteDescription,再调用createAnswer生成 answer 回传。第四步是双方在onicecandidate回调里把候选地址发给对方,收到后调用addIceCandidate。第五步是监听ontrack事件,把远端流绑定到另一个 video 标签上。

这里有一个细节值得展开:createOffer和createAnswer都是异步操作,返回的是 Promise,必须等 Promise resolve 之后才能拿到完整的 SDP。另外,SDP 里包含了编解码器协商信息,如果两端支持的编解码器不一致,可能会协商失败。WebRTC 默认会优先选择 VP8 或 H.264,音频默认是 Opus。如果发现连接建立成功但没有画面,可以检查 SDP 里的m=video行,看看实际协商出来的编解码器是什么。

const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:yourdomain.example.com:3478' }, { urls: 'turn:yourdomain.example.com:3478', username: 'testuser', credential: 'testpassword' } ] }); pc.onicecandidate = (event) => { if (event.candidate) { signaling.send({ type: 'candidate', candidate: event.candidate }); } }; pc.ontrack = (event) => { remoteVideo.srcObject = event.streams[0]; };

提示:网页端在本地开发时,getUserMedia要求页面运行在 HTTPS 或者 localhost 下,否则浏览器会拒绝授权。部署到线上时务必配置有效的 TLS 证书。

3.2 安卓端原生 WebRTC 的集成要点

安卓端的集成比网页端要繁琐一些,因为需要引入 WebRTC Android SDK,并且要自己管理音视频采集、渲染和生命周期。首先在build.gradle里添加依赖,然后初始化PeerConnectionFactory,这一步会加载 native 库,建议放在 Application 或者 Activity 的 onCreate 里做一次即可。接着创建VideoCapturer,通常用Camera2Enumerator枚举前后摄像头,选择前置摄像头做通话。音频方面,AudioSource和AudioTrack的创建比较直接,但要注意在通话结束时及时释放,否则会占用麦克风资源。

安卓端的信令部分和网页端类似,也是通过 WebSocket 收发消息,但消息格式需要和网页端保持一致,这样才能实现跨端互通。SDP 的生成和设置通过SdpObserver回调完成,ICE Candidate 通过PeerConnection.Observer的onIceCandidate回调获取。渲染远端视频用SurfaceViewRenderer,需要设置缩放模式和镜像模式,前置摄像头通常要镜像显示才符合用户习惯。这里有一个容易忽略的点:安卓端的PeerConnectionFactory在创建PeerConnection时,RTCConfiguration里的 ICE 服务器配置必须和网页端一致,否则跨端连接会失败。

PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(context) .createInitializationOptions() ); PeerConnectionFactory factory = PeerConnectionFactory.builder() .setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true)) .setVideoDecoderFactory(new DefaultVideoDecoderFactory(eglBase.getEglBaseContext())) .createPeerConnectionFactory();

3.3 跨端互通时最容易出问题的三个环节

网页端和安卓端互通时,最容易出问题的环节有三个。第一个是 SDP 格式差异,安卓端生成的 SDP 里可能包含一些浏览器不认识的属性行,虽然 WebRTC 标准做了统一,但不同版本的 SDK 实现还是有细微差别。解决办法是在信令服务器里不做 SDP 解析,原样转发,让两端自己去协商。第二个是编解码器协商失败,安卓端默认可能优先使用硬件编码器,而浏览器可能只支持软件编码器,这时候需要强制指定编解码器优先级。第三个是 ICE 候选地址类型不匹配,比如安卓端只收集到了主机候选,而网页端只收集到了中继候选,双方没有交集,连接自然建立不起来。排查这个问题的方法是打印双方的候选列表,看看有没有类型重叠。

问题现象可能原因排查方法
连接卡在 checking候选地址交换不完整检查信令消息是否丢失
有声音无画面编解码器协商失败查看 SDP 中 m=video 行
通话几秒后断开ICE 保活失败检查 TURN 服务器端口
安卓端无法采集权限未授权检查运行时权限

4. 常见问题与排查技巧实录

4.1 信令服务器连接不上的排查思路

信令服务器连不上是最常见的问题,表现是客户端一直处于“连接中”状态。排查顺序建议从外到内:先确认服务器进程是否正常运行,用netstat或ss命令查看 WebSocket 端口是否在监听;再确认防火墙是否放行了该端口,云服务器还需要检查安全组规则;然后确认客户端配置的服务器地址和端口是否正确,有没有写错 IP 或域名;最后检查 WebSocket 路径是否匹配,有些服务器实现会校验Sec-WebSocket-Protocol头。如果用的是 Nginx 做反向代理,还需要确认proxy_set_header Upgrade和Connection头是否正确传递,否则 WebSocket 握手会失败。

注意:WebSocket 连接建立后,建议加一个心跳机制,每隔 30 秒发一次 ping,防止中间网络设备因为空闲超时断开连接。

4.2 TURN 服务器中继失败的典型原因

TURN 服务器配置好了但中继不生效,通常有四个原因。第一是realm配置和客户端不一致,coturn 会用 realm 做鉴权,不一致直接拒绝。第二是用户名密码错误,或者用了lt-cred-mech但没有在配置文件里正确定义 user。第三是端口没放行,除了 3478 和 5349,中继媒体流的 UDP 端口范围也必须放行。第四是证书问题,如果用了 TLS 但证书链不完整,客户端会拒绝连接。排查时可以先关掉 TLS,用纯 UDP 测试,确认基础中继功能正常后再开 TLS。coturn 的日志默认输出到 syslog,可以用journalctl -u coturn查看实时日志,里面会记录每次分配请求的结果和失败原因。

4.3 音视频不同步与卡顿的优化经验

音视频不同步和卡顿是实时通话里最影响体验的问题。从我的实操经验来看,原因通常不在 WebRTC 本身,而在网络抖动和采集参数设置。首先检查采集分辨率,720p 在移动网络下可能带宽不够,降到 480p 会稳很多。其次检查码率上限,WebRTC 默认会根据带宽动态调整,但如果设置了固定的高码率,网络跟不上就会丢包。然后检查音频采样率,Opus 支持 8k 到 48k,两端不一致会导致重采样,增加延迟。最后检查渲染环节,安卓端的SurfaceViewRenderer如果设置了错误的缩放模式,会造成画面拉伸,看起来像卡顿。我一般会在PeerConnection的RTCConfiguration里设置bundlePolicy为max-bundle,把所有媒体流复用一个传输通道,减少连接建立时间。

优化项推荐值说明
视频分辨率640x480移动网络下更稳定
视频码率500-800 kbps根据网络动态调整
音频采样率48000 HzOpus 原生支持
ICE 传输策略all同时尝试 UDP 和 TCP
捆绑策略max-bundle复用传输通道

4.4 安卓端后台切换与锁屏的处理

安卓端在通话过程中切到后台或者锁屏,系统可能会限制摄像头和麦克风访问,导致通话中断。解决办法是在AndroidManifest.xml里声明FOREGROUND_SERVICE权限,并在通话开始时启动一个前台服务,通知栏显示“通话中”。这样即使应用切到后台,系统也不会回收资源。另外,CameraCapturer在onPause时应该停止采集,在onResume时重新开始,避免占用摄像头导致其他应用无法使用。锁屏场景下,建议禁用锁屏或者使用FLAG_KEEP_SCREEN_ON保持屏幕常亮,否则用户锁屏后视频流会黑屏。这些细节在 Demo 阶段可以简化,但如果要做成可用的产品,必须处理到位。

5. 部署与扩展的实操建议

5.1 从 Demo 到可用系统的部署清单

把这个 Demo 部署到实际可用的环境,需要做几件事。第一,信令服务器和 TURN 服务器建议分开部署,信令服务器对 CPU 要求低,TURN 服务器对带宽要求高,混在一起容易互相影响。第二,TURN 服务器建议部署在离用户近的机房,减少中继延迟,如果用户分布广,可以部署多个 TURN 节点,客户端配置里填多个地址,WebRTC 会自动选择延迟最低的。第三,所有服务都必须配置 TLS 证书,浏览器对非 HTTPS 页面的媒体权限限制越来越严格,安卓端从 Android 9 开始也默认禁止明文流量。第四,建议加一个简单的监控,记录信令服务器的在线用户数和 TURN 服务器的中继流量,方便排查问题。第五,日志要分级,生产环境不要开 debug 级别,否则日志量会爆炸。

5.2 后续可以扩展的方向

这个 Demo 目前只实现了 1 对 1 通话,后续可以扩展的方向有不少。比如多人通话,可以用 Mesh 架构让每个客户端和其他所有客户端建立连接,但人数多了之后上行带宽压力大,更好的方案是用 SFU(选择性转发单元)架构,客户端只和 SFU 建立一条连接,由 SFU 负责转发。再比如录制功能,可以在服务端用 MCU 混流后录制,或者在客户端用 MediaRecorder 录制本地流。还有屏幕共享,网页端可以用getDisplayMedia,安卓端可以用MediaProjectionAPI。这些扩展都需要在现有架构上做调整,但核心的信令和 ICE 框架可以复用。我个人建议先把 1 对 1 的稳定性做扎实,再考虑扩展,否则问题会成倍增加。

5.3 我踩过的几个坑和对应解法

第一个坑是 coturn 的no-multicast-peers配置,如果不加这一行,服务器可能会尝试向组播地址发送数据,导致中继失败。第二个坑是安卓端的PeerConnectionFactory没有正确释放,多次进入通话页面后内存泄漏,最后 OOM,解决办法是在onDestroy里调用factory.dispose()。第三个坑是网页端的ontrack事件在某些浏览器版本里触发时机不稳定,有时候srcObject设置后没有自动播放,需要手动调用video.play()。第四个坑是信令消息的 JSON 序列化,RTCIceCandidate对象直接JSON.stringify会丢失部分字段,必须先转成普通对象再序列化。这些坑在官方文档里很少提到,但实际开发中几乎都会遇到,提前知道能省不少时间。

提示:建议在信令消息里加一个timestamp字段,方便排查消息延迟和乱序问题。

5.4 安全与合规方面的注意事项

虽然这是一个技术 Demo,但涉及音视频通话就绕不开安全和合规。首先,信令服务器必须做鉴权,不能任何人都能注册和呼叫,建议用 token 或者 session 机制。其次,TURN 服务器的凭证不要硬编码在客户端,最好由业务服务器动态下发临时凭证,coturn 支持use-auth-secret模式,可以用时间戳和密钥生成临时用户名密码。再次,媒体流建议强制加密,WebRTC 默认使用 DTLS-SRTP 加密,但 TURN 中继的 TCP 连接如果不走 TLS,信令部分可能暴露,所以turns:协议比turn:更安全。最后,通话内容不要录制和存储,除非有明确的业务需求和用户授权,否则会带来隐私风险。这些点在做 Demo 时可以简化,但心里要有数,真正上线前必须补齐。

整个项目做下来,我最大的体会是 WebRTC 的复杂度不在 API 本身,而在网络环境和跨端兼容性。把信令和 TURN 这两块自己搭一遍之后,再看 WebRTC 的文档和错误日志,理解会深很多。如果你也在做类似的东西,建议先把 1 对 1 的网页端跑通,再加安卓端,最后配 TURN,一步一步来,比一上来就搞全套要稳得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询