1、简介 本文将详细介绍如何利用WebRTC技术实现P2P音视频通话,并提供了一个跨平台的方案,包括:基于socket。io和Node。js实现的服务端,以及JavaScript和Android客户端。让我们一起来探讨如何搭建这个系统,以及如何编写代码吧。 由于server、js、android代码还在整理中,预计还需要23天时间。地址:github。comyangkun1992 下面是PC与IOS在不同网络环境下的效果图(WiFi移动网络): 2、服务端2。1使用nodejs和socket。io实现信令服务器 我们借助上一篇信令服务的流程图,来实现一个nodejs信令服务器 我们先设计一个信令 join:当前用户和远端用户加入到房间中的信令 leave:当前用户和远端用户离开房间的信令 message:交换双方的SDP、ICE信令 首先,我们需要搭建一个Node。js服务端,用于处理信令交换。在这里,我们将使用socket。io库作为通信协议,借助http、https、fs等组件。实现一个简单的Node。js服务端实例: createserver。js下面就是信令服务的核心代码varlog4jsrequire(log4js);varhttprequire(http);varhttpsrequire(https);varfsrequire(fs);varsocketIorequire(socket。io);varexpressrequire(express);varserveIndexrequire(serveindex);varUSERCOUNT3;。。。httpservervarhttpserverhttp。createServer(app);httpserver。listen(80,0。0。0。0);varoptions{key:fs。readFileSync(。certxxx。key),cert:fs。readFileSync(。certxxx。pem)}httpsservervarhttpsserverhttps。createServer(options,app);variosocketIo。listen(httpsserver);io。sockets。on(connection,(socket){socket。on(message,(room,data){socket。to(room)。emit(message,room,data);发送给当前房间的其它客户端});socket。on(join,(room){socket。join(room);varmyRoomio。sockets。adapter。rooms〔room〕;varusers(myRoom)?Object。keys(myRoom。sockets)。length:0;logger。debug(theusernumberofroomis:users);if(usersUSERCOUNT){socket。emit(joined,room,socket。id);发送给自己,相当于回调if(users1){socket。to(room)。emit(otherjoin,room,socket。id);发送给当前房间的其它客户端}}else{socket。leave(room);socket。emit(full,room,socket。id);}});socket。on(leave,(room){varmyRoomio。sockets。adapter。rooms〔room〕;varusers(myRoom)?Object。keys(myRoom。sockets)。length:0;logger。debug(theusernumberofroomis:(users1));socket。to(room)。emit(bye,room,socket。id);socket。emit(leaved,room,socket。id);});});httpsserver。listen(443,0。0。0。0); 要运行上面的server。js信令服务器,您需要按照以下步骤进行安装和运行:安装Node。js和npm:安装所需的依赖项npminstallexpresssocket。iofshttphttps启动servernodeserver。js2。2搭建sturnturn服务器 由于网络环境的影响我们需要搭建一个sturnturn服务器,以便提升P2P的成功率,下面是一个粗略的搭建方式,但是也够用了。安装Coturn 在终端中输入以下命令,使用yum包管理器安装Coturn:sudoyuminstallcoturn配置Coturn 找到并编辑Coturn的配置文件etccoturnturnserver。conf,根据您的需求修改以下配置项:配置监听的端口号listeningport3478minport49152maxport65535配置域名realmxxx。com允许使用TURNSTUN服务的用户的凭据user123456:123456certpathtoxxx。pempkeypathtoxxx。pem配置日志文件路径logfilerootlogturnserver。log启动Coturn 在终端中输入以下命令,启动Coturn服务:sudosystemctlstartcoturnsudosystemctlstopcoturnsudosystemctlrestartcoturnsudosystemctlstatuscoturn测试coturn我们可以去trickleice测试网站进行测试 正如trickleice网站所说:如果你测试一个STUN服务器,你能收集到一个类型为srflx的候选者,它就可以工作。如果你测试一个TURN服务器,你能收集到一个类型为relay的候选人,它就会工作。 由此上图sturn和turn候选者地址都能成功连接。 C音视频学习资料免费获取方法:关注音视频开发T哥,点击链接即可免费获取2023年最新C音视频开发进阶独家免费学习大礼包!3、客户端 WebRTC是一种基于Web技术的实时通信解决方案,可用于在浏览器中实现P2P音视频通话。当然,现在基本上所有上层平台都支持了。在WebRTC中,双方通信通过ICE协议进行连接,通过SDP协议交换媒体信息,通过DTLS协议进行加密,通过SRTP协议进行媒体传输。 下面,我们将为你介绍如何使用WebRTC在浏览器和Android中实现P2P音视频通话。3。1Web 我们按照上面信令的流程来实现:3。1。1获取媒体流 WebRTC支持从设备摄像头和麦克风获取视频和音频流。使用JavaScript的getUserMediaAPI,您可以请求用户授权,从摄像头和麦克风获取本地媒体流,并将其添加到一个MediaStream对象中。functionstartCall(){if(!navigator。mediaDevices!navigator。mediaDevices。getUserMedia){console。error(thegetUserMediaisnotsupported!);return;}else{varconstraints{video:true,传输视频audio:true传输音频}navigator。mediaDevices。getUserMedia(constraints)。then(getMediaStream)打开成功的回调。catch(handleError);打开失败}}3。1。2连接信令服务器并加入到房间中functionconnect(){连接信令服务器socketio。connect();加入成功的通知socket。on(joined,(roomid,id){。。。});远端加入socket。on(otherjoin,(roomid){。。。});房间满了socket。on(full,(roomid,id){。。。});接收自己离开房间的回调socket。on(leaved,(roomid,id){。。。});收到对方挂断的消息socket。on(bye,(room,id){。。。});收到服务断开的消息socket。on(disconnect,(socket){。。。});收消息,用于交换SDP和ICE消息等socket。on(message,(roomid,data){。。。});发送join消息到信令服务器并加入到123456房间中socket。emit(join,123456);}3。1。3创建PeerConnection并添加媒体轨道 当收到自己加入房间成功的消息后,连接到远程对等方,我们就需要创建一个RTCPeerConnection对象,并将本地媒体流添加到其中。然后,您需要创建一个RTCDataChannel对象,用于在对等方之间传输数据。varpcConfig{iceServers:〔{urls:turn:xxx:3478,credential:1234,username:1234}〕};pcnewRTCPeerConnection(pcConfig);当前icecandida数据pc。onicecandidate(e){。。。}datachannel传输通道pc。ondatachannele{。。。}添加远端的媒体流到videoelementpc。ontrackgetRemoteStream;最后添加媒体轨道到peerconnection对象中localStream。getTracks()。forEach((track){pc。addTrack(track,localStream);});创建一个非音视频的数据通道dcpc。createDataChannel(test);dc。onmessagereceivemsg;接收对端消息dc。onopendataChannelStateChange;当打开dc。onclosedataChannelStateChange;当关闭functiongetRemoteStream(e){remoteStreame。streams〔0〕;remoteVideo。srcObjecte。streams〔0〕;}3。1。4发送createOffer数据到远端 当对方加入到房间中,我们需要把当前UserA的SDP信息告诉UserB用户,使用如下代码varofferOptions{同时接收远端的音、视频数据offerToRecieveAudio:1,offerToRecieveVideo:1}pc。createOffer(offerOptions)。then(getOffer)创建成功的回调。catch(handleOfferError);functiongetOffer(desc){设置UserASDP信息pc。setLocalDescription(desc);offerdescdesc;将usera的SDP发送到信令服务器,信令服务器再根据roomid进行转发sendMessage(roomid,offerdesc);}3。1。5发送answer消息到对方 当UserB收到UserA发来的offer消息,我们需要设置UserA的SDP并且设置当前的SDP然后再讲自己的SDP发送给UserA,以进行媒体协商,如下代码:1。当收到UserAOFFER消息,设置SDPpc。setRemoteDescription(newRTCSessionDescription(data));2。然后创建answer消息pc。createAnswer()。then(getAnswer)。catch(handleAnswerError);3。当创建成功后,拿到UserB自己的SDP消息并设置当前的SDP信息,最后再讲SDP消息发给信令再转发给roomid房间中的客户端functiongetAnswer(desc){pc。setLocalDescription(desc);optBw。disabledfalse;sendanswersdpsendMessage(roomid,desc);}3。1。6接收answer消息,并设置UserB的SDP信息 当我们收到UserB发来的answersdp消息后告诉底层pc。setRemoteDescription(newRTCSessionDescription(data));3。1。7交换ICE候选 SDP协商完后,UserAUserB交换ice消息,用于nat和转发媒体数据,如果都在局域网其实可以省略这一步userAUserB收到onicecandidate回调然后将candidate发送给UserBpc。onicecandidate(e){if(e。candidate){sendMessage(roomid,{type:candidate,label:event。candidate。sdpMLineIndex,id:event。candidate。sdpMid,candidate:event。candidate。candidate});}else{console。log(thisistheendcandidate);}}当UserBUserA接收到UserAUserB的candidate后进行添加functionaddIcecandida(data){varcandidatenewRTCIceCandidate({sdpMLineIndex:data。label,candidate:data。candidate});pc。addIceCandidate(candidate)。then((){console。log(Successedtoaddicecandidate);})。catch(err{console。error(err);});} 通过如上核心步骤代码,你已经完成了一个基于WebRTCJS版的跨平台P2P音视频通话系统。当然,这里展示的代码只是简化版示例,完整版的代码可以点击文末简介处有说明。3。2Android 上面我们实现了服务端和跨平台的JS端,最后我们实现一个Android端,毕竟最开始我就是搞Android的。 对于Android客户端,您可以使用Google提供的WebRTC库。如下,当前也可以直接依赖javac源码。当前我们就是直接依赖的javac源码 依赖wertcsdk方式,在build。gradle文件中添加依赖项:implementationorg。webrtc:googlewebrtc:1。0。 依赖wertc源码方式,在build。gradle文件中添加如下设置:externalNativeBuild{cmake{version3。10。2pathCMakeLists。txt}} 没错,我们通过编写cmake直接依赖的c源码。好了,依赖方式就不再多说了,可以直接去看项目中的build。gradle文件即可。 Android上的实现步骤流程与JS几乎一样,我们来看一下如何实现吧。3。2。1获取媒体流并初始化PeerConnectionFactory 这里我们直接通过Camera2来实现相机数据的采集privateVideoCapturercreateVideoCapture(){finalVideoCapturervideoCapturer;videoCapturercreateCameraCapturer(newCamera2Enumerator(this));returnvideoCapturer;}设置本地预览窗口mLocalSurfaceView。init(mRootEglBase。getEglBaseContext(),null);mLocalSurfaceView。setScalingType(RendererCommon。ScalingType。SCALEASPECTFILL);mLocalSurfaceView。setMirror(true);mLocalSurfaceView。setEnableHardwareScaler(falseenabled);设置远端预览窗口mRemoteSurfaceView。init(mRootEglBase。getEglBaseContext(),null);mRemoteSurfaceView。setScalingType(RendererCommon。ScalingType。SCALEASPECTFILL);mRemoteSurfaceView。setMirror(true);mRemoteSurfaceView。setEnableHardwareScaler(trueenabled);mRemoteSurfaceView。setZOrderMediaOverlay(true);callStartedTimeMsSystem。currentTimeMillis();创建factory,pc是从factory里获得的createPeerConnectionFactory();privatevoidcreatePeerConnectionFactory(){finalStringfieldTrialsgetFieldTrials(mPeerConnectionParameters);executor。execute((){Log。d(Constants。P2PTAG,InitializeWebRTC。Fieldtrials:fieldTrials);PeerConnectionFactory。initialize(PeerConnectionFactory。InitializationOptions。builder(mContext)。setFieldTrials(fieldTrials)。setEnableInternalTracer(true)。createInitializationOptions());});executor。execute((){createPeerConnectionFactoryInternal();});}3。2。2连接信令服务器并加入到房间中publicvoidconnectToRoom(RoomConnectionParametersparameters,ISignalEventListenersignalEventListener){mRoomConnectParametersparameters;executor。execute((){if(mISignalClient!null){try{mISignalClient。connect(parameters。roomUrl,newISignalEventListener(){OverridepublicvoidOnConnecting(){Log。i(Constants。P2PTAG,OnConnecting);。。。}OverridepublicvoidOnConnected(){Log。i(Constants。P2PTAG,OnConnected);Log。i(Constants。P2PTAG,join:parameters。roomId);mISignalClient。join(parameters。roomId);。。。}OverridepublicvoidOnDisconnected(){if(signalEventListener!null){signalEventListener。OnConnecting();}}OverridepublicvoidOnUserJoined(StringroomName,StringuserId,booleanisInitiator){if(signalEventListener!null){signalEventListener。OnUserJoined(roomName,userId,isInitiator);}Log。i(Constants。P2PTAG,joined:roomNameuserIdisInitiator);Log。i(Constants。P2PTAG,createPeerConnection);。。。}OverridepublicvoidOnUserLeaved(StringroomName,StringuserId){。。。}OverridepublicvoidOnRemoteUserJoined(StringroomName,StringuserId){Log。i(Constants。P2PTAG,createOfferroomNameuserId);。。。}OverridepublicvoidOnRemoteUserLeaved(StringroomName,StringuserId){。。。}OverridepublicvoidOnRoomFull(StringroomName,StringuserId){。。。}OverridepublicvoidOnMessage(JSONObjectmessage){。。。}});}catch(Exceptione){Log。e(TAG,e。getMessage());}}});}3。2。3创建PeerConnection并添加媒体轨道 当收到自己加入房间成功的消息后,连接到远程对等方,我们就需要创建一个PeerConnection对象,并将本地媒体流添加到其中。然后,您需要创建一个DataChannel对象,用于在对等方之间传输数据。 简要代码如下:当连接成功并且进入到房间中执行privatevoidcreatePeerConnection(){executor。execute((){try{createMediaConstraintsInternal();createPeerConnectionInternal();Log。i(Constants。P2PTAG,createPeerConnectionSucceed);}catch(Exceptione){Log。e(TAG,Failedtocreatepeerconnection:e。getMessage());throwe;}});}privatevoidcreateMediaConstraintsInternal(){Createvideoconstraintsifvideocallisenabled。。。。Createaudioconstraints。mAudioConstraintsnewMediaConstraints();addedforaudioperformancemeasurementsif(mPeerConnectionParameters。noAudioProcessing){Log。d(TAG,Disablingaudioprocessing);mAudioConstraints。mandatory。add(newMediaConstraints。KeyValuePair(AUDIOECHOCANCELLATIONCONSTRAINT,false));mAudioConstraints。mandatory。add(newMediaConstraints。KeyValuePair(AUDIOAUTOGAINCONTROLCONSTRAINT,false));mAudioConstraints。mandatory。add(newMediaConstraints。KeyValuePair(AUDIOHIGHPASSFILTERCONSTRAINT,false));mAudioConstraints。mandatory。add(newMediaConstraints。KeyValuePair(AUDIONOISESUPPRESSIONCONSTRAINT,false));}CreateSDPconstraints。mSdpMediaConstraintsnewMediaConstraints();mSdpMediaConstraints。mandatory。add(newMediaConstraints。KeyValuePair(OfferToReceiveAudio,true));mSdpMediaConstraints。mandatory。add(newMediaConstraints。KeyValuePair(OfferToReceiveVideo,Boolean。toString(isVideoCallEnabled())));}privatevoidcreatePeerConnectionInternal(){if(mPeerConnectionFactorynull){Log。e(TAG,Peerconnectionfactoryisnotcreated);return;}Log。d(TAG,Createpeerconnection。);queuedRemoteCandidatesnewArrayList();ListPeerConnection。IceServericeServersnewArrayList();iceServers。add(PeerConnection。IceServer。builder(turn:xxx:3478)。setPassword(xxx)。setUsername(xxx)。createIceServer());PeerConnection。RTCConfigurationrtcConfignewPeerConnection。RTCConfiguration(iceServers);TCPcandidatesareonlyusefulwhenconnectingtoaserverthatsupportsICETCP。rtcConfig。tcpCandidatePolicyPeerConnection。TcpCandidatePolicy。DISABLED;rtcConfig。bundlePolicyPeerConnection。BundlePolicy。MAXBUNDLE;rtcConfig。rtcpMuxPolicyPeerConnection。RtcpMuxPolicy。REQUIRE;rtcConfig。continualGatheringPolicyPeerConnection。ContinualGatheringPolicy。GATHERCONTINUALLY;UseECDSAencryption。rtcConfig。keyTypePeerConnection。KeyType。ECDSA;rtcConfig。sdpSemanticsPeerConnection。SdpSemantics。UNIFIEDPLAN;mPeerConnectionmPeerConnectionFactory。createPeerConnection(rtcConfig,pcObserver);if(dataChannelEnabled){DataChannel。InitinitnewDataChannel。Init();init。orderedmPeerConnectionParameters。dataChannelParameters。ordered;init。negotiatedmPeerConnectionParameters。dataChannelParameters。negotiated;init。maxRetransmitsmPeerConnectionParameters。dataChannelParameters。maxRetransmits;init。maxRetransmitTimeMsmPeerConnectionParameters。dataChannelParameters。maxRetransmitTimeMs;init。idmPeerConnectionParameters。dataChannelParameters。id;init。protocolmPeerConnectionParameters。dataChannelParameters。protocol;mDataChannelmPeerConnection。createDataChannel(P2Pdata,init);}isInitiatorfalse;SetINFOlibjinglelogging。NOTE:thismusthappenwhilefactoryisalive!Logging。enableLogToDebugOutput(Logging。Severity。LSINFO);ListStringmediaStreamLabelsCollections。singletonList(ARDAMS);if(isVideoCallEnabled()){mPeerConnection。addTrack(createVideoTrack(mVideoCapture),mediaStreamLabels);Wecanaddtherenderersrightawaybecausewedontneedtowaitforananswertogettheremotetrack。remoteVideoTrackgetRemoteVideoTrack();remoteVideoTrack。setEnabled(renderVideo);目前就一个remoteVideoTrack。addSink(mRemoteSurfaceView);}mPeerConnection。addTrack(createAudioTrack(),mediaStreamLabels);if(isVideoCallEnabled()){findVideoSender();}}3。2。4发送createOffer数据到远端 当对方加入到房间中,我们需要把当前UserA的SDP信息告诉UserB用户,使用如下代码publicvoidcreateOffer(){executor。execute((){if(mPeerConnection!null){Log。d(Constants。P2PTAG,PCCreateOFFER);isInitiatortrue;1。createoffermPeerConnection。createOffer(sdpObserver,mSdpMediaConstraints);}});}2。当createOffer成功我们会收到如下回调OverridepublicvoidonCreateSuccess(finalSessionDescriptiondesc){然后我们需要设置当前的SDPmPeerConnection。setLocalDescription(sdpObserver,newDesc);}3。当设置成功后,我们会收到onSetSuccess回调,然后将UserASDPoffer消息发送给对等方OverridepublicvoidonSetSuccess(){JSONObjectmessagenewJSONObject();try{Stringtypeoffer;if(sdp。typeSessionDescription。Type。ANSWER)typeanswer;message。put(type,type);message。put(sdp,sdp。description);sendMessage(message);}catch(JSONExceptione){e。printStackTrace();}}3。2。5发送answer消息到对方 当UserB收到UserAoffer消息后的处理1。设置UserASDP描述符mPeerConnection。setRemoteDescription(sdpObserver,sdpRemote);if(desc。typeSessionDescription。Type。OFFER){Log。i(Constants。P2PTAG,CreatingANSWER。。。);2。创建answermPeerConnection。createAnswer(sdpObserver,mSdpMediaConstraints);}3。answer创建成功后的处理mPeerConnection。setLocalDescription(sdpObserver,newDesc);4。UserB设置成功后的处理,将sdp发给UserAJSONObjectmessagenewJSONObject();try{Stringtypeoffer;if(sdp。typeSessionDescription。Type。ANSWER)typeanswer;message。put(type,type);message。put(sdp,sdp。description);sendMessage(message);}catch(JSONExceptione){e。printStackTrace();}3。2。6接收answer消息,并设置UserB的SDP信息 当我们收到UserB发来的answersdp消息后告诉底层mPeerConnection。setRemoteDescription(sdpObserver,sdpRemote);3。2。7交换ICE候选 SDP协商完后,UserAUserB交换ice消息,用于nat和转发媒体数据,如果都在局域网其实可以省略这一步userAUserB收到onicecandidate回调然后将candidate发送给UserBOverridepublicvoidonIceCandidate(finalIceCandidateiceCandidate){executor。execute((){Log。i(Constants。P2PTAG,onIceCandidate:iceCandidate);try{JSONObjectmessagenewJSONObject();message。put(type,candidate);message。put(label,iceCandidate。sdpMLineIndex);message。put(id,iceCandidate。sdpMid);message。put(candidate,iceCandidate。sdp);mISignalClient。sendSignalMessage(mRoomConnectParameters。roomId,message);}catch(JSONExceptione){e。printStackTrace();}});}当UserBUserA接收到UserAUserB的candidate后进行添加mPeerConnection。addIceCandidate(candidate,newAddIceObserver(){。。。}} 通过如上核心步骤代码,你已经完成了一个基于WebRTCAndroid版的P2P音视频通话系统。当然,这里展示的代码只是简化版示例,完整版的代码可以点击文末简介处有说明。 到此,你已经可以JSJS、AndroidAndroid、JSAndroid平台下进行P2P的音视频通话了。4、总结 本文为你介绍了如何基于WebRTC实现一个P2P音视频通话系统,和提供了一个跨平台的实现方案,主要包括以下三个部分:服务端:使用Node。js和socket。io构建的信令服务器,负责协调通信和传递ICE候选、SDP信息。客户端(跨平台):基于WebRTC的JavaScript客户端,实现浏览器端的音视频通话功能。客户端:Android客户端,使用Google提供的WebRTC库构建音视频通话应用。 请注意,本文提供的代码是简化版示例,您可以根据项目需求进行扩展和优化。通过本教程,您应该对如何使用WebRTC构建P2P音视频通话系统有了更深入的了解,并能将其应用于实际项目中。 到此,P2P音视频通话系统我们已经实现完了,下一篇我们会介绍视频会议的实现方案,尽请期待吧。 原文链接:WebRTC实战:P2P音视频通话解决方案掘金