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通信

现实网络中主要有三种环境:

  1. 公共网络:这类网络主机之间可以不受限制地进行直接访问,现实中这种网络很少存在。
  2. NAT网络:这类网络中主机位于私有网络中,没有单独的公有IP,访问私有网络中的主机需要打洞,通过打洞我们可以发现主机在公网中的IP,STUN协议就是用来帮助实现这一功能的。
  3. 严格受限的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的探测过程:

  1. 判断是否在公网中:B向C的IPC1和port1发送一个UDP包,C收到后,它会把收到的源IP和端口写进UDP包中,并通过IPC1和port1发还给B,B收到该UDP包后,将IP地址与本地的IP地址对比,如果一样,则处在公网中,否则进行step2
  2. 检测NAT的类型:B向C的IPC1和port1发送一个UDP包,请求C用另外一个IP地址IPC2和port2给B返还一个UDP包。B如果能收到该UDP包,说明是full cone NAT,如果没收到,进行step3
  3. B向C的IPC2和port2发送一个UDP包,C收到后将源IP和port写入UDP包返还给B,B收到后,跟step1中的端口号进行对比,如果不一致,说明是对称NAT,原理很简单,根据对称NAT的规则,目的地址的IP和port有一个改变的时候,都会导致原地址的port重分配,此时就应该放弃p2p了。如果一致,进行step4
  4. 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 收集地址

android rtc服务修改硬件时钟 安卓 rtc_NAT


  其中派生地质是指:通过本地地址向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() {
            ...
        }
    };

总结

到此,通话连接过程就算正式完成了。接下来我们总结一下通话的整个流程:

主叫方:
  1. 初始化WebSocket作为我们的信令服务器,监听WebSocket与服务器的交互过程
  2. 初始化PeerConnectionFactory,使用PeerConnectionFactory得到PeerConnection实例(mFactory.createPeerConnection()),创建DataChannel,注册DataChannel监听。
  3. 添加媒体流(mPeerConnection.addStream()
  4. 创建offer并发送,创建成功后设置本地会话描述。
  5. 收到被叫方的preAnswer,设置对方的会话描述到本地(mPeerClient.setRemoteDescription()
  6. 被叫方点击接听按钮后,会创建一个answer发给主叫方,收到这个answer后设置本地会话描述,通话就建立成功了
被叫方
  1. 开始两步跟主叫方一致,初始化WebSocket与PeerConnection
  2. 将主叫方会话描述设置到本地(mPeerClient.setRemoteDescription()),设置完成后发送preAnswer给主叫方
  3. 被叫方点击接听按钮以后会添加媒体流(mPeerConnection.addStream())同时创建一个应答answer发送给主叫方,到此通话连接建立成功。

本文有部分内容参考自即时通讯网(http://www.52im.net/thread-265-1-1.html