当前位置: 首页 > news >正文

WebRTC 系列(三、点对点通话,H5、Android、iOS)

WebRTC 系列(二、本地 demo,H5、Android、iOS)

上一篇博客中,我已经展示了各端的本地 demo,大家应该知道 WebRTC 怎么用了。在本地 demo 中是用了一个 RemotePeerConnection 来模拟远端,可能理解起来还有点麻烦,下面就来实现点对点通话,这个 demo 完成后,流程会更加清晰。

一、信令服务器

既然不同端之间要通信,那就需要一个中间人来做桥梁,传递通信链路建立之前的信息,也就是 offer、answer、iceCandidate 这些信息。信令服务器的实现手段也有很多,可以通过 SocketIO、WebSocket、Netty 等。

这里我就选择用 Java 通过 WebSocket 搭建一个信令服务器了,后续可能还会写个 nodejs 版的。

在 Android Studio 中新建一个项目,然后在项目中创建一个 Java Module,到时候就可以在 Java Module 中运行 main 方法了,这样就不用再下载一个 IDEA 了。

Java Module 的 build 中添加 WebSocket 依赖:

plugins {id 'java-library'
}java {sourceCompatibility = JavaVersion.VERSION_1_7targetCompatibility = JavaVersion.VERSION_1_7
}dependencies {// WebSocketimplementation 'org.java-websocket:Java-WebSocket:1.5.3'
}

然后编写 WebSocket 服务端代码:

package com.qinshou.webrtcdemo_server;import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;public class WebSocketServerHelper {private WebSocketServer mWebSocketServer;private final List<WebSocket> mWebSockets = new ArrayList<>();private static final String HOST_NAME = "192.168.1.105";private static final int PORT = 8888;public void start() {InetSocketAddress inetSocketAddress = new InetSocketAddress(HOST_NAME, PORT);mWebSocketServer = new WebSocketServer(inetSocketAddress) {@Overridepublic void onOpen(WebSocket conn, ClientHandshake handshake) {System.out.println("onOpen--->" + conn);// 客户端连接时保存到集合中mWebSockets.add(conn);}@Overridepublic void onClose(WebSocket conn, int code, String reason, boolean remote) {System.out.println("onClose--->" + conn);// 客户端断开时从集合中移除mWebSockets.remove(conn);}@Overridepublic void onMessage(WebSocket conn, String message) {
//                System.out.println("onMessage--->" + message);// 消息直接透传给除发送方以外的连接for (WebSocket webSocket : mWebSockets) {if (webSocket != conn) {webSocket.send(message);}}}@Overridepublic void onError(WebSocket conn, Exception ex) {System.out.println("onError--->" + conn + ", ex--->" + ex);// 客户端连接异常时从集合中移除mWebSockets.remove(conn);}@Overridepublic void onStart() {System.out.println("onStart");}};mWebSocketServer.start();}public void stop() {if (mWebSocketServer == null) {return;}for (WebSocket webSocket : mWebSocketServer.getConnections()) {webSocket.close();}try {mWebSocketServer.stop();} catch (InterruptedException e) {throw new RuntimeException(e);}mWebSocketServer = null;}public static void main(String[] args) {new WebSocketServerHelper().start();}
}

p2p 通信场景下信令服务器不需要做太多,只需要分发消息即可,为了简单,我也没有引入用户和房间等概念,所以在测试的时候,只能连接两个客户端。

二、消息格式

既然我们需要将 sdp 和 iceCandidate 传递给别人,那双方就得约定一个格式,这样传递给对方后对方才能解析,p2p 阶段我们只需要定义 sdp 和 iceCandidate 消息即可,其中 sdp :

// sdp
{"msgType": "sdp","type": sessionDescription.type,"sdp": sessionDescription.sdp
}// iceCandidate
{"msgType": "iceCandidate","id": iceCandidate.sdpMid,"label": iceCandidate.sdpMLineIndex,"candidate": iceCandidate.candidate
}

三、H5

代码与 local_demo 其实差不了太多,只是要将模拟远端的 RemotePeerConnection 去掉,在主动呼叫或收到 offer 时创建一个 PeerConnection 就可以。然后把发送 sdp、iceCandidate 的地方改成通过 WebSocket 发送即可,所以我们还需要创建一个 WebSocket 客户端。

1.添加依赖

WebSocket 也是 H5 的标准之一,所以不需要我们额外引入。

2.p2p_demo.html

<html><head><title>P2P Demo</title><style>body {overflow: hidden;margin: 0px;padding: 0px;}#local_view {width: 100%;height: 100%;}#remote_view {width: 9%;height: 16%;position: absolute;top: 10%;right: 10%;}#left {width: 10%;height: 5%;position: absolute;left: 10%;top: 10%;}#p_websocket_state,#input_server_url,.my_button {width: 100%;height: 100%;display: block;margin-bottom: 10%;}</style>
</head><body><video id="local_view" autoplay controls muted></video><video id="remote_view" autoplay controls muted></video><div id="left"><p id="p_websocket_state">WebSocket 已断开</p><input id="input_server_url" type="text" placeholder="请输入服务器地址" value="ws://192.168.1.105:8888"></input><button id="btn_connect" class="my_button" onclick="connect()">连接 WebSocket</button><button id="btn_disconnect" class="my_button" onclick="disconnect()">断开 WebSocket</button><button id="btn_call" class="my_button" onclick="call()">呼叫</button><button id="btn_hang_up" class="my_button" onclick="hangUp()">挂断</button></div>
</body><script type="text/javascript">let localView = document.getElementById("local_view");let remoteView = document.getElementById("remote_view");var localStream;var peerConnection;function createPeerConnection() {let rtcPeerConnection = new RTCPeerConnection();rtcPeerConnection.oniceconnectionstatechange = function (event) {if ("disconnected" == event.target.iceConnectionState) {hangUp();}}rtcPeerConnection.onicecandidate = function (event) {console.log("onicecandidate--->" + event.candidate);let iceCandidate = event.candidate;if (iceCandidate == null) {return;}sendIceCandidate(iceCandidate);}rtcPeerConnection.ontrack = function (event) {console.log("remote ontrack--->" + event.streams);let streams = event.streams;if (streams && streams.length > 0) {remoteView.srcObject = streams[0];}}return rtcPeerConnection}function call() {// 创建 PeerConnectionpeerConnection = createPeerConnection();// 为 PeerConnection 添加音轨、视轨for (let i = 0; localStream != null && i < localStream.getTracks().length; i++) {const track = localStream.getTracks()[i];peerConnection.addTrack(track, localStream);}// 通过 PeerConnection 创建 offer,获取 sdppeerConnection.createOffer().then(function (sessionDescription) {console.log("create offer success.");// 将 offer sdp 作为参数 setLocalDescription;peerConnection.setLocalDescription(sessionDescription).then(function () {console.log("set local sdp success.");// 发送 offer sdpsendOffer(sessionDescription)})})}function sendOffer(offer) {var jsonObject = {"msgType": "sdp","type": offer.type,"sdp": offer.sdp};send(JSON.stringify(jsonObject));}function receivedOffer(offer) {// 创建 PeerConnectionpeerConnection = createPeerConnection();// 为 PeerConnection 添加音轨、视轨for (let i = 0; localStream != null && i < localStream.getTracks().length; i++) {const track = localStream.getTracks()[i];peerConnection.addTrack(track, localStream);}// 将 offer sdp 作为参数 setRemoteDescriptionpeerConnection.setRemoteDescription(offer).then(function () {console.log("set remote sdp success.");// 通过 PeerConnection 创建 answer,获取 sdppeerConnection.createAnswer().then(function (sessionDescription) {console.log("create answer success.");// 将 answer sdp 作为参数 setLocalDescriptionpeerConnection.setLocalDescription(sessionDescription).then(function () {console.log("set local sdp success.");// 发送 answer sdpsendAnswer(sessionDescription);})})})}function sendAnswer(answer) {var jsonObject = {"msgType": "sdp","type": answer.type,"sdp": answer.sdp};send(JSON.stringify(jsonObject));}function receivedAnswer(answer) {// 收到 answer sdp,将 answer sdp 作为参数 setRemoteDescriptionpeerConnection.setRemoteDescription(answer).then(function () {console.log("set remote sdp success.");})}function sendIceCandidate(iceCandidate) {var jsonObject = {"msgType": "iceCandidate","id": iceCandidate.sdpMid,"label": iceCandidate.sdpMLineIndex,"candidate": iceCandidate.candidate};send(JSON.stringify(jsonObject));}function receivedCandidate(iceCandidate) {peerConnection.addIceCandidate(iceCandidate);}function hangUp() {if (peerConnection != null) {peerConnection.close();peerConnection = null;}remoteView.removeAttribute('src');remoteView.load();}navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (mediaStream) {// 初始化 PeerConnectionFactory// 创建 EglBase// 创建 PeerConnectionFactory// 创建音轨// 创建视轨localStream = mediaStream;// 初始化本地视频渲染控件// 初始化远端视频渲染控件// 开始本地渲染localView.srcObject = mediaStream;}).catch(function (error) {console.log("error--->" + error);})</script><script type="text/javascript">var websocket;function connect() {let inputServerUrl = document.getElementById("input_server_url");let pWebsocketState = document.getElementById("p_websocket_state");let url = inputServerUrl.value;websocket = new WebSocket(url);websocket.onopen = function () {console.log("onOpen");pWebsocketState.innerText = "WebSocket 已连接";}websocket.onmessage = function (message) {console.log("onmessage--->" + message.data);let jsonObject = JSON.parse(message.data);let msgType = jsonObject["msgType"];if ("sdp" == msgType) {let type = jsonObject["type"];if ("offer" == type) {let options = {"type": jsonObject["type"],"sdp": jsonObject["sdp"]}let offer = new RTCSessionDescription(options);receivedOffer(offer);} else if ("answer" == type) {let options = {"type": jsonObject["type"],"sdp": jsonObject["sdp"]}let answer = new RTCSessionDescription(options);receivedAnswer(answer);}} else if ("iceCandidate" == msgType) {let options = {"sdpMLineIndex": jsonObject["label"],"sdpMid": jsonObject["id"],"candidate": jsonObject["candidate"]}let iceCandidate = new RTCIceCandidate(options);receivedCandidate(iceCandidate);}}websocket.onclose = function (error) {console.log("onclose--->" + error);pWebsocketState.innerText = "WebSocket 已断开";}websocket.onerror = function (error) {console.log("onerror--->" + error);}}function disconnect() {websocket.close();}function send(message) {if (!websocket) {return;}websocket.send(message);}</script></html>

主要流程都是一样的,有什么不懂的地方可以留言,由于需要 p2p 通话至少需要两个端,我们就等所有端都实现了再最后任选两个端来看效果。

四、Android

1.添加依赖

Android 则需要在 app 的 build.gradle 中引入 WebSocket 依赖:

// WebSocket
implementation 'org.java-websocket:Java-WebSocket:1.5.3'

权限申请跟之前的一样,就不重复了。

2.布局

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"android:background="#FF000000"android:keepScreenOn="true"tools:context=".P2PDemoActivity"><org.webrtc.SurfaceViewRendererandroid:id="@+id/svr_local"android:layout_width="match_parent"android:layout_height="0dp"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintDimensionRatio="9:16"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent" /><org.webrtc.SurfaceViewRendererandroid:id="@+id/svr_remote"android:layout_width="90dp"android:layout_height="0dp"android:layout_marginTop="30dp"android:layout_marginEnd="30dp"app:layout_constraintDimensionRatio="9:16"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintTop_toTopOf="parent" /><androidx.appcompat.widget.LinearLayoutCompatandroid:layout_width="match_parent"android:layout_height="wrap_content"android:layout_marginStart="30dp"android:layout_marginTop="30dp"android:layout_marginEnd="30dp"android:orientation="vertical"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent"><androidx.appcompat.widget.AppCompatTextViewandroid:id="@+id/tv_websocket_state"android:layout_width="match_parent"android:layout_height="wrap_content"android:text="WebSocketServer 已断开"android:textColor="#FFFFFFFF" /><androidx.appcompat.widget.AppCompatEditTextandroid:id="@+id/et_server_url"android:layout_width="match_parent"android:layout_height="wrap_content"android:hint="请输入服务器地址"android:textColor="#FFFFFFFF"android:textColorHint="#FFFFFFFF" /><androidx.appcompat.widget.AppCompatButtonandroid:id="@+id/btn_connect"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="连接 WebSocket"android:textAllCaps="false" /><androidx.appcompat.widget.AppCompatButtonandroid:id="@+id/btn_disconnect"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="断开 WebSocket"android:textAllCaps="false" /><androidx.appcompat.widget.AppCompatButtonandroid:id="@+id/btn_call"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="呼叫" /><androidx.appcompat.widget.AppCompatButtonandroid:id="@+id/btn_hang_up"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="挂断" /></androidx.appcompat.widget.LinearLayoutCompat>
</androidx.constraintlayout.widget.ConstraintLayout>

3.P2PDemoActivity

package com.qinshou.webrtcdemo_android;import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;import androidx.appcompat.app.AppCompatActivity;import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
import org.webrtc.Camera2Capturer;
import org.webrtc.Camera2Enumerator;
import org.webrtc.CameraEnumerator;
import org.webrtc.DataChannel;
import org.webrtc.DefaultVideoDecoderFactory;
import org.webrtc.DefaultVideoEncoderFactory;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints;
import org.webrtc.MediaStream;
import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.RtpReceiver;
import org.webrtc.SessionDescription;
import org.webrtc.SurfaceTextureHelper;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoDecoderFactory;
import org.webrtc.VideoEncoderFactory;
import org.webrtc.VideoSource;
import org.webrtc.VideoTrack;import java.util.ArrayList;
import java.util.List;/*** Author: MrQinshou* Email: cqflqinhao@126.com* Date: 2023/3/21 17:22* Description: P2P demo*/
public class P2PDemoActivity extends AppCompatActivity {private static final String TAG = P2PDemoActivity.class.getSimpleName();private static final String AUDIO_TRACK_ID = "ARDAMSa0";private static final String VIDEO_TRACK_ID = "ARDAMSv0";private static final List<String> STREAM_IDS = new ArrayList<String>() {{add("ARDAMS");}};private static final String SURFACE_TEXTURE_HELPER_THREAD_NAME = "SurfaceTextureHelperThread";private static final int WIDTH = 1280;private static final int HEIGHT = 720;private static final int FPS = 30;private EglBase mEglBase;private PeerConnectionFactory mPeerConnectionFactory;private VideoCapturer mVideoCapturer;private AudioTrack mAudioTrack;private VideoTrack mVideoTrack;private PeerConnection mPeerConnection;private WebSocketClientHelper mWebSocketClientHelper = new WebSocketClientHelper();@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_p2p_demo);((EditText) findViewById(R.id.et_server_url)).setText("ws://192.168.1.105:8888");findViewById(R.id.btn_connect).setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View view) {String url = ((EditText) findViewById(R.id.et_server_url)).getText().toString().trim();mWebSocketClientHelper.connect(url);}});findViewById(R.id.btn_disconnect).setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View view) {mWebSocketClientHelper.disconnect();}});findViewById(R.id.btn_call).setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View view) {call();}});findViewById(R.id.btn_hang_up).setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View view) {hangUp();}});mWebSocketClientHelper.setOnWebSocketListener(new WebSocketClientHelper.OnWebSocketClientListener() {@Overridepublic void onOpen() {runOnUiThread(new Runnable() {@Overridepublic void run() {((TextView) findViewById(R.id.tv_websocket_state)).setText("WebSocket 已连接");}});}@Overridepublic void onClose() {runOnUiThread(new Runnable() {@Overridepublic void run() {((TextView) findViewById(R.id.tv_websocket_state)).setText("WebSocket 已断开");}});}@Overridepublic void onMessage(String message) {try {JSONObject jsonObject = new JSONObject(message);String msgType = jsonObject.optString("msgType");if (TextUtils.equals("sdp", msgType)) {String type = jsonObject.optString("type");if (TextUtils.equals("offer", type)) {String sdp = jsonObject.optString("sdp");SessionDescription offer = new SessionDescription(SessionDescription.Type.OFFER, sdp);receivedOffer(offer);} else if (TextUtils.equals("answer", type)) {String sdp = jsonObject.optString("sdp");SessionDescription answer = new SessionDescription(SessionDescription.Type.ANSWER, sdp);receivedAnswer(answer);}} else if (TextUtils.equals("iceCandidate", msgType)) {String id = jsonObject.optString("id");int label = jsonObject.optInt("label");String candidate = jsonObject.optString("candidate");IceCandidate iceCandidate = new IceCandidate(id, label, candidate);receivedCandidate(iceCandidate);}} catch (JSONException e) {e.printStackTrace();}}});// 初始化 PeerConnectionFactoryinitPeerConnectionFactory(P2PDemoActivity.this);// 创建 EglBasemEglBase = EglBase.create();// 创建 PeerConnectionFactorymPeerConnectionFactory = createPeerConnectionFactory(mEglBase);// 创建音轨mAudioTrack = createAudioTrack(mPeerConnectionFactory);// 创建视轨mVideoCapturer = createVideoCapturer();VideoSource videoSource = createVideoSource(mPeerConnectionFactory, mVideoCapturer);mVideoTrack = createVideoTrack(mPeerConnectionFactory, videoSource);// 初始化本地视频渲染控件,这个方法非常重要,不初始化会黑屏SurfaceViewRenderer svrLocal = findViewById(R.id.svr_local);svrLocal.init(mEglBase.getEglBaseContext(), null);mVideoTrack.addSink(svrLocal);// 初始化远端视频渲染控件,这个方法非常重要,不初始化会黑屏SurfaceViewRenderer svrRemote = findViewById(R.id.svr_remote);svrRemote.init(mEglBase.getEglBaseContext(), null);// 开始本地渲染// 创建 SurfaceTextureHelper,用来表示 camera 初始化的线程SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create(SURFACE_TEXTURE_HELPER_THREAD_NAME, mEglBase.getEglBaseContext());// 初始化视频采集器mVideoCapturer.initialize(surfaceTextureHelper, P2PDemoActivity.this, videoSource.getCapturerObserver());mVideoCapturer.startCapture(WIDTH, HEIGHT, FPS);}@Overrideprotected void onDestroy() {super.onDestroy();if (mEglBase != null) {mEglBase.release();mEglBase = null;}if (mVideoCapturer != null) {try {mVideoCapturer.stopCapture();} catch (InterruptedException e) {e.printStackTrace();}mVideoCapturer.dispose();mVideoCapturer = null;}if (mAudioTrack != null) {mAudioTrack.dispose();mAudioTrack = null;}if (mVideoTrack != null) {mVideoTrack.dispose();mVideoTrack = null;}if (mPeerConnection != null) {mPeerConnection.close();mPeerConnection = null;}SurfaceViewRenderer svrLocal = findViewById(R.id.svr_local);svrLocal.release();SurfaceViewRenderer svrRemote = findViewById(R.id.svr_remote);svrRemote.release();mWebSocketClientHelper.disconnect();}private void initPeerConnectionFactory(Context context) {PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions());}private PeerConnectionFactory createPeerConnectionFactory(EglBase eglBase) {VideoEncoderFactory videoEncoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true);VideoDecoderFactory videoDecoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());return PeerConnectionFactory.builder().setVideoEncoderFactory(videoEncoderFactory).setVideoDecoderFactory(videoDecoderFactory).createPeerConnectionFactory();}private AudioTrack createAudioTrack(PeerConnectionFactory peerConnectionFactory) {AudioSource audioSource = peerConnectionFactory.createAudioSource(new MediaConstraints());AudioTrack audioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, audioSource);audioTrack.setEnabled(true);return audioTrack;}private VideoCapturer createVideoCapturer() {VideoCapturer videoCapturer = null;CameraEnumerator cameraEnumerator = new Camera2Enumerator(P2PDemoActivity.this);for (String deviceName : cameraEnumerator.getDeviceNames()) {// 前摄像头if (cameraEnumerator.isFrontFacing(deviceName)) {videoCapturer = new Camera2Capturer(P2PDemoActivity.this, deviceName, null);}}return videoCapturer;}private VideoSource createVideoSource(PeerConnectionFactory peerConnectionFactory, VideoCapturer videoCapturer) {// 创建视频源VideoSource videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast());return videoSource;}private VideoTrack createVideoTrack(PeerConnectionFactory peerConnectionFactory, VideoSource videoSource) {// 创建视轨VideoTrack videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource);videoTrack.setEnabled(true);return videoTrack;}private PeerConnection createPeerConnection() {PeerConnection.RTCConfiguration rtcConfiguration = new PeerConnection.RTCConfiguration(new ArrayList<>());PeerConnection peerConnection = mPeerConnectionFactory.createPeerConnection(rtcConfiguration, new PeerConnection.Observer() {@Overridepublic void onSignalingChange(PeerConnection.SignalingState signalingState) {}@Overridepublic void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {if (iceConnectionState == PeerConnection.IceConnectionState.DISCONNECTED) {runOnUiThread(new Runnable() {@Overridepublic void run() {hangUp();}});}}@Overridepublic void onIceConnectionReceivingChange(boolean b) {}@Overridepublic void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {}@Overridepublic void onIceCandidate(IceCandidate iceCandidate) {ShowLogUtil.verbose("onIceCandidate--->" + iceCandidate);sendIceCandidate(iceCandidate);}@Overridepublic void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}@Overridepublic void onAddStream(MediaStream mediaStream) {ShowLogUtil.verbose("onAddStream--->" + mediaStream);if (mediaStream == null || mediaStream.videoTracks == null || mediaStream.videoTracks.isEmpty()) {return;}runOnUiThread(new Runnable() {@Overridepublic void run() {SurfaceViewRenderer svrRemote = findViewById(R.id.svr_remote);mediaStream.videoTracks.get(0).addSink(svrRemote);}});}@Overridepublic void onRemoveStream(MediaStream mediaStream) {}@Overridepublic void onDataChannel(DataChannel dataChannel) {}@Overridepublic void onRenegotiationNeeded() {}@Overridepublic void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {}});return peerConnection;}private void call() {// 创建 PeerConnectionmPeerConnection = createPeerConnection();// 为 PeerConnection 添加音轨、视轨mPeerConnection.addTrack(mAudioTrack, STREAM_IDS);mPeerConnection.addTrack(mVideoTrack, STREAM_IDS);// 通过 PeerConnection 创建 offer,获取 sdpMediaConstraints mediaConstraints = new MediaConstraints();mPeerConnection.createOffer(new MySdpObserver() {@Overridepublic void onCreateSuccess(SessionDescription sessionDescription) {ShowLogUtil.verbose("create offer success.");// 将 offer sdp 作为参数 setLocalDescriptionmPeerConnection.setLocalDescription(new MySdpObserver() {@Overridepublic void onCreateSuccess(SessionDescription sessionDescription) {}@Overridepublic void onSetSuccess() {ShowLogUtil.verbose("set local sdp success.");// 发送 offer sdpsendOffer(sessionDescription);}}, sessionDescription);}@Overridepublic void onSetSuccess() {}}, mediaConstraints);}private void sendOffer(SessionDescription offer) {try {JSONObject jsonObject = new JSONObject();jsonObject.put("msgType", "sdp");jsonObject.put("type", "offer");jsonObject.put("sdp", offer.description);mWebSocketClientHelper.send(jsonObject.toString());} catch (JSONException e) {e.printStackTrace();}}private void receivedOffer(SessionDescription offer) {// 创建 PeerConnectionmPeerConnection = createPeerConnection();// 为 PeerConnection 添加音轨、视轨mPeerConnection.addTrack(mAudioTrack, STREAM_IDS);mPeerConnection.addTrack(mVideoTrack, STREAM_IDS);// 将 offer sdp 作为参数 setRemoteDescriptionmPeerConnection.setRemoteDescription(new MySdpObserver() {@Overridepublic void onCreateSuccess(SessionDescription sessionDescription) {}@Overridepublic void onSetSuccess() {ShowLogUtil.verbose("set remote sdp success.");// 通过 PeerConnection 创建 answer,获取 sdpMediaConstraints mediaConstraints = new MediaConstraints();mPeerConnection.createAnswer(new MySdpObserver() {@Overridepublic void onCreateSuccess(SessionDescription sessionDescription) {ShowLogUtil.verbose("create answer success.");// 将 answer sdp 作为参数 setLocalDescriptionmPeerConnection.setLocalDescription(new MySdpObserver() {@Overridepublic void onCreateSuccess(SessionDescription sessionDescription) {}@Overridepublic void onSetSuccess() {ShowLogUtil.verbose("set local sdp success.");// 发送 answer sdpsendAnswer(sessionDescription);}}, sessionDescription);}@Overridepublic void onSetSuccess() {}}, mediaConstraints);}}, offer);}private void sendAnswer(SessionDescription answer) {try {JSONObject jsonObject = new JSONObject();jsonObject.put("msgType", "sdp");jsonObject.put("type", "answer");jsonObject.put("sdp", answer.description);mWebSocketClientHelper.send(jsonObject.toString());} catch (JSONException e) {e.printStackTrace();}}private void receivedAnswer(SessionDescription answer) {// 收到 answer sdp,将 answer sdp 作为参数 setRemoteDescriptionmPeerConnection.setRemoteDescription(new MySdpObserver() {@Overridepublic void onCreateSuccess(SessionDescription sessionDescription) {}@Overridepublic void onSetSuccess() {ShowLogUtil.verbose("set remote sdp success.");}}, answer);}private void sendIceCandidate(IceCandidate iceCandidate) {try {JSONObject jsonObject = new JSONObject();jsonObject.put("msgType", "iceCandidate");jsonObject.put("id", iceCandidate.sdpMid);jsonObject.put("label", iceCandidate.sdpMLineIndex);jsonObject.put("candidate", iceCandidate.sdp);mWebSocketClientHelper.send(jsonObject.toString());} catch (JSONException e) {e.printStackTrace();}}private void receivedCandidate(IceCandidate iceCandidate) {mPeerConnection.addIceCandidate(iceCandidate);}private void hangUp() {// 关闭 PeerConnectionif (mPeerConnection != null) {mPeerConnection.close();mPeerConnection.dispose();mPeerConnection = null;}// 释放远端视频渲染控件SurfaceViewRenderer svrRemote = findViewById(R.id.svr_remote);svrRemote.clearImage();}
}

其中 WebSocketClientHelper 也只是对 WebSocket 的一个简单封装:

package com.qinshou.webrtcdemo_android;import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;import java.net.URI;/*** Author: MrQinshou* Email: cqflqinhao@126.com* Date: 2023/2/8 9:33* Description: 类描述*/
public class WebSocketClientHelper {public interface OnWebSocketClientListener {void onOpen();void onClose();void onMessage(String message);}private WebSocketClient mWebSocketClient;private OnWebSocketClientListener mOnWebSocketClientListener = new OnWebSocketClientListener() {@Overridepublic void onOpen() {}@Overridepublic void onClose() {}@Overridepublic void onMessage(String message) {}};public void setOnWebSocketListener(OnWebSocketClientListener onWebSocketClientListener) {if (onWebSocketClientListener == null) {return;}mOnWebSocketClientListener = onWebSocketClientListener;}public void connect(String url) {mWebSocketClient = new WebSocketClient(URI.create(url)) {@Overridepublic void onOpen(ServerHandshake handshakedata) {ShowLogUtil.debug("onOpen");mOnWebSocketClientListener.onOpen();}@Overridepublic void onMessage(String message) {
//                ShowLogUtil.debug("onMessage--->" + message);mOnWebSocketClientListener.onMessage(message);}@Overridepublic void onClose(int code, String reason, boolean remote) {ShowLogUtil.debug("onClose--->" + code);mOnWebSocketClientListener.onClose();}@Overridepublic void onError(Exception ex) {ShowLogUtil.debug("onError");}};mWebSocketClient.connect();}public void disconnect() {if (mWebSocketClient == null) {return;}mWebSocketClient.close();}public void send(String message) {if (mWebSocketClient == null) {return;}mWebSocketClient.send(message);}
}

跟 H5 是一样的,有什么不懂的地方可以留言,由于需要 p2p 通话至少需要两个端,我们就等所有端都实现了再最后任选两个端来看效果。

五、iOS

1.添加依赖

iOS 也需要在 app 的 build.gradle 中引入 WebSocket 依赖:

...
target 'WebRTCDemo-iOS' do...pod 'Starscream', '~> 4.0.0'
end
...

权限申请跟之前的一样,就不重复了。

2.P2PViewController

//
//  LocalDemoViewController.swift
//  WebRTCDemo-iOS
//
//  Created by 覃浩 on 2023/3/21.
//import UIKit
import WebRTC
import SnapKitclass P2PDemoViewController: UIViewController {private static let AUDIO_TRACK_ID = "ARDAMSa0"private static let VIDEO_TRACK_ID = "ARDAMSv0"private static let STREAM_IDS = ["ARDAMS"]private static let WIDTH = 1280private static let HEIGHT = 720private static let FPS = 30private var localView: RTCEAGLVideoView!private var remoteView: RTCEAGLVideoView!private var peerConnectionFactory: RTCPeerConnectionFactory!private var audioTrack: RTCAudioTrack?private var videoTrack: RTCVideoTrack?/**iOS 需要将 Capturer 保存为全局变量,否则无法渲染本地画面*/private var videoCapturer: RTCVideoCapturer?/**iOS 需要将远端流保存为全局变量,否则无法渲染远端画面*/private var remoteStream: RTCMediaStream?private var peerConnection: RTCPeerConnection?private var lbWebSocketState: UILabel? = nilprivate var tfServerUrl: UITextField? = nilprivate let webSocketHelper = WebSocketClientHelper()override func viewDidLoad() {super.viewDidLoad()// 表明 View 不要扩展到整个屏幕,而是在 NavigationBar 下的区域edgesForExtendedLayout = UIRectEdge()self.view.backgroundColor = UIColor.black// WebSocket 状态文本框lbWebSocketState = UILabel()lbWebSocketState!.textColor = UIColor.whitelbWebSocketState!.text = "WebSocket 已断开"self.view.addSubview(lbWebSocketState!)lbWebSocketState!.snp.makeConstraints({ make inmake.left.equalToSuperview().offset(30)make.right.equalToSuperview().offset(-30)make.height.equalTo(40)})// 服务器地址输入框tfServerUrl = UITextField()tfServerUrl!.textColor = UIColor.whitetfServerUrl!.text = "ws://192.168.1.105:8888"tfServerUrl!.placeholder = "请输入服务器地址"tfServerUrl!.delegate = selfself.view.addSubview(tfServerUrl!)tfServerUrl!.snp.makeConstraints({ make inmake.left.equalToSuperview().offset(30)make.right.equalToSuperview().offset(-30)make.height.equalTo(20)make.top.equalTo(lbWebSocketState!.snp.bottom).offset(10)})// 连接 WebSocket 按钮let btnConnect = UIButton()btnConnect.backgroundColor = UIColor.lightGraybtnConnect.setTitle("连接 WebSocket", for: .normal)btnConnect.setTitleColor(UIColor.black, for: .normal)btnConnect.addTarget(self, action: #selector(connect), for: .touchUpInside)self.view.addSubview(btnConnect)btnConnect.snp.makeConstraints({ make inmake.left.equalToSuperview().offset(30)make.width.equalTo(140)make.height.equalTo(40)make.top.equalTo(tfServerUrl!.snp.bottom).offset(10)})// 断开 WebSocket 按钮let btnDisconnect = UIButton()btnDisconnect.backgroundColor = UIColor.lightGraybtnDisconnect.setTitle("断开 WebSocket", for: .normal)btnDisconnect.setTitleColor(UIColor.black, for: .normal)btnDisconnect.addTarget(self, action: #selector(disconnect), for: .touchUpInside)self.view.addSubview(btnDisconnect)btnDisconnect.snp.makeConstraints({ make inmake.left.equalToSuperview().offset(30)make.width.equalTo(140)make.height.equalTo(40)make.top.equalTo(btnConnect.snp.bottom).offset(10)})// 呼叫按钮let btnCall = UIButton()btnCall.backgroundColor = UIColor.lightGraybtnCall.setTitle("呼叫", for: .normal)btnCall.setTitleColor(UIColor.black, for: .normal)btnCall.addTarget(self, action: #selector(call), for: .touchUpInside)self.view.addSubview(btnCall)btnCall.snp.makeConstraints({ make inmake.left.equalToSuperview().offset(30)make.width.equalTo(80)make.height.equalTo(40)make.top.equalTo(btnDisconnect.snp.bottom).offset(10)})// 挂断按钮let btnHangUp = UIButton()btnHangUp.backgroundColor = UIColor.lightGraybtnHangUp.setTitle("挂断", for: .normal)btnHangUp.setTitleColor(UIColor.black, for: .normal)btnHangUp.addTarget(self, action: #selector(hangUp), for: .touchUpInside)self.view.addSubview(btnHangUp)btnHangUp.snp.makeConstraints({ make inmake.left.equalToSuperview().offset(30)make.width.equalTo(80)make.height.equalTo(40)make.top.equalTo(btnCall.snp.bottom).offset(10)})webSocketHelper.setDelegate(delegate: self)// 初始化 PeerConnectionFactoryinitPeerConnectionFactory()// 创建 EglBase// 创建 PeerConnectionFactorypeerConnectionFactory = createPeerConnectionFactory()// 创建音轨audioTrack = createAudioTrack(peerConnectionFactory: peerConnectionFactory)// 创建视轨videoTrack = createVideoTrack(peerConnectionFactory: peerConnectionFactory)let tuple = createVideoCapturer(videoSource: videoTrack!.source)let captureDevice = tuple.captureDevicevideoCapturer = tuple.videoCapture// 初始化本地视频渲染控件localView = RTCEAGLVideoView()localView.delegate = selfself.view.insertSubview(localView,at: 0)localView.snp.makeConstraints({ make inmake.width.equalToSuperview()make.height.equalTo(localView.snp.width).multipliedBy(16.0/9.0)make.centerY.equalToSuperview()})videoTrack?.add(localView!)// 初始化远端视频渲染控件remoteView = RTCEAGLVideoView()remoteView.delegate = selfself.view.insertSubview(remoteView, aboveSubview: localView)remoteView.snp.makeConstraints({ make inmake.width.equalTo(90)make.height.equalTo(160)make.top.equalToSuperview().offset(30)make.right.equalToSuperview().offset(-30)})// 开始本地渲染(videoCapturer as? RTCCameraVideoCapturer)?.startCapture(with: captureDevice!, format: captureDevice!.activeFormat, fps: P2PDemoViewController.FPS)}override func viewDidDisappear(_ animated: Bool) {(videoCapturer as? RTCCameraVideoCapturer)?.stopCapture()videoCapturer = nilpeerConnection?.close()peerConnection = nilwebSocketHelper.disconnect()}private func initPeerConnectionFactory() {RTCPeerConnectionFactory.initialize()}private func createPeerConnectionFactory() -> RTCPeerConnectionFactory {var videoEncoderFactory = RTCDefaultVideoEncoderFactory()var videoDecoderFactory = RTCDefaultVideoDecoderFactory()if TARGET_OS_SIMULATOR != 0 {videoEncoderFactory = RTCSimluatorVideoEncoderFactory()videoDecoderFactory = RTCSimulatorVideoDecoderFactory()}return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory)}private func createAudioTrack(peerConnectionFactory: RTCPeerConnectionFactory) -> RTCAudioTrack {let mandatoryConstraints : [String : String] = [:]let optionalConstraints : [String : String] = [:]let audioSource = peerConnectionFactory.audioSource(with: RTCMediaConstraints(mandatoryConstraints: mandatoryConstraints, optionalConstraints: optionalConstraints))let audioTrack = peerConnectionFactory.audioTrack(with: audioSource, trackId: P2PDemoViewController.AUDIO_TRACK_ID)audioTrack.isEnabled = truereturn audioTrack}private func createVideoTrack(peerConnectionFactory: RTCPeerConnectionFactory) -> RTCVideoTrack? {let videoSource = peerConnectionFactory.videoSource()let videoTrack = peerConnectionFactory.videoTrack(with: videoSource, trackId: P2PDemoViewController.VIDEO_TRACK_ID)videoTrack.isEnabled = truereturn videoTrack}private func createVideoCapturer(videoSource: RTCVideoSource) -> (captureDevice: AVCaptureDevice?, videoCapture: RTCVideoCapturer?) {let videoCapturer = RTCCameraVideoCapturer(delegate: videoSource)let captureDevices = RTCCameraVideoCapturer.captureDevices()if (captureDevices.count == 0) {return (nil, nil)}var captureDevice: AVCaptureDevice?for c in captureDevices {// 前摄像头if (c.position == .front) {captureDevice = cbreak}}if (captureDevice == nil) {return (nil, nil)}return (captureDevice, videoCapturer)}private func createPeerConnection() -> RTCPeerConnection {let rtcConfiguration = RTCConfiguration()let mandatoryConstraints : [String : String] = [:]let optionalConstraints : [String : String] = [:]let mediaConstraints = RTCMediaConstraints(mandatoryConstraints: mandatoryConstraints, optionalConstraints: optionalConstraints)let peerConnection = peerConnectionFactory.peerConnection(with: rtcConfiguration, constraints: mediaConstraints, delegate: self)return peerConnection}@objc private func call() {// 创建 PeerConnectionpeerConnection = createPeerConnection()// 为 PeerConnection 添加音轨、视轨peerConnection?.add(audioTrack!, streamIds: P2PDemoViewController.STREAM_IDS)peerConnection?.add(videoTrack!, streamIds: P2PDemoViewController.STREAM_IDS)// 通过 PeerConnection 创建 offer,获取 sdplet mandatoryConstraints: [String : String] = [:]let optionalConstraints: [String : String] = [:]let mediaConstraints = RTCMediaConstraints(mandatoryConstraints: mandatoryConstraints, optionalConstraints: optionalConstraints)peerConnection?.offer(for: mediaConstraints, completionHandler: { sessionDescription, error inShowLogUtil.verbose("create offer success.")// 将 offer sdp 作为参数 setLocalDescriptionself.peerConnection?.setLocalDescription(sessionDescription!, completionHandler: { _ inShowLogUtil.verbose("set local sdp success.")// 发送 offer sdpself.sendOffer(offer: sessionDescription!)})})}private func sendOffer(offer: RTCSessionDescription) {var jsonObject = [String : String]()jsonObject["msgType"] = "sdp"jsonObject["type"] = "offer"jsonObject["sdp"] = offer.sdpdo {let data = try JSONSerialization.data(withJSONObject: jsonObject)webSocketHelper.send(message: String(data: data, encoding: .utf8)!)} catch {ShowLogUtil.verbose("error--->\(error)")}}private func receivedOffer(offer: RTCSessionDescription) {// 创建 PeerConnectionpeerConnection = createPeerConnection()// 为 PeerConnection 添加音轨、视轨peerConnection?.add(audioTrack!, streamIds: P2PDemoViewController.STREAM_IDS)peerConnection?.add(videoTrack!, streamIds: P2PDemoViewController.STREAM_IDS)// 将 offer sdp 作为参数 setRemoteDescriptionpeerConnection?.setRemoteDescription(offer, completionHandler: { _ inShowLogUtil.verbose("set remote sdp success.")// 通过 PeerConnection 创建 answer,获取 sdplet mandatoryConstraints : [String : String] = [:]let optionalConstraints : [String : String] = [:]let mediaConstraints = RTCMediaConstraints(mandatoryConstraints: mandatoryConstraints, optionalConstraints: optionalConstraints)self.peerConnection?.answer(for: mediaConstraints, completionHandler: { sessionDescription, error inShowLogUtil.verbose("create answer success.")// 将 answer sdp 作为参数 setLocalDescriptionself.peerConnection?.setLocalDescription(sessionDescription!, completionHandler: { _ inShowLogUtil.verbose("set local sdp success.")// 发送 answer sdpself.sendAnswer(answer: sessionDescription!)})})})}private func sendAnswer(answer: RTCSessionDescription) {var jsonObject = [String : String]()jsonObject["msgType"] = "sdp"jsonObject["type"] = "answer"jsonObject["sdp"] = answer.sdpdo {let data = try JSONSerialization.data(withJSONObject: jsonObject)webSocketHelper.send(message: String(data: data, encoding: .utf8)!)} catch {ShowLogUtil.verbose("error--->\(error)")}}private func receivedAnswer(answer: RTCSessionDescription) {// 收到 answer sdp,将 answer sdp 作为参数 setRemoteDescriptionpeerConnection?.setRemoteDescription(answer, completionHandler: { _ in   ShowLogUtil.verbose("set remote sdp success.")})}private func sendIceCandidate(iceCandidate: RTCIceCandidate)  {var jsonObject = [String : Any]()jsonObject["msgType"] = "iceCandidate"jsonObject["id"] = iceCandidate.sdpMidjsonObject["label"] = iceCandidate.sdpMLineIndexjsonObject["candidate"] = iceCandidate.sdpdo {let data = try JSONSerialization.data(withJSONObject: jsonObject)webSocketHelper.send(message: String(data: data, encoding: .utf8)!)} catch {ShowLogUtil.verbose("error--->\(error)")}}private func receivedCandidate(iceCandidate: RTCIceCandidate) {peerConnection?.add(iceCandidate)}@objc private func hangUp() {// 关闭 PeerConnectionpeerConnection?.close()peerConnection = nil// 释放远端视频渲染控件if let track = remoteStream?.videoTracks.first {track.remove(remoteView!)}}@objc private func connect() {webSocketHelper.connect(url: tfServerUrl!.text!.trimmingCharacters(in: .whitespacesAndNewlines))}@objc private func disconnect() {webSocketHelper.disconnect()}
}// MARK: - RTCVideoViewDelegate
extension P2PDemoViewController: RTCVideoViewDelegate {func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {}
}// MARK: - RTCPeerConnectionDelegate
extension P2PDemoViewController: RTCPeerConnectionDelegate {func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {ShowLogUtil.verbose("peerConnection didAdd stream--->\(stream)")DispatchQueue.main.async {self.remoteStream = streamif let track = stream.videoTracks.first {track.add(self.remoteView!)}if let audioTrack = stream.audioTracks.first{audioTrack.source.volume = 8}}}func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {if (newState == .disconnected) {DispatchQueue.main.async {self.hangUp()}}}func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {}func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {ShowLogUtil.verbose("didGenerate candidate--->\(candidate)")self.sendIceCandidate(iceCandidate: candidate)}func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
}// MARK: - UITextFieldDelegate
extension P2PDemoViewController: UITextFieldDelegate {func textFieldShouldReturn(_ textField: UITextField) -> Bool {textField.resignFirstResponder()return true}
}// MARK: - WebSocketDelegate
extension P2PDemoViewController: WebSocketDelegate {func onOpen() {lbWebSocketState?.text = "WebSocket 已连接"}func onClose() {lbWebSocketState?.text = "WebSocket 已断开"}func onMessage(message: String) {do {let data = message.data(using: .utf8)let jsonObject: [String : Any] = try JSONSerialization.jsonObject(with: data!) as! [String : Any]let msgType = jsonObject["msgType"] as? Stringif ("sdp" == msgType) {let type = jsonObject["type"] as? Stringif ("offer" == type) {let sdp = jsonObject["sdp"] as! Stringlet offer = RTCSessionDescription(type: .offer, sdp: sdp)receivedOffer(offer: offer)} else if ("answer" == type) {let sdp = jsonObject["sdp"] as! Stringlet answer = RTCSessionDescription(type: .answer, sdp: sdp)receivedAnswer(answer: answer)}} else if ("iceCandidate" == msgType) {let id = jsonObject["id"] as? Stringlet label = jsonObject["label"] as? Int32let candidate = jsonObject["candidate"] as? Stringlet iceCandidate = RTCIceCandidate(sdp: candidate!, sdpMLineIndex: label!, sdpMid: id)receivedCandidate(iceCandidate: iceCandidate)}} catch {}}
}

其中 WebSocketClientHelper 也只是对 WebSocket 的一个简单封装:

//
//  WebClientHelper.swift
//  WebRTCDemo-iOS
//
//  Created by 覃浩 on 2023/3/1.
//import Starscreampublic protocol WebSocketDelegate {func onOpen()func onClose()func onMessage(message: String)
}class WebSocketClientHelper {private var webSocket: WebSocket?private var delegate: WebSocketDelegate?func setDelegate(delegate: WebSocketDelegate) {self.delegate = delegate}func connect(url: String) {let request = URLRequest(url: URL(string: url)!)webSocket = WebSocket(request: request)webSocket?.onEvent = { event inswitch event {case .connected(let headers):self.delegate?.onOpen()breakcase .disconnected(let reason, let code):self.delegate?.onClose()breakcase .text(let string):self.delegate?.onMessage(message: string)breakcase .binary(let data):breakcase .ping(_):breakcase .pong(_):breakcase .viabilityChanged(_):breakcase .reconnectSuggested(_):breakcase .cancelled:self.delegate?.onClose()breakcase .error(let error):self.delegate?.onClose()break}}webSocket?.connect()}func disconnect() {webSocket?.disconnect()}func send(message: String) {webSocket?.write(string: message)}
}

好了,现在三端都实现了,我们可以来看看效果了。

六、效果展示

运行 WebSocketServerHelper 的 main() 方法,我们可以看到服务端已经开启:

运行 html、Android、iOS 三端,任选其中两端连接 WebSocket,这两端任选一端点击呼叫:

 

 

需要注意的是我在 WebSocket 中没有引入用户和房间的概念,呼叫都是透传给除自己外的所有连接,所以在测试的时候,只能连接两个客户端,不用的时候就要断开 WebSocket。

七、总结

实现完成后可以感觉到点对点呼叫其实也没有多难,跟本地 Demo 的流程大致一样,只是我们需要将音视频通话的协商信息通过网络传输而已,所以我之前才说,明白 WebRTC 的流程比较重要,信令服务器反而在其次,毕竟真实场景中,信令服务器还会加入很多业务逻辑。

下一次我们在信令服务器中加入一些逻辑,来实现多人通话。

八、Demo

Demo 传送门

相关文章:

WebRTC 系列(三、点对点通话,H5、Android、iOS)

WebRTC 系列&#xff08;二、本地 demo&#xff0c;H5、Android、iOS&#xff09; 上一篇博客中&#xff0c;我已经展示了各端的本地 demo&#xff0c;大家应该知道 WebRTC 怎么用了。在本地 demo 中是用了一个 RemotePeerConnection 来模拟远端&#xff0c;可能理解起来还有点…...

RabbitMQ( 发布订阅模式 ==> DirectExchange)

本章目录&#xff1a; 何为DirectExchangeDirectExchange具体使用 一、何为DirectExchange 在上一篇文章中&#xff0c;讲述了FanoutExchange&#xff0c;其中publish向交换机发送消息时&#xff0c;我们并没有指定routkingKey&#xff0c;如下图所示 我们看看官方文档 之前使…...

Pytorch基础 - 5. torch.cat() 和 torch.stack()

目录 1. torch.cat(tensors, dim) 2. torch.stack(tensors, dim) 3. 两者不同 torch.cat() 和 torch.stack()常用来进行张量的拼接&#xff0c;在神经网络里经常用到。且前段时间有一个面试官也问到了这个知识点&#xff0c;虽然内容很小很细&#xff0c;但需要了解。 1. t…...

基于AIGC的3D场景创作引擎概述

通过改变3D场景制作流程复杂、成本高、门槛高、流动性差的现状&#xff0c;让商家像玩转2D一样去玩转3D&#xff0c;让普通消费者也能参与到3D内容创作和消费中&#xff0c;真正实现内容生产模式从PGC/UGC过渡到AIGC&#xff0c;是我们3D场景智能创作引擎一直追求的目标。 前言…...

C++算法恢复训练之快速排序

快速排序&#xff08;Quick Sort&#xff09;是一种基于分治思想的排序算法&#xff0c;它通过将待排序数组分成两个子数组&#xff0c;其中一个子数组的所有元素都比另一个子数组的元素小&#xff0c;然后对这两个子数组递归地进行排序&#xff0c;最终将整个数组排序。快速排…...

事务的特性

四大特性 原子性&#xff08;atomicity&#xff09; 事务的一系列操作&#xff0c;要么所有操作所有都成功&#xff0c;要么一个操作都不做 一致性&#xff08;consistency&#xff09; 指数据的规则,在事务前/后应保持一致&#xff0c;事务的原子性保证了一致性 隔离性&a…...

Python 计算三角形的面积、Python 阶乘实例

Python 计算三角形的面积 以下实例为通过用户输入三角形三边长度&#xff0c;并计算三角形的面积&#xff1a; # -*- coding: UTF-8 -*-# Filename : test.py # author by : www.w3cschool.cna float(input(输入三角形第一边长: )) b float(input(输入三角形第二边长: )) c …...

C++入门教程||C++ 重载运算符和重载函数||C++ 多态

C 重载运算符和重载函数 C 重载运算符和重载函数 C 允许在同一作用域中的某个函数和运算符指定多个定义&#xff0c;分别称为函数重载和运算符重载。 重载声明是指一个与之前已经在该作用域内声明过的函数或方法具有相同名称的声明&#xff0c;但是它们的参数列表和定义&…...

docker+docker-compose+nginx前后端分离项目部署

文章目录 1.安装docker1.1 基于centos的安装1.2 基于ubuntu 2.配置国内加速器2.1 配置阿里云加速器&#x1f340; 找到相应页面&#x1f340; 创建 docker 目录&#x1f340; 创建 daemon.json 文件&#x1f340; 重新加载服务配置文件&#x1f340; 重启 docker 引擎 2.2 配置…...

基于PCA与LDA的数据降维实践

基于PCA与LDA的数据降维实践 描述 数据降维&#xff08;Dimension Reduction&#xff09;是降低数据冗余、消除噪音数据的干扰、提取有效特征、提升模型的效率和准确性的有效途径&#xff0c; PCA&#xff08;主成分分析&#xff09;和LDA&#xff08;线性判别分析&#xff0…...

【Hello Network】网络编程套接字(一)

作者&#xff1a;小萌新 专栏&#xff1a;网络 作者简介&#xff1a;大二学生 希望能和大家一起进步 本篇博客简介&#xff1a;简单介绍网络的基础概念 网络编程套接字&#xff08;一&#xff09; 预备知识源ip和目的ip端口号TCP和UDP协议网络中的字节序 socket编程接口socket常…...

【计算机网络】学习笔记:第二章 物理层(五千字详细配图)【王道考研】

创作不易&#xff0c;本篇文章如果帮助到了你&#xff0c;还请点赞支持一下♡>&#x16966;<)!! 主页专栏有更多知识&#xff0c;如有疑问欢迎大家指正讨论&#xff0c;共同进步&#xff01; 给大家跳段街舞感谢支持&#xff01;ጿ ኈ ቼ ዽ ጿ ኈ ቼ ዽ ጿ ኈ ቼ ዽ ጿ…...

直流有刷电机的电路分析

这里写目录标题 H桥改进后的电路L298N原理图野火的电机驱动板MOS管野火的原理图 H桥 当 Q1 和 Q4 导通时&#xff0c;电流将经过 Q1 从左往右流过电机&#xff0c;在经过 Q4 流到电源负极&#xff0c;这时图中电机可以顺时针转动。 当 Q3 和 Q2 导通时&#xff0c;电流将经过 Q…...

使用PowerShell自动部署ASP.NetCore程序到IIS

asp.net core 安装asp.net core sdk https://dotnet.microsoft.com/en-us/download/dotnet/3.1 创建asp.net core项目 dotnet new webapi运行项目 访问https://localhost:5001/WeatherForecast iis配置 安装iis 以管理员身份运行powershell Enable-WindowsOptiona…...

Elasticsearch:保留字段名称

作为 Elasticsearch 用户&#xff0c;我们从许多不同的位置收集数据。 我们使用 Logstash、Beats 和其他工具来抓取数据并将它们发送到 Elasticsearch。 有时&#xff0c;我们无法控制数据本身&#xff0c;我们需要管理数据的结构&#xff0c;甚至需要在摄取数据时处理字段名称…...

Qt 套接字类(QTcpSocket和QUdpSocket)解密:迈向 Qt 网络编程之巅

Qt 套接字类解密&#xff1a;迈向 Qt 网络编程之巅 一、套接字类简介&#xff08;Introduction to Socket Classes&#xff09;# 套接字类的作用&#xff08;Role of Socket Classes&#xff09;Qt 中常见套接字类概述&#xff08;Overview of Common Socket Classes in Qt&…...

Python视频编辑库:MoviePy

MoviePy MoviePy是一个关于视频编辑的python库,主要包括:剪辑,嵌入拼接,标题插入,视频合成(又名非线性编辑),视频处理,和自定制效果。可以看gallery中的一些实例来了解用法。MoviePy可以读写所有的音频和视频格式,包括GIF,通过python2.7+和python3可以跨平台运行于window/M…...

课程3:ASP.NET Core 身份验证 - Cookie

课程简介目录 🚀前言一、.Net Core 身份验证简介二、开启Cookie身份验证三、添加登录接口3.1 添加登录Dto3.2 添加登录接口Login3.3 获取用户信息接口,添加身份验证四、获取用户信息接口测试4.1 测试获取用户信息接口4.2 登录4.3 再次测试:获取用户信息接口4.4 其他浏览器测…...

Visual Studio 2022如何安装和使用MSDN

我是荔园微风&#xff0c;作为一名在IT界整整25年的老兵&#xff0c;在后台收到提问&#xff0c;问我Visual Studio 2022如何安装和使用MSDN&#xff0c;这个我之前也没有在这个版本上装过MSDN&#xff0c;我之前是在Visual Studio 2017版上装过MSDN&#xff0c;那既然有人问了…...

82.qt qml-2D粒子系统、粒子方向、粒子项(一)

由于粒子系统相关的类比较多, 所以本章参考自QmlBook in chinese的粒子章节配合学习: 由于QmlBook in chinese翻译过来的文字有些比较难理解,所以本章在它的基础上做些个人理解,建议学习的小伙伴最好配合QmlBook in chinese一起学习。 1.介绍 粒子模拟的核心是粒子系统(Partic…...

引用的底层原理(汇编指令),引用与指针的联系与区别

TIPS 2. 3. 4. 引用的底层本质 在语法层面上的话&#xff0c;这个引用是不开空间的&#xff0c;相当于是对一个变量进行一个取别名的这么一个操作。在底层实现上实际是有空间的&#xff0c;因为引用是按照指针方式来实现的。然而如果你从底层的角度去看的话&#xff0c;因…...

磁盘的移臂调度算法

1、概要 访问磁盘&#xff0c;首先要找到数据&#xff0c;但机械硬盘并不是直接电子读取&#xff0c;是需要移动磁头到相应的数据块上才能读取的&#xff0c;即需要磁头移动到目标柱面(磁道)&#xff0c;然后磁片旋转使磁头能访问到相应扇区&#xff0c;进而读取到数据。 根据访…...

软考第六章 网络互连与互联网

网络互连与互联网 1.网络互连设备 组成因特网的各个网络叫做子网&#xff0c;用于连接子网的设备叫做中间系统。它的主要作用是协调各个网络的工作&#xff0c;使得跨网络的通信得以实现。 网络互连设备可以根据它们工作的协议层进行分类&#xff1a; 中继器&#xff1a;工…...

C6678-缓存和内存

C6678-缓存和内存 全局内存映射扩展内存控制器&#xff08;XMC&#xff09;-MPAX内存保护与地址扩展使用例程缓存 全局内存映射 扩展内存控制器&#xff08;XMC&#xff09;-MPAX内存保护与地址扩展 每个C66x核心都具有相同大小的L1和L2缓存&#xff0c;并且可配置为普通内存使…...

实操| 前端新人无敲代码开发APP

作为一种大型的基于GPT-3. 5结构的语言模型&#xff0c;ChatGPT由OpenAI训练&#xff0c;采用深度学习技术&#xff0c;通过大量的文本数据学习&#xff0c;可以生成类似于人类自然语言的文字。ChatGPT是一种非常强大的对话引擎&#xff0c;能进行对话、回答问题和完成任务。Ch…...

OpenCV图像处理之傅里叶变换

文章目录 OpenCV图像处理之傅里叶变换图像处理之傅里叶变换流程图OpenCv图像处理之傅里叶变换OpenCv傅里叶变换之低通滤波OpenCv傅里叶变换之高通滤波 OpenCV图像处理之傅里叶变换 傅里叶变换&#xff1a;目的就是得到图像的低频和高频&#xff0c;然后针对低频和高频进行不同…...

Docker网络案例

bridge 是什么 Docker 服务默认会创建一个 docker0 网桥(其上有一个 docker0 内部接口),该桥接网络的名称为docker0,它在内核层连通了其他的物理或虚拟网卡,这就将所有容器和本地主机都放到同一个物理网络。Docker 默认指定了 docker0 接口 的 IP 地址和子网掩码,让主机…...

Java实验课的学习笔记(二)类的简单使用

本文章就讲的是很基础的类的使用 重点大概就是类的构造函数以及一些很基础的东西。 实验内容是些老生常谈的东西&#xff0c;Complex类&#xff0c;在当初学C面向对象的时候也是这个样子展开的。 内容如以下&#xff1a; public class Complex {float real;float imag;public…...

实战案例|聚焦攻击面管理,腾讯安全威胁情报守护头部券商资产安全

金融“活水”润泽千行百业&#xff0c;对金融客户来说&#xff0c;由于业务场景存在特殊性和复杂性&#xff0c;网络安全必然是一场“持久战”。如何在事前做好安全部署&#xff0c;构建威胁情报分析的防护体系至为重要&#xff0c;实现更为精准、高效的动态防御。 客户名片 …...

c++算法初级8——递推

c算法初级8——递推 文章目录 c算法初级8——递推递推递推思想的运用错位排序杨辉三角&#xff08;二维递推&#xff09; 递推 递推思想&#xff1a; 根据已有的东西一点点地推出未知的东西。 使用递推解题三步骤&#xff1a; 数学建模找出递推式和初始条件写出代码。 张爽…...