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 系列(二、本地 demo,H5、Android、iOS) 上一篇博客中,我已经展示了各端的本地 demo,大家应该知道 WebRTC 怎么用了。在本地 demo 中是用了一个 RemotePeerConnection 来模拟远端,可能理解起来还有点…...
RabbitMQ( 发布订阅模式 ==> DirectExchange)
本章目录: 何为DirectExchangeDirectExchange具体使用 一、何为DirectExchange 在上一篇文章中,讲述了FanoutExchange,其中publish向交换机发送消息时,我们并没有指定routkingKey,如下图所示 我们看看官方文档 之前使…...
Pytorch基础 - 5. torch.cat() 和 torch.stack()
目录 1. torch.cat(tensors, dim) 2. torch.stack(tensors, dim) 3. 两者不同 torch.cat() 和 torch.stack()常用来进行张量的拼接,在神经网络里经常用到。且前段时间有一个面试官也问到了这个知识点,虽然内容很小很细,但需要了解。 1. t…...
基于AIGC的3D场景创作引擎概述
通过改变3D场景制作流程复杂、成本高、门槛高、流动性差的现状,让商家像玩转2D一样去玩转3D,让普通消费者也能参与到3D内容创作和消费中,真正实现内容生产模式从PGC/UGC过渡到AIGC,是我们3D场景智能创作引擎一直追求的目标。 前言…...
C++算法恢复训练之快速排序
快速排序(Quick Sort)是一种基于分治思想的排序算法,它通过将待排序数组分成两个子数组,其中一个子数组的所有元素都比另一个子数组的元素小,然后对这两个子数组递归地进行排序,最终将整个数组排序。快速排…...
事务的特性
四大特性 原子性(atomicity) 事务的一系列操作,要么所有操作所有都成功,要么一个操作都不做 一致性(consistency) 指数据的规则,在事务前/后应保持一致,事务的原子性保证了一致性 隔离性&a…...
Python 计算三角形的面积、Python 阶乘实例
Python 计算三角形的面积 以下实例为通过用户输入三角形三边长度,并计算三角形的面积: # -*- coding: UTF-8 -*-# Filename : test.py # author by : www.w3cschool.cna float(input(输入三角形第一边长: )) b float(input(输入三角形第二边长: )) c …...
C++入门教程||C++ 重载运算符和重载函数||C++ 多态
C 重载运算符和重载函数 C 重载运算符和重载函数 C 允许在同一作用域中的某个函数和运算符指定多个定义,分别称为函数重载和运算符重载。 重载声明是指一个与之前已经在该作用域内声明过的函数或方法具有相同名称的声明,但是它们的参数列表和定义&…...
docker+docker-compose+nginx前后端分离项目部署
文章目录 1.安装docker1.1 基于centos的安装1.2 基于ubuntu 2.配置国内加速器2.1 配置阿里云加速器🍀 找到相应页面🍀 创建 docker 目录🍀 创建 daemon.json 文件🍀 重新加载服务配置文件🍀 重启 docker 引擎 2.2 配置…...
基于PCA与LDA的数据降维实践
基于PCA与LDA的数据降维实践 描述 数据降维(Dimension Reduction)是降低数据冗余、消除噪音数据的干扰、提取有效特征、提升模型的效率和准确性的有效途径, PCA(主成分分析)和LDA(线性判别分析࿰…...
【Hello Network】网络编程套接字(一)
作者:小萌新 专栏:网络 作者简介:大二学生 希望能和大家一起进步 本篇博客简介:简单介绍网络的基础概念 网络编程套接字(一) 预备知识源ip和目的ip端口号TCP和UDP协议网络中的字节序 socket编程接口socket常…...
【计算机网络】学习笔记:第二章 物理层(五千字详细配图)【王道考研】
创作不易,本篇文章如果帮助到了你,还请点赞支持一下♡>𖥦<)!! 主页专栏有更多知识,如有疑问欢迎大家指正讨论,共同进步! 给大家跳段街舞感谢支持!ጿ ኈ ቼ ዽ ጿ ኈ ቼ ዽ ጿ ኈ ቼ ዽ ጿ…...
直流有刷电机的电路分析
这里写目录标题 H桥改进后的电路L298N原理图野火的电机驱动板MOS管野火的原理图 H桥 当 Q1 和 Q4 导通时,电流将经过 Q1 从左往右流过电机,在经过 Q4 流到电源负极,这时图中电机可以顺时针转动。 当 Q3 和 Q2 导通时,电流将经过 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 用户,我们从许多不同的位置收集数据。 我们使用 Logstash、Beats 和其他工具来抓取数据并将它们发送到 Elasticsearch。 有时,我们无法控制数据本身,我们需要管理数据的结构,甚至需要在摄取数据时处理字段名称…...
Qt 套接字类(QTcpSocket和QUdpSocket)解密:迈向 Qt 网络编程之巅
Qt 套接字类解密:迈向 Qt 网络编程之巅 一、套接字类简介(Introduction to Socket Classes)# 套接字类的作用(Role of Socket Classes)Qt 中常见套接字类概述(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
我是荔园微风,作为一名在IT界整整25年的老兵,在后台收到提问,问我Visual Studio 2022如何安装和使用MSDN,这个我之前也没有在这个版本上装过MSDN,我之前是在Visual Studio 2017版上装过MSDN,那既然有人问了…...
82.qt qml-2D粒子系统、粒子方向、粒子项(一)
由于粒子系统相关的类比较多, 所以本章参考自QmlBook in chinese的粒子章节配合学习: 由于QmlBook in chinese翻译过来的文字有些比较难理解,所以本章在它的基础上做些个人理解,建议学习的小伙伴最好配合QmlBook in chinese一起学习。 1.介绍 粒子模拟的核心是粒子系统(Partic…...
大数据学习栈记——Neo4j的安装与使用
本文介绍图数据库Neofj的安装与使用,操作系统:Ubuntu24.04,Neofj版本:2025.04.0。 Apt安装 Neofj可以进行官网安装:Neo4j Deployment Center - Graph Database & Analytics 我这里安装是添加软件源的方法 最新版…...
Vue3 + Element Plus + TypeScript中el-transfer穿梭框组件使用详解及示例
使用详解 Element Plus 的 el-transfer 组件是一个强大的穿梭框组件,常用于在两个集合之间进行数据转移,如权限分配、数据选择等场景。下面我将详细介绍其用法并提供一个完整示例。 核心特性与用法 基本属性 v-model:绑定右侧列表的值&…...
《从零掌握MIPI CSI-2: 协议精解与FPGA摄像头开发实战》-- CSI-2 协议详细解析 (一)
CSI-2 协议详细解析 (一) 1. CSI-2层定义(CSI-2 Layer Definitions) 分层结构 :CSI-2协议分为6层: 物理层(PHY Layer) : 定义电气特性、时钟机制和传输介质(导线&#…...
理解 MCP 工作流:使用 Ollama 和 LangChain 构建本地 MCP 客户端
🌟 什么是 MCP? 模型控制协议 (MCP) 是一种创新的协议,旨在无缝连接 AI 模型与应用程序。 MCP 是一个开源协议,它标准化了我们的 LLM 应用程序连接所需工具和数据源并与之协作的方式。 可以把它想象成你的 AI 模型 和想要使用它…...
【机器视觉】单目测距——运动结构恢复
ps:图是随便找的,为了凑个封面 前言 在前面对光流法进行进一步改进,希望将2D光流推广至3D场景流时,发现2D转3D过程中存在尺度歧义问题,需要补全摄像头拍摄图像中缺失的深度信息,否则解空间不收敛…...
Spring AI 入门:Java 开发者的生成式 AI 实践之路
一、Spring AI 简介 在人工智能技术快速迭代的今天,Spring AI 作为 Spring 生态系统的新生力量,正在成为 Java 开发者拥抱生成式 AI 的最佳选择。该框架通过模块化设计实现了与主流 AI 服务(如 OpenAI、Anthropic)的无缝对接&…...
全面解析各类VPN技术:GRE、IPsec、L2TP、SSL与MPLS VPN对比
目录 引言 VPN技术概述 GRE VPN 3.1 GRE封装结构 3.2 GRE的应用场景 GRE over IPsec 4.1 GRE over IPsec封装结构 4.2 为什么使用GRE over IPsec? IPsec VPN 5.1 IPsec传输模式(Transport Mode) 5.2 IPsec隧道模式(Tunne…...
听写流程自动化实践,轻量级教育辅助
随着智能教育工具的发展,越来越多的传统学习方式正在被数字化、自动化所优化。听写作为语文、英语等学科中重要的基础训练形式,也迎来了更高效的解决方案。 这是一款轻量但功能强大的听写辅助工具。它是基于本地词库与可选在线语音引擎构建,…...
uniapp 集成腾讯云 IM 富媒体消息(地理位置/文件)
UniApp 集成腾讯云 IM 富媒体消息全攻略(地理位置/文件) 一、功能实现原理 腾讯云 IM 通过 消息扩展机制 支持富媒体类型,核心实现方式: 标准消息类型:直接使用 SDK 内置类型(文件、图片等)自…...
P10909 [蓝桥杯 2024 国 B] 立定跳远
# P10909 [蓝桥杯 2024 国 B] 立定跳远 ## 题目描述 在运动会上,小明从数轴的原点开始向正方向立定跳远。项目设置了 $n$ 个检查点 $a_1, a_2, \cdots , a_n$ 且 $a_i \ge a_{i−1} > 0$。小明必须先后跳跃到每个检查点上且只能跳跃到检查点上。同时࿰…...
