webRTC原理及Android使用WebRTC实现P2P通信
1 背景
在WebRTC(Web Real-Time Communication
)出现之前,用户想要实现音视频聊天,流媒体播放等实时通信,需要依赖于专有协议、客户端软件或浏览器插件,例如在浏览器上通过在浏览器上安装Flash插件实现音视频采集和传输,然后用RTMP协议将音视频流传输到服务器中转,再由服务器分发给接收端。这种方式存在延迟较高,且需安装插件,兼容性等问题。
直到 2011 年,Google 收购了专注音视频处理技术的 GIPS(Global IP Solutions
)公司,将其核心技术开源和整合为 WebRTC,并凭借浏览器原生支持(无需插件,自带音视频处理),低延时(P2P点到点通信)等特性,才彻底改变了实时通信的开发方式。
如今webRTC虽然名字带web
,但是早已不仅仅局限于浏览器,已经广泛支持桌面端、移动端甚至物联网设备。
2 关键概念和原理
WebRTC是一种支持p2p的实时通信技术,那么什么是P2P?
2.1 P2P
传统的中继通信模式中,数据需通过服务器进行转发(如:客户端 A → 中继服务器 → 客户端 B)。这种方式不仅需要部署额外的服务器,而且每一个数据包都必须经过服务器收发,带来高带宽压力,导致延迟增加,成本也随之上升。
相比之下,后来兴起的 P2P(Peer-to-Peer,点对点)通信模式,数据可以直接在客户端 A 与客户端 B 之间传输,无需经过中转服务器,从而大幅降低延迟,同时节省了服务器部署和带宽成本。
注意p2p的通讯虽然不通过中转服务器转发媒体流数据,但是仍然需要通过一个服务器转发基础信息。 通信大致流程: 双方创建音视频设备和连接实例,通过信令交换连接信息,协商通信参数后收集并交换网络地址,完成连通性测试后建立 P2P 连接,开始实时传输数据。
sequenceDiagram
participant A as 呼叫方(Peer A)
participant S as 信令服务器
participant B as 接收方(Peer B)
A->>S: 发起会话请求
S->>B: 转发请求
B->>S: 发送回应
S->>A: 转发回应
A-->>B: 建立 P2P 直接,发送音视频数据
2.2 信令服务器
在p2p双方建立直连之前,互相不知道对方的存在,因此就需要一个称为信令服务器的中间人来转达信息,一是可以知道对方存在,而是可以交换信息,信息交换完成后它们才可以互相直接通讯。
WebRTC并没规定这部分的内容,因此协议、库、语言都是可选的,只要双方能通信、解析信息就可以:
- 自定义信令消息格式(通常是 JSON)
- 使用任何支持实时通信的协议(如 WebSocket)
- 自己定义和实现信令命令
虽然协议是自定义的,但通常要实现以下几种基础信令命令来协助客户端完成 WebRTC 的连接建立过程:
命令 | 作用 | 说明 |
---|---|---|
join / login | 客户端加入房间或注册标识 | 建立用户身份或房间 |
offer | 发送 SDP offer | 发起方发送连接请求 |
answer | 回复 SDP answer | 接收方回复连接同意 |
candidate | 发送 ICE 候选 | 交换 NAT 穿透所需的 ICE 信息 |
leave / disconnect / bye | 退出连接或房间 | 用于释放资源或提示对方 |
ping / pong(可选) | 保活机制 | 检测连接存活状态 |
p2p通讯过程中需要交换哪些信息?
2.3 SDP 协商
SDP(Session Description Protoco
)是webrtc中用来描述媒体流信息的协议,比如媒体格式、编解码方式等的一个协议。因为不同设备的媒体参数(分辨率、采样率、比特率、通道数等)和支持的编解码器(如 VP8, H264, Opus 等)可能不尽相同,双方需要协商,采用一个大家都支持或者兼容的参数。
例如以下是当 A 执行 createOffer() 时生成的 SDP部分内容:
v=0
o=- 4611733053049959640 2 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 9 UDP/TLS/RTP/SAVPF 111
a=rtpmap:111 opus/48000/2
a=sendrecv
...
当 B 在 setRemoteDescription() 后会根据此 SDP 判断自己是否能接受,如果可以就生成对应 answer SDP,告诉 A:“我接受你的要求,我也支持这些设置”。
部分SDP 协商包含类型:
目的 | 说明 |
---|---|
媒体类型 | 是否使用音频、视频或数据通道 |
编解码器 | 每种媒体类型支持的编解码器,如 Opus、VP8、H264 等 |
媒体参数 | 分辨率、采样率、比特率、通道数 |
加密方式 | 通信的加密协商,比如 DTLS-SRTP 的指纹信息 |
此时连接流程图:
sequenceDiagram
participant A as Peer A(呼叫方)
participant S as 信令服务器
participant B as Peer B(接收方)
A->>S: 发送 Offer SDP
S->>B: 转发 Offer SDP
B->>S: 发送 Answer SDP
S->>A: 转发 Answer SDP
A-->>B: 建立 P2P 连接
A-->>B: 传输音视频流或数据
B-->>A: 传输音视频流或数据
2.4 NAT技术
以上p2p通信建立过程中如果两个设备都有公网ip地址,那他们可以直接建立连接,但是现实情况是:绝大多数设备都没有自己的公网ip,只能通过路由器等设备共享一个公网地址来与互联网上的其他设备进行连接。
为什么设备要通过内网地址映射而不能使用公网IP直连呢,因为公网IP不够用。相信很多人都曾听说过IP网地址即将用尽的说法:IPv4是目前广泛使用的 IP 协议, 总共只有 约 43 亿个地址,但是设计者没有预料到互联网发展如此迅速,很快IP地址将不够分配给所有设备。
为了解决公网 IP 不够的问题,IETF组织提出了私有地址的概念,专门保留了三段 IP 地址给内部网络使用。设备使用私有地址,形成了局域网(LAN),各自局域网的私有地址可以重复利用,于是有效缓解公网地址枯竭的问题。但是私有地址无法直接访问公网,就需要NAT(Network Address Translation
)技术桥接,将私有地址转换成公网地址。
私有地址范围: 10.0.0.0-10.255.255.255 172.16.0.0—172.31.255.255 192.168.0.0-192.168.255.255
例如家里的电脑A(假设ip为192.168.1.100)想要访问xx网站(假设地址为104.18.10.232):
-
首先电脑A发出的http请求会被路由器(假设地址为203.0.113.5)收到,
-
路由器会将这个TCP/IP包的电脑A的地址替换为自己的公网地址并记录下来(NAT表,实际上路由器为了稳定和避免冲突,端口也会被重新分配),再由它向xx网站服务器发出请求,
-
服务器返回信息给路由器,路由器查 NAT 表将回包发给电脑A。
NAT技术有效解决 IPv4 地址紧张的问题,同时也将终端设备与公网隔离,不易被攻击,增加了安全性。不过正是由于内网设备不能直接被公网直接访问,增加了p2p连接的难度。想要打破这层物理障碍,必须进行NAT穿透(俗称打洞)。
常见穿透方式:
方式 | 原理 |
---|---|
STUN协议 | 协助内网设备获取 NAT 后的公网地址和端口 |
TURN协议 | 通过中继服务器转发数据(穿透失败时兜底方案) |
ICE框架 | 综合使用 STUN + TURN,选择最佳路径 |
2.5 STUN协议及其服务器
STUN 是 NAT 穿透的第一步,作用是获取设备在公网的映射地址。它的原理很简单,内网设备向 STUN 服务器发送一个请求(通常是 UDP 协议),服务器记录这个IP和端口并返回响应,内网设备便知道了自己的公网地址(实际上是路由器的公网 IP + 动态端口),后续再发给信令服务器以建立连接。 打个比方就是你忘了自己的手机号码,打电话给朋友让他从来电记录里告知你的电话号码。
NAT 穿透到底穿的是什么? 通常只有内网设备发送数据到外部,路由器才创建NAT 映射。公网的外部是不能主动向这个内网设备发起连接的,因为NAT 表里没有记录,就无法找到对应哪个内网设备,此时外部无法连接内网,就仿佛一堵NAT墙。
为什么不能用信令服务器进行NAT穿透 NAT 是基于 协议类型(TCP 或 UDP) 维护映射表的,即使内网设备的本地 IP 和端口是一样的,只要协议类型不同,NAT 会创建两条不同的映射表项。
信令协议通常使用 TCP 或 WebSocket,因为 TCP 具有较好的可靠性和穿透性,适合用于信令数据的传输。而对于音视频数据,虽然可以使用 TCP,但UDP提供更低的延迟和更好的实时性,即使它会丢包,但不会像 TCP 那样因丢包而阻塞流媒体的传输。
因此,NAT 穿透通常不适用于信令服务器,因为信令服务器本身并不直接处理音视频数据流的传输,也无法直接穿透 NAT 来建立端到端的 P2P 连接。
2.6 TURN协议及其服务器
NAT 有几种类型,如对称型、完全锥型,不同类型穿透难度不同,对称型NAT基本无法穿透。当 NAT 穿透失败时,通信双方恢复到中转通信的模式,TURN 提供一个中继服务器来转发数据,都把数据发到 TURN 服务器,由它进行转发。
2.7 ICE框架
除了交换媒体参数,通信双方还需要交互网络信息,才能找到一条相互通讯的链路。
ICE是WebRTC 中的机制,作用是通过收集候选(Candidate)地址,找出一条最优的网络路径让两个 Peer 能直接通信。
ICE 中的候选类型:
类型 | 描述 | 来源 |
---|---|---|
Host 候选(本地 IP) | 直接使用本地网络 IP 地址 | 设备本地 |
Server Reflexive(公网映射 IP) | 通过 STUN 服务器获取的公网地址 | STUN |
Relay (TURN服务器IP) | 通过 TURN 服务器中转的地址 | TURN |
连接时双方收集候选(Candidate)地址,并通过信令服务器互相交换,然后选出连接成功、延迟最低的一组地址作为最终通信路径,具体是:用本地 IP 直连,不行就通过 STUN 获取你们的公网映射 IP 再打洞,还不行就用 TURN 中继来绕过防火墙。
2.8 关键API
API | 用途说明 |
---|---|
new RTCPeerConnection() | 创建一个用于 WebRTC 连接的核心对象 |
createOffer() | 创建一个包含本地媒体信息的 SDP(会话描述),用于发起连接请求。 |
createAnswer() | 基于接收到的 offer 创建一个 SDP 回应 |
setLocalDescription() | 设置本地 SDP(发起者),并启动异步 ICE 候选收集过程 |
setRemoteDescription() | 设置远端的 answer SDP,完成 SDP 协商流程。 |
onicecandidate | 触发事件回调,在收集到一个候选地址(candidate)时被调用。 |
addIceCandidate(candidate) | 接收到对方通过信令发送来的 candidate 后,添加到 ICE 引擎中,参与连接测试。 |
结合上面的api现在可以整理一份较为完整的流程图,
2.9 WebRTC P2P 建立连接时序图
注意:实际上当一方调用 setLocalDescription() 设置了 offer/answer 后,ICE 代理就会开始收集候选地址(如主机地址、STUN 公网地址、TURN 中继地址等),和 SDP 的发送是并行的。
它不需要等到 Offer/Answer 流程完成才开始发送。每收集一个候选,浏览器会通过 onicecandidate 回调通知应用并通过信令发送给对端,收到对端的候选后添加进连接中,然后双方互测这些地址直至找到最佳路径。
总结一下,以上流程看似复杂,实则主要做了两件事情,交换SDP信息(媒体协商)和交换ice(网络协商)。
打个不太准确的比方,信令服务器就像中介/媒人,通信过程可以看成相亲过程:
1小伙子阿强先写下自我介绍我是阿强,我会blabla...(createOffer),保存好(setLocalDescription),然后把资料发给中间人(发送 offer SDP)。 2.中间人把资料发给女方,女方收到后,先记下对方资料(setRemoteDescription),然后再整理自己的介绍语我是阿珍blabla(createAnswer)也保存来(setLocalDescription),发给中间人代为转达( 发送 answer SDP)。 3.小伙子收到中间人回信,记下来对方信息(setRemoteDescription) 4.双方继续通过中间人交换电话号码/住址/微信等联系信息,确定如何联系对方之后就可以直接交流。
3 实现信令服务器及其客户端
这个例子实现两台Android设备在局域网内的通过WebRTC进行视频通话,不涉及TURN服务器。我们首先看信令服务器:
3.1 服务端
信令服务器主要功能是帮助两个客户端交换建立连接所需的信息, 这个例子采用Node.js + ws实现,搭建起来比java略快。命令只完成了最基础的3条:发起P2P连接请求(offer)、接受P2P连接请求(answer)、交换ice信息(candidate)。没有区分客户端,接收到命令后转发给其他所有在线的客户端。
环境搭建 Node.js是一个跨平台的 JavaScript 运行时环境, 能够创建服务器 Web 应用。附安装搭建步骤:
- 在Node.js官网下载Node.js,安装完成可通过
node -v
和npm -v
命令检测是否安装成功,然后新建项目目录并在目录打开命令提示符:
# 创建js文件,名字随意,也可以用右键菜单新建文件
touch server.js
# 初始化 package.json
npm init -y
# 安装ws 模块
npm install ws express
源码
以下server.js
完整源码:
const WebSocket = require('ws');
const express = require('express');
const app = express();
// 创建 WebSocket 服务器
const wss = new WebSocket.Server({ port: 8080 });
// 连接的用户列表(可以用来广播消息给所有连接的客户端)
let clients = [];
wss.on('connection', (ws) => {
// 每当有客户端连接时,将其添加到客户端列表
clients.push(ws);
console.log('A new client connected.');
// 处理来自客户端的消息
ws.on('message', (message) => {
const text = typeof message === 'string' ? message : message.toString('utf-8');
console.log('>>>>>>>>>>>>>>>>>>>>');
console.log('Received: %s', text);
console.log('<<<<<<<<<<<<<<<<<<<<');
// 可以根据实际情况广播消息,或者发送给特定客户端
// 在这个例子中,我们将消息广播给所有客户端
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(text);
}
});
});
// 处理客户端断开连接
ws.on('close', () => {
clients = clients.filter(client => client !== ws);
console.log('A client disconnected.');
});
});
// 启动 HTTP 服务(用于测试接口等)
app.get('/', (req, res) => {
res.send('WebSocket signaling server is running!');
});
app.listen(3000, () => {
console.log('HTTP server is running on port 3000');
});
运行
使用以下命令运行代码,正常会输出WebSocket signaling server is running!
# 使用命令运行代码
node server.js
4 Aandroid 通过WebRTC实现P2P通信
webrtc的原生库可以通过官网进行编译,也可以使用现成的, 添加依赖
// webrtc库 可以使用谷歌官方仓库or webrtc-sdk
//implementation("org.webrtc:google-webrtc:1.0.32006")
implementation("io.github.webrtc-sdk:android:125.6422.06.1")
// okhttp3 用于信令通信
implementation("com.squareup.okhttp3:okhttp:4.12.0")
4.1 UI界面
我们先来看UI界面,WebRTCUI提供了一个全屏的SurfaceView用于渲染对方的视频画面,左上角一个小localSurfaceView用于展示自己的视频画面,Button用于拨打或者挂断,拨打后对方直接接通。
@Composable
fun WebRTCUI() {
var isCalling by remember { mutableStateOf(false) }
var webRTCClient:WebRTCClient?=null
val context = LocalContext.current
// 本地视频显示
val localSurfaceView = remember { SurfaceViewRenderer(context) }
// 远程视频显示
val remoteSurfaceView = remember { SurfaceViewRenderer(context) }
LaunchedEffect(key1 = Unit) {
webRTCClient = WebRTCClient(context)
webRTCClient?.setLocalSurfaceView(localSurfaceView)
webRTCClient?.setRemoteSurfaceView(remoteSurfaceView)
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { remoteSurfaceView },
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
AndroidView(
factory = { localSurfaceView },
modifier = Modifier
.width(160.dp).height(240.dp)
.background(color = Color.Red, shape = RoundedCornerShape(8.dp))
.align(Alignment.TopEnd)
)
Column(
modifier = Modifier
.fillMaxWidth().align(Alignment.BottomCenter)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "WebRTC Demo")
Spacer(modifier = Modifier.height(16.dp))
// 启动通话按钮
Button(onClick = {
if (!isCalling) {
// 启动视频通话
webRTCClient?.startPreview()
webRTCClient?.startCall()
isCalling = true
} else {
// 结束视频通话
webRTCClient?.stopPreview()
webRTCClient?.endCall()
isCalling = false
}
}) {
Text(text = if (isCalling) "挂断" else "拨打")
}
}
}
}
4.2 WebRTC组件
初始化 PeerConnectionFactory 是 WebRTC 的核心类之一,它的作用是创建和管理 WebRTC 中涉及的所有媒体组件。在使用 WebRTC 建立音视频通信时,第一步就是初始化 PeerConnectionFactory,之后通过它来创建其他必要组件,包括:
- PeerConnection P2P 连接,用于交换媒体数据
- AudioSource / VideoSource 音频/视频的数据源
- AudioTrack / VideoTrack 音频/视频轨道,用于传输
class WebRTCClient(private val context: Context) {
private fun initialize(){
// 初始化 WebRTC
val options = PeerConnectionFactory.InitializationOptions.builder(context)
.createInitializationOptions()
PeerConnectionFactory.initialize(options)
// 创建 EGL 上下文,用于视频处理和渲染
eglBase = EglBase.create()
// 创建 PeerConnectionFactory
factory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase!!.eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase!!.eglBaseContext))
.createPeerConnectionFactory()
}
}
4.3 视频采集和预览
SurfaceViewRenderer组件
在 Android 下 WebRTC 使用OpenGL ES 进行视频渲染,然后提供的一个用于在 Android 上 渲染视频画面(本地或远端) 的视图组件org.webrtc.SurfaceViewRenderer
(继承自SurfaceView)。
<!-- 远端视频 -->
<org.webrtc.SurfaceViewRenderer
android:id="@+id/remote_video_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- 本地视频(画中画) -->
<org.webrtc.SurfaceViewRenderer
android:id="@+id/local_video_view"
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_gravity="top|end"
android:layout_margin="16dp" />
或者compose中
// 本地视频显示
val localSurfaceView = remember { SurfaceViewRenderer(context) }
// 远程视频显示
val remoteSurfaceView = remember { SurfaceViewRenderer(context) }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { remoteSurfaceView },
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
AndroidView(
factory = { localSurfaceView },
modifier = Modifier
.width(160.dp).height(240.dp)
.background(color = Color.Red, shape = RoundedCornerShape(8.dp))
.align(Alignment.TopEnd)
)
}
初始化SurfaceViewRenderer,本地和远端的初始化参数基本相同
fun setLocalSurfaceView(surfaceView: SurfaceViewRenderer) {
localSurfaceView = surfaceView
localSurfaceView?.apply {
// 初始化渲染器,必须传入 EGL 上下文
init(eglBase!!.eglBaseContext, null)
setZOrderMediaOverlay(true) // 设置为覆盖层(在其他 UI 上层)
setEnableHardwareScaler(true) // 启用硬件缩放
setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) // 保持比例填充
}
}
摄像头采集
/**
* 创建摄像头捕获器
*/
private fun createCameraCapture(usingFront: Boolean): VideoCapturer? {
// 使用 Camera2 API 枚举摄像头
val enumerator = Camera2Enumerator(context)
// 遍历设备列表
for (deviceName in enumerator.deviceNames) {
if (usingFront && enumerator.isFrontFacing(deviceName)) {
return enumerator.createCapturer(deviceName, null)
} else if (!usingFront && enumerator.isBackFacing(deviceName)) {
return enumerator.createCapturer(deviceName, null)
}
}
return null // 没找到合适的摄像头
}
创建本地音视频流实现预览
fun startPreview(usingFrontCamera: Boolean = false) {
if (localVideoTrack != null) return // 避免重复初始化
// 1 创建本地音频源和音频轨道
val audioSource = factory?.createAudioSource(MediaConstraints())
localAudioTrack = factory?.createAudioTrack("101", audioSource!!)
// 创建摄像头捕获器
videoCapture = createCameraCapture(usingFrontCamera)
// 2 创建视频源和视频轨道
val videoSource = factory?.createVideoSource(videoCapture!!.isScreencast)
// 创建纹理处理线程(SurfaceTextureHelper)并初始化捕获器
surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase!!.eglBaseContext)
videoCapture?.initialize(surfaceTextureHelper, context, videoSource?.capturerObserver)
// 启动视频捕获(分辨率:1280x720,帧率:30)
videoCapture?.startCapture(1280, 720, 30)
// 创建视频轨道(trackId 随便起名)
localVideoTrack = factory?.createVideoTrack("100", videoSource!!)
// 绑定到 SurfaceViewRenderer
localVideoTrack?.addSink(localSurfaceView)
}
4.4 创建及初始化 PeerConnection 对象
呼叫方点击拨打按钮,创建PeerConnection连接并进行初始。
fun startCall() {
initPeerConnectionForCallee()
sendOffer()
}
private fun initPeerConnectionForCallee(){
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
// 这里STUN/TURN根据实际中需要设置
// val rtcConfig = PeerConnection.RTCConfiguration(iceServers)
val rtcConfig = PeerConnection.RTCConfiguration(emptyList())
// 创建 PeerConnection,并注册监听器(Observer)
peerConnection = factory?.createPeerConnection(rtcConfig, object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate) {
// 当生成新的 ICE 候选时,调用 onIceCandidate 方法并发送到信令服务器
signalingClient.sendIceCandidate(candidate)
}
// 其他回调...
})
// 添加音视频轨道到 PeerConnection
peerConnection?.addTrack(localVideoTrack) // 添加视频轨道
peerConnection?.addTrack(localAudioTrack) // 添加音频轨道
}
然后创建呼叫方 SDP Offer(本地描述),创建成功后,设置本地sdp,并通过信令服务器发送给接收方。 发送Offer
private fun sendOffer(){
val sdpConstraints = MediaConstraints()
peerConnection?.createOffer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
// 设置本地 SDP
peerConnection?.setLocalDescription(createSdpObserver("呼叫方:设置LocalDescription"), sdp)
// 将 offer SDP 发送给远端用户
signalingClient.sendOffer(offer = sdp?.description ?: "")
}
// ...
}, sdpConstraints)
}
4.5 交换SDP和ICE信息
连接信令服务器 这一步应在WebRTCClient初始化一同完成
呼叫方收到Offer命令
当呼叫方建立连接并通过sendOffer
方法获取自己的SDP信息并通过信令服务器客户端SignalingClient发送命令,信令服务器收到后转发给接收方,接受方的SignalingClient监听到服务器发过来的offer
命令,回调onOfferReceived
方法,即此时接受方收到“来电提醒”,,可以选择接听或者拒绝,这里为了演示简便自动连接,启动预览、创建连接和发送anser SDP信息。
object :SignalingListener{
override fun onOfferReceived(offer: String) {
// 2 接收方:收到呼叫方步骤1的Offer,记录下来,然后创建自己的 SDP Answer 并发送
startPreview()
initPeerConnectionForCallee()
sendAnswer(remoteOffer)
}
}
private fun sendAnswer(remoteOffer:String) {
val remoteSdp = SessionDescription(SessionDescription.Type.OFFER, remoteOffer)
peerConnection?.setRemoteDescription(createSdpObserver(), remoteSdp)
val sdpConstraints = MediaConstraints()
peerConnection?.createAnswer(object :SdpObserver{
override fun onCreateSuccess(sdp: SessionDescription?) {
peerConnection?.setLocalDescription(createSdpObserver(), sdp)
// 将 Answer SDP 发送给发送方
signalingClient.sendAnswer(sdp?.description ?: "")
}
// ...
},sdpConstraints)
}
呼叫方收到Answer
override fun onAnswerReceived(answer: String) {
// 3 呼叫方:收到接收方的SDP Answer,记录(设置)下来
val sessionDescription = SessionDescription(SessionDescription.Type.ANSWER, answer)
peerConnection?.setRemoteDescription(createSdpObserver("呼叫方:设置RemoteDescription"), sessionDescription)
}
添加ICE候选地址 此过程也会通信进行ICE候选,可能会执行数次,直接连接成功建立或失败。
override fun onIceCandidateReceived(candidate: String) {
// 将远端的 ICE 候选添加到本地 PeerConnection
val jsonObject = JSONObject(candidate)
val sdpMid = jsonObject.getString("sdpMid")
val sdpMLineIndex = jsonObject.getInt("sdpMLineIndex")
val sdp = jsonObject.getString("sdp")
val iceCandidate = IceCandidate(sdpMid, sdpMLineIndex, sdp)
peerConnection?.addIceCandidate(iceCandidate)
}
4.6 传输数据和视频渲染
当连接成功,会在初始化PeerConnection时的PeerConnection.Observer监听到状态。然后则可以在
onTrack回调中获得对端的数据,将它与SurfaceViewRenderer
绑定,即可获得对方的视频画面。
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
when (newState) {
PeerConnection.IceConnectionState.CONNECTED -> Logcat.d(TAG, "Ice Connected")
PeerConnection.IceConnectionState.FAILED -> Logcat.e(TAG, "Ice Connection failed")
// 处理其他状态
}
}
// peerConnection.addTrack对应onTrack, peerConnection.addStream 对应 onAddStream
override fun onTrack(transceiver: RtpTransceiver?) {
super.onTrack(transceiver)
val receiver = transceiver?.receiver
val track = receiver?.track()
if (track is VideoTrack) {
track.addSink(remoteSurfaceView)
}
}
4.7 结束通话释放资源
fun stopPreview() {
try {
// 停止捕获摄像头
videoCapture?.stopCapture()
} catch (e: Exception) {
e.printStackTrace()
}
// 释放资源
videoCapture?.dispose()
surfaceTextureHelper?.dispose()
// 取消预览绑定
localVideoTrack?.removeSink(localSurfaceView)
}
fun endCall() {
peerConnection?.close()
peerConnection = null
}
至此,完成一个视频通话的预览、p2p连接,数据传输的简单例子,进一步的优化可以增加客户端标识,拨打给指定的接受方而不是广播给所有人;增加拒绝或挂断信令,主动通知对方通话结束等等。
5 总结
WebRTC以其标准化API、低延迟和免费开源的特点被作为成熟的P2P通信方案采用,但是WebRTC也不是万能的,NAT穿越复杂,穿越失败需要依赖TURN服务器,增加维护成本。另外用户连接数较多时,WebRTC 的点对点(P2P)架构会面临性能瓶颈。是否适宜使用WebRTC,取决于所采用的架构方式(Mesh/SFU/MCU)和会议规模。 本文通过直白的语言讲述了WebRTC的信令交换、SDP 协商、NAT 穿透等概念,并演示了在Android平台上的视频通话例子。
完整源码已上传到GitHub。