1、引入
implementation 'org.webrtc:google-webrtc:1.0.24465'
// 参考:https://webrtc.org/native-code/android/
本文有部分内容参考自即时通讯网(http://www.52im.net/thread-265-1-1.html)
2、WebRTC介绍
WebRTC,网页实时通信(Web Real-Time Communication)的缩写,它是一种支持跨平台的实时语音通讯、视频通讯的技术。
WebRTC的音视频通讯技术是基于p2p实现的,这种技术需要处理两个主要的问题:
信令交互,本文使用WebSocket来实现,主要有以下几个功能:
- 控制通信开启或关闭
- 告知通讯双方媒体流数据,比如解码器、媒体类型等
- 交换通讯双方的IP地址、端口号
- 发生错误时告知彼此
P2P通信
现实网络中主要有三种环境:
- 公共网络:这类网络主机之间可以不受限制地进行直接访问,现实中这种网络很少存在。
- NAT网络:这类网络中主机位于私有网络中,没有单独的公有IP,访问私有网络中的主机需要打洞,通过打洞我们可以发现主机在公网中的IP,STUN协议就是用来帮助实现这一功能的。
- 严格受限的NAT网络(对称NAT):这类网络中的主机位于私有网络中,只能单向访问外网。对称NAT网络特性是当目的地址的IP或者端口有一个发生改变时,NAT都会重新分配一个端口使用,所以这个时候我们不能选择使用P2P来实现网络通信,可以借助公共网络上的TURN服务器来实现数据传输,TURN协议就是解决此类问题的。
2.1 P2P实现的原理:
首先介绍一些基本概念,NAT(Network Address Translators),网络地址转换。网络地址转换是在IP地址日益缺乏的情况下产生的,它的主要目的是为了地址重用。NAT主要分为两类:基本的NAT和NAPT(Network Address/Port Translators)
最先提出的是NAT,由于在子网络中只有很少的节点需要与外网连接,那么这个子网络中其实只有少数节点需要全球唯一的IP地址,其他的节点的IP地址应该是可以重用的。因此,基本的NAT实现的功能很简单,当子网中的节点需要访问外网时,NAT负责将子网的IP转换为全球唯一的IP地址并发送出去(只会改变IP包中的原IP地址,不会改变端口号)。另外一种NAT叫做NAPT,这个是现在普遍使用的NAT,它会改变IP包中的原IP地址,同时也会修改原IP包中的端口号。
举个例子:
有一个私有网络中的主机A(IP:10.0.0.10,port:1234)想访问外网主机B(IP:18.181.0.31,port:1235),那么数据包通过NAT(假设外网IP为155.99.25.11,端口为6000)时会发生什么呢?
NAT会改变这个数据包的原地址和端口号,改为155.99.25.11,端口改为6000。NAT会记住6000端口对应的是(IP:10.0.0.10,port:1234),以后从外网主机B(IP:18.181.0.31,port:1235)发来的数据NAT会自动转发给主机A(IP:10.0.0.10,port:1234),其他的IP发送过来的数据将被NAT丢弃。
接上面的例子,如果主机A又向另外一个主机C发送了一个UDP包,那么这个UDP包在通过NAT时会发生什么呢?
这时可能会发生两种情况:
- 重新为主机A分配一个端口号,比如6001,这种就属于对称NAT,会导致很多P2P软件失灵
- 继续使用之前的端口号6000,这种叫锥形NAT(Cone NAT)。
Cone NAT又分为3种:
- 全克隆(Full Cone):NAT把所有来自相同内部主机的IP地址和端口的请求映射到相同的外部IP地址和端口,任何一个外部主机均可通过该映射访问内部该IP地址和端口对应的主机。
- 限制性克隆(Restricted Cone):在全克隆的基础上多了IP的限制。外部主机能访问内部主机的条件是内部主机发送过IP包给外部IP地址为X的主机,这时外部IP地址为X的主机才能访问该内部主机。
- 端口限制性克隆(Port Restricted Cone):在限制性克隆的基础上多了端口的限制。外部主机能访问内部主机的条件是内部主机发送过IP包给外部IP地址为X、端口为Y的主机,这时外部IP地址为X、端口为Y的主机才能访问该内部主机。
通过以上的信息我们可以知道,在有NAT存在的情况下,子网内的主机可以直接连接外部的主机,而外部的主机想访问内部的主机就比较困难了(这正是P2P需要的)。那么如果外部主机想访问内部主机该怎么做呢?
我们必须在NAT上打一个“洞”,这个洞只能由内部主机来打。而且这个洞是有方向的,比如从内部某台主机(192.168.0.10)向外部的某台主机(219.60.0.37)发送一个UDP包,这个时候我们就打了一个方向为219.60.0.37的洞,以后219.60.0.37可以通过这个“洞”来与内部主机(192.168.0.10)进行通信,但是其他的IP传过来的数据则会被直接丢弃。
2.2 P2P的常用实现
2.2.1 普通的直连式P2P实现
通过上面的理论,实现两个内网主机通讯就只差最后一步了:两边都无法主动发连接请求,那我们如何打这个洞呢?我们需要一个中间人来联系这两个主机。
举个例子:
Client A想访问Client B,首先,Client A登录服务器,NAT A为Client A分配了端口6000,Server S收到了Client A的地址为:202.187.43.2:6000,这就是A在公网中的地址。同样,Client B也登录Server S,NAT B为Client B分配了端口4000,Server S收到B的地址为:202.187.43.3:4000,,这就是B在公网中的地址。
此时,Client A可以通过Server S获取到B的地址202.187.43.3:4000,如果A直接向B发送数据包,这个数据包会被NAT B认为是不安全的,直接丢弃这个数据包。现在我们需要在NAT B上打一个方向为202.187.43.2:6000(即Client A的IP地址)的洞,Client A发送的数据包B就能收到了。那么这个打洞的命令由谁来发呢?自然是Server S。
总结一下这个过程:Client A想向Client B发送消息,那么Client A可以先向Server S发送命令,请求Server S命令Client B向Client A方向打洞,然后ClientA就可以通过Client B的外网地址与Client进行通信了。
注意,上面这个过程只适用于Cone NAT,如果是严格受限的NAT网络,当B向A打洞时,A的端口已经重新分配了,Client B无法知道这个端口,这时候P2P传输会失败。
2.2.2 STUN方式的P2P实现
STUN方式的探测过程需要有一个有公网IP的STUN Server,位于NAT后面的主机需要与STUN Server发送多个UDP数据包,UDP数据包中需要包含NAT外网IP、Port等信息。主机通过是否得到这个UDP包和包中的数据来判断NAT的类型。假设有主机B和Server S,Server C有两个IP:IPC1和IPC2,下面是NAT的探测过程:
- 判断是否在公网中:B向C的IPC1和port1发送一个UDP包,C收到后,它会把收到的源IP和端口写进UDP包中,并通过IPC1和port1发还给B,B收到该UDP包后,将IP地址与本地的IP地址对比,如果一样,则处在公网中,否则进行step2
- 检测NAT的类型:B向C的IPC1和port1发送一个UDP包,请求C用另外一个IP地址IPC2和port2给B返还一个UDP包。B如果能收到该UDP包,说明是full cone NAT,如果没收到,进行step3
- B向C的IPC2和port2发送一个UDP包,C收到后将源IP和port写入UDP包返还给B,B收到后,跟step1中的端口号进行对比,如果不一致,说明是对称NAT,原理很简单,根据对称NAT的规则,目的地址的IP和port有一个改变的时候,都会导致原地址的port重分配,此时就应该放弃p2p了。如果一致,进行step4
- B向C的IPC2发送一个UDP包,请求C使用另一个端口port返还数据。如果B能收到C传回的数据,说明只要IP一致,端口不一样也能收到数据,这就是restrict CONE NAT,否则是port restrict CONE NAT
3、ICE框架
基于信令协议的多媒体传输是一个两段式传输,首先通过信令协议(如WebSocket)建立一个连接,通过该连接,双方交换传输媒体时所必须的信息。
基于传输效率的考虑,在完成第一阶段的交互之后,通信双方会建立一条通道来实现媒体传输,以减少传输时延、降低丢包率并减少开销。由于使用新的链路,当传输双方有任意一方位于NAT之后,新的传输链路就需要考虑NAT穿越的问题了
通常有四种形式的NAT,每一种NAT都有相应的解决方案,然而在复杂的网络环境中,由于每一种NAT穿越方案都局限于对应的NAT方式,这些方案就给整个系统带来了一定的复杂性。在这种背景下,交互式连通建立方式(Interactive Connectivity Establishment)即ICE解决方案应运而生,ICE能够在不增加整个系统的复杂性和脆弱性的情况下,实现对各种形式的NAT的穿越。
ICE工作的核心
3.1 收集地址
其中派生地质是指:通过本地地址向STUN服务器发送STUN请求后获得的网络地址
服务器反向地址:终端经过一层或多层NAT穿透后,在STUN服务器上收到的经过NAT转换后的地址
中继地址:STUN服务器收到STUN请求后,在本地分配的一个代理地址。所有被路由到该代理地址的网络包都会被转发到服务器反向地址,继而穿透NAT发送到终端。
3.2 连通性检查
主机A收集到所有的候选地址后,对其按优先级进行排序,再将其作为SDP的属性通过信令信道发送给主机B。主机B收到主机A的候选地址后,执行相应的过程,将主机B的候选地址发送给主机A。这样主机A和主机B都拥有了一个完整的地址对,然后准备执行连通性检查。
执行连通性检查的原理:
- 按照优先顺序对地址对排序
- 逐个地址对去发送检查包
- 收到另一个主机的确认检查包
首先,主机A将本地候选地址对与远程地址对进行配对,比如本地有n个地址,远程有m个地址,则可以组成n×m个地址对。对这些地址对进行连通性检查是通过发送和接收STUN请求完成的。若通信双方以某一地址对完成一次四次握手过程,那么该地址对就是有效地址对。
四次握手:通过一对地址对中的本地地址给远程地址发送一个STUN请求,如果能成功收到响应,则称该地址对是可接收的;当地址对中的本地地址收到远程地址的一个STUN请求,并成功的响应,则称改地址是可发送的;如果一个地址对是可接收的,同时又是可发送的,则称该地址对是有效的,可以用于媒体传输。
3.3 对候选地址对进行排序
- 主机为每一个候选地址设置一个优先级,这个优先级连同候选地址对一起发送给远程主机
- 综合本地地址和远程地址的优先级,计算出每一个地址对的优先级,这样,双方同一个地址对的优先级相同
3.4 进行SDP编码
为了实现基于ICE的NAT穿越,增加了四个属性:
- candidate属性:提供多种可能地址中的一个。这个地址是使用STUN连通性检查有效的地址。
- remote-candidates属性:提供请求者想要应答者在应答中使用的远程候选传输地址标识。
- ice-pwd属性:提供用于保护STUN连通性检查的密码。
- ice-ufrag属性:提供用于在STUN连通性检查中组成用户名的片段。
4、WebRTC流程
下面我们介绍一个使用WebRTC实现音视频通话的例子,由于这个例子来自于我现在开发的项目,为避免代码量太大不便于理清流程,有一些优化配置相关的代码便做了一些删改:
4.1 主叫方流程
1、 初始化WebSocket作为我们的信令服务器,创建PeerConnection实例
/**
* WebSocket
* @param url WebSocket对应的url
* @param listeners 这是对信令服务器WebSocket的监听
* @return
*/
private IWsMgr obtainSignalWsMgr(final String url, final Collection<OnSignalWsMessageListener> listeners) {
JavaWsMgr wsMgr = JavaWsMgr.obtainWsMgr(url);//这里是根据url产生一个WebSocket实例
wsMgr.setWsStatusListener(new WsStatusListenerAdapter() {
@Override
public void onOpen(ServerHandshake serverHandshake) {
...
}
@Override
public void onMessage(String s) {
...
PhoneWsBean<String> phoneWsBean = parseObject(s, new TypeReference<PhoneWsBean<String>>();
...
// 处理信令事件
handleSignalEvent(url, s, phoneWsBean, listeners);
}
@Override
public void onClose(int code, String reason, boolean remote) {
...
}
@Override
public void onError(Exception e) {
...
}
});
return wsMgr;
}
这个方法里的handleSignalEvent
就是在处理信令事件,这个方法最终会调用ackIceServers
当收到服务器端发来的ackIceServers时,开始初始化PeerConnection:
@Override
public void ackIceServers(final ArrayList<IceServersBean> iceServers) {
...
//重点看这个方法
initPeer();
...
}
private void initPeer() {
...
// 这里创建PeerConnectionFactory
mPeerClient.createPeerConnectionFactory(this, mParameters, mIceServerParameters, mEvents);
...
// 这里创建PeerConnection
mPeerClient.createPeerConnection(mIsVideo ? mRootEglBase.getEglBaseContext() : null, sv_localView, sv_remoteView, mIsVideo, isActive);
}
我们看下PeerClient的createPeerConnection方法
public void createPeerConnection(
final EglBase.Context renderEGLContext, final VideoRenderer.Callbacks mLocalRender, final VideoRenderer.Callbacks mRemoteRender, final boolean isVideo, final boolean isCall ){
...
LinkedList<PeerConnection.IceServer> ret = new LinkedList<>();
// 配置STUN服务器
for (int i = 0; i < mIceServerParameters.list.size(); i++) {
IceServersBean bean = mIceServerParameters.list.get(i);
String username = bean.getUsername();
String credential = bean.getCredential();
username = username == null ? "" : username;
credential = credential == null ? "" : credential;
ret.add(new PeerConnection.IceServer(bean.getUrl(), username, credential));
}
PeerConnection.RTCConfiguration rtcConfig =
new PeerConnection.RTCConfiguration(ret);
// 这里做一些配置工作
rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED;
...//省略一些配置
rtcConfig.keyType = PeerConnection.KeyType.ECDSA;
// 用之前实例化好的PeerConnectionFactory创建PeerConnection,mPcObserver是一个监听
mPeerConnection = mFactory.createPeerConnection(
rtcConfig, createPcConstraints(), mPcObserver);
// 创建DataChannel,用于传输媒体数据
mDataChannel = mPeerConnection.createDataChannel(DATA_CHANNEL_TAG, init);
// 注册DataChannel监听
mDataChannel.registerObserver(new DcObserver());
}
/**
*DataChannel监听
*/
public class DcObserver implements DataChannel.Observer {
@Override
public void onBufferedAmountChange(long l) {
}
@Override
public void onStateChange() {
switch (mDataChannel.state()) {
...
case OPEN:
// 创建好DataChannel之后,被叫方创建answer
performAddStream();
mPeerConnection.createAnswer(mPassiveAnswerObserver, createSdpConstraint());
break;
}
}
@Override
public void onMessage(DataChannel.Buffer buffer) {
...
// 解析buffer,当收到被叫的Answer时,通话建立成功
handlerMessage(stringDataChannelBean);
}
}
2、 初始化PeerConnection之后,创建本地流,必须添加这个stream才能对方才能听到音频或者视频
private void performAddStream() {
if (mPeerConnection != null) {//这个步骤就是建立了通道就发出去;
mMediaStream = mFactory.createLocalMediaStream("ARDAMS");
// Create audio constraints.
mLocalAudioTrack = createAudioTrack();
mMediaStream.addTrack(mLocalAudioTrack);
mPeerConnection.addStream(mMediaStream);
}
}
3、 创建offer发给服务器
mPeerConnection.createOffer(mActiveCreateOfferObserver, createSdpConstraint());
mActiveCreateOfferObserver
是一个监听
private final SDPObserver mActiveCreateOfferObserver = new SDPObserver() {
private SessionDescription mSdp;
@Override
public void onCreateSuccess(final SessionDescription origSdp) {
mExecutor.execute(new Runnable() {
@Override
public void run() {
String sdpDescription = origSdp.description;
...
final SessionDescription sdp = new SessionDescription(
origSdp.type, sdpDescription);
//将会话描述设置在本地,
mPeerConnection.setLocalDescription(mActiveCreateOfferObserver, mSdp);
//接下来使用之前的WebSocket实例将offer发送给服务器,代码很简单就不贴上来了
}
});
}
@Override
public void onSetSuccess() {
...
}
};
4、 收到对方回复的preAnswer,设置会话描述
@Override
public void preAnswer(String fr, String frSid, String to, String toSid, final SessionDescription sdp) {
//主叫收到 preanswer
mPeerClient.setRemoteDescription(sdp, new PeerConnectionClient.SDPObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
}
@Override
public void onSetSuccess() {
}
});
}
以上就是打电话过程中主叫的一个完整WebRTC流程,接下来我们看下被叫的WebRTC流程。
4.2 被叫方WebRTC流程
1、 首先也是先要初始化WebSocket与PeerConnection,这个步骤跟主叫方一致
2、 第二步开始有一些区别,被叫方收到主叫方发过来的数据包后,会在本地设置会话描述并且创建preAnswer
private void passiveSetRemoteAndCreatePreAnswer() {
mPeerClient.setRemoteDescription(mSdp, new PeerConnectionClient.SDPObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
}
@Override
public void onSetSuccess() {
// 这里创建应答preAnswer
mPeerClient.passiveCreatePreAnswer();
}
});
}
/**
* 被叫方 创建 pre answer
*/
public void passiveCreatePreAnswer() {
...
// mPreAnswerObserver是一个监听
mPeerConnection.createAnswer(mPreAnswerObserver, createSdpConstraint());
...
}
/**
* 被叫方 创建 preanswer的观察者
*/
private final SDPObserver mPreAnswerObserver = new SDPObserver() {
@Override
public void onCreateSuccess(final SessionDescription origSdp) {
String sdpDescription = origSdp.description;
...
final SessionDescription sdp = new SessionDescription(
SessionDescription.Type.PRANSWER, sdpDescription); //新流程,被叫方收到 主叫方的offer以后 创建 pranswer
// 设置本地会话描述
mPeerConnection.setLocalDescription(mPreAnswerObserver, mSdp);
// 使用WebSocket发送answer给主叫方
sendPreAnswer(sdp);
}
@Override
public void onSetSuccess() {
...
}
}
3、 被叫点击接听电话以后,创建answer,到这一步就可以成功通话了
public void passiveTryCreateAnswer() {
// 添加媒体流
performAddStream();
// 创建应答answer,mPassiveAnswerObserver是创建过程的一个监听
mPeerConnection.createAnswer(mPassiveAnswerObserver, createSdpConstraint());
}
/**
* 被叫方创建 media sdp answer 的 监听
*/
private final SDPObserver mPassiveAnswerObserver = new SDPObserver() {
@Override
public void onCreateSuccess(final SessionDescription origSdp) {
String sdpDescription = origSdp.description;
final SessionDescription sdp = new SessionDescription(
origSdp.type, sdpDescription);
// 设置本地会话描述
mPeerConnection.setLocalDescription(mPassiveAnswerObserver, mSdp);
...
}
@Override
public void onSetSuccess() {
...
}
};
总结
到此,通话连接过程就算正式完成了。接下来我们总结一下通话的整个流程:
主叫方:
- 初始化WebSocket作为我们的信令服务器,监听WebSocket与服务器的交互过程
- 初始化PeerConnectionFactory,使用PeerConnectionFactory得到PeerConnection实例(
mFactory.createPeerConnection()
),创建DataChannel,注册DataChannel监听。 - 添加媒体流(
mPeerConnection.addStream()
) - 创建offer并发送,创建成功后设置本地会话描述。
- 收到被叫方的preAnswer,设置对方的会话描述到本地(
mPeerClient.setRemoteDescription()
) - 被叫方点击接听按钮后,会创建一个answer发给主叫方,收到这个answer后设置本地会话描述,通话就建立成功了
被叫方
- 开始两步跟主叫方一致,初始化WebSocket与PeerConnection
- 将主叫方会话描述设置到本地(
mPeerClient.setRemoteDescription()
),设置完成后发送preAnswer给主叫方 - 被叫方点击接听按钮以后会添加媒体流(
mPeerConnection.addStream()
)同时创建一个应答answer发送给主叫方,到此通话连接建立成功。
本文有部分内容参考自即时通讯网(http://www.52im.net/thread-265-1-1.html)