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

news2024/11/19 22:48:51

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_7
    targetCompatibility = JavaVersion.VERSION_1_7
}

dependencies {
    // WebSocket
    implementation '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) {

            @Override
            public void onOpen(WebSocket conn, ClientHandshake handshake) {
                System.out.println("onOpen--->" + conn);
                // 客户端连接时保存到集合中
                mWebSockets.add(conn);
            }

            @Override
            public void onClose(WebSocket conn, int code, String reason, boolean remote) {
                System.out.println("onClose--->" + conn);
                // 客户端断开时从集合中移除
                mWebSockets.remove(conn);
            }

            @Override
            public void onMessage(WebSocket conn, String message) {
//                System.out.println("onMessage--->" + message);
                // 消息直接透传给除发送方以外的连接
                for (WebSocket webSocket : mWebSockets) {
                    if (webSocket != conn) {
                        webSocket.send(message);
                    }
                }
            }

            @Override
            public void onError(WebSocket conn, Exception ex) {
                System.out.println("onError--->" + conn + ", ex--->" + ex);
                // 客户端连接异常时从集合中移除
                mWebSockets.remove(conn);
            }

            @Override
            public 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() {
		// 创建 PeerConnection
		peerConnection = createPeerConnection();
		// 为 PeerConnection 添加音轨、视轨
		for (let i = 0; localStream != null && i < localStream.getTracks().length; i++) {
			const track = localStream.getTracks()[i];
			peerConnection.addTrack(track, localStream);
		}
		// 通过 PeerConnection 创建 offer,获取 sdp
		peerConnection.createOffer().then(function (sessionDescription) {
			console.log("create offer success.");
			// 将 offer sdp 作为参数 setLocalDescription;
			peerConnection.setLocalDescription(sessionDescription).then(function () {
				console.log("set local sdp success.");
				// 发送 offer sdp
				sendOffer(sessionDescription)
			})
		})
	}

	function sendOffer(offer) {
		var jsonObject = {
			"msgType": "sdp",
			"type": offer.type,
			"sdp": offer.sdp
		};
		send(JSON.stringify(jsonObject));
	}

	function receivedOffer(offer) {
		// 创建 PeerConnection
		peerConnection = createPeerConnection();
		// 为 PeerConnection 添加音轨、视轨
		for (let i = 0; localStream != null && i < localStream.getTracks().length; i++) {
			const track = localStream.getTracks()[i];
			peerConnection.addTrack(track, localStream);
		}
		// 将 offer sdp 作为参数 setRemoteDescription
		peerConnection.setRemoteDescription(offer).then(function () {
			console.log("set remote sdp success.");
			// 通过 PeerConnection 创建 answer,获取 sdp
			peerConnection.createAnswer().then(function (sessionDescription) {
				console.log("create answer success.");
				// 将 answer sdp 作为参数 setLocalDescription
				peerConnection.setLocalDescription(sessionDescription).then(function () {
					console.log("set local sdp success.");
					// 发送 answer sdp
					sendAnswer(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 作为参数 setRemoteDescription
		peerConnection.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.SurfaceViewRenderer
        android: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.SurfaceViewRenderer
        android: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.LinearLayoutCompat
        android: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.AppCompatTextView
            android:id="@+id/tv_websocket_state"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="WebSocketServer 已断开"
            android:textColor="#FFFFFFFF" />

        <androidx.appcompat.widget.AppCompatEditText
            android: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.AppCompatButton
            android:id="@+id/btn_connect"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="连接 WebSocket"
            android:textAllCaps="false" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/btn_disconnect"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="断开 WebSocket"
            android:textAllCaps="false" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/btn_call"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="呼叫" />

        <androidx.appcompat.widget.AppCompatButton
            android: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();

    @Override
    protected 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() {
            @Override
            public 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() {
            @Override
            public void onClick(View view) {
                mWebSocketClientHelper.disconnect();
            }
        });
        findViewById(R.id.btn_call).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                call();
            }
        });
        findViewById(R.id.btn_hang_up).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                hangUp();
            }
        });
        mWebSocketClientHelper.setOnWebSocketListener(new WebSocketClientHelper.OnWebSocketClientListener() {
            @Override
            public void onOpen() {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        ((TextView) findViewById(R.id.tv_websocket_state)).setText("WebSocket 已连接");
                    }
                });
            }

            @Override
            public void onClose() {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        ((TextView) findViewById(R.id.tv_websocket_state)).setText("WebSocket 已断开");
                    }
                });
            }

            @Override
            public 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();
                }
            }
        });
        // 初始化 PeerConnectionFactory
        initPeerConnectionFactory(P2PDemoActivity.this);
        // 创建 EglBase
        mEglBase = EglBase.create();
        // 创建 PeerConnectionFactory
        mPeerConnectionFactory = 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);
    }

    @Override
    protected 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() {
            @Override
            public void onSignalingChange(PeerConnection.SignalingState signalingState) {

            }

            @Override
            public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
                if (iceConnectionState == PeerConnection.IceConnectionState.DISCONNECTED) {
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            hangUp();
                        }
                    });
                }
            }

            @Override
            public void onIceConnectionReceivingChange(boolean b) {

            }

            @Override
            public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {

            }

            @Override
            public void onIceCandidate(IceCandidate iceCandidate) {
                ShowLogUtil.verbose("onIceCandidate--->" + iceCandidate);
                sendIceCandidate(iceCandidate);
            }

            @Override
            public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {

            }

            @Override
            public void onAddStream(MediaStream mediaStream) {
                ShowLogUtil.verbose("onAddStream--->" + mediaStream);
                if (mediaStream == null || mediaStream.videoTracks == null || mediaStream.videoTracks.isEmpty()) {
                    return;
                }
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        SurfaceViewRenderer svrRemote = findViewById(R.id.svr_remote);
                        mediaStream.videoTracks.get(0).addSink(svrRemote);
                    }
                });
            }

            @Override
            public void onRemoveStream(MediaStream mediaStream) {

            }

            @Override
            public void onDataChannel(DataChannel dataChannel) {

            }

            @Override
            public void onRenegotiationNeeded() {

            }

            @Override
            public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {

            }
        });
        return peerConnection;
    }

    private void call() {
        // 创建 PeerConnection
        mPeerConnection = createPeerConnection();
        // 为 PeerConnection 添加音轨、视轨
        mPeerConnection.addTrack(mAudioTrack, STREAM_IDS);
        mPeerConnection.addTrack(mVideoTrack, STREAM_IDS);
        // 通过 PeerConnection 创建 offer,获取 sdp
        MediaConstraints mediaConstraints = new MediaConstraints();
        mPeerConnection.createOffer(new MySdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {
                ShowLogUtil.verbose("create offer success.");
                // 将 offer sdp 作为参数 setLocalDescription
                mPeerConnection.setLocalDescription(new MySdpObserver() {
                    @Override
                    public void onCreateSuccess(SessionDescription sessionDescription) {

                    }

                    @Override
                    public void onSetSuccess() {
                        ShowLogUtil.verbose("set local sdp success.");
                        // 发送 offer sdp
                        sendOffer(sessionDescription);

                    }
                }, sessionDescription);
            }

            @Override
            public 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) {
        // 创建 PeerConnection
        mPeerConnection = createPeerConnection();
        // 为 PeerConnection 添加音轨、视轨
        mPeerConnection.addTrack(mAudioTrack, STREAM_IDS);
        mPeerConnection.addTrack(mVideoTrack, STREAM_IDS);
        // 将 offer sdp 作为参数 setRemoteDescription
        mPeerConnection.setRemoteDescription(new MySdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {

            }

            @Override
            public void onSetSuccess() {
                ShowLogUtil.verbose("set remote sdp success.");
                // 通过 PeerConnection 创建 answer,获取 sdp
                MediaConstraints mediaConstraints = new MediaConstraints();
                mPeerConnection.createAnswer(new MySdpObserver() {
                    @Override
                    public void onCreateSuccess(SessionDescription sessionDescription) {
                        ShowLogUtil.verbose("create answer success.");
                        // 将 answer sdp 作为参数 setLocalDescription
                        mPeerConnection.setLocalDescription(new MySdpObserver() {
                            @Override
                            public void onCreateSuccess(SessionDescription sessionDescription) {

                            }

                            @Override
                            public void onSetSuccess() {
                                ShowLogUtil.verbose("set local sdp success.");
                                // 发送 answer sdp
                                sendAnswer(sessionDescription);
                            }
                        }, sessionDescription);
                    }

                    @Override
                    public 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 作为参数 setRemoteDescription
        mPeerConnection.setRemoteDescription(new MySdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {

            }

            @Override
            public 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() {
        // 关闭 PeerConnection
        if (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() {
        @Override
        public void onOpen() {

        }

        @Override
        public void onClose() {

        }

        @Override
        public 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)) {
            @Override
            public void onOpen(ServerHandshake handshakedata) {
                ShowLogUtil.debug("onOpen");
                mOnWebSocketClientListener.onOpen();
            }

            @Override
            public void onMessage(String message) {
//                ShowLogUtil.debug("onMessage--->" + message);
                mOnWebSocketClientListener.onMessage(message);
            }

            @Override
            public void onClose(int code, String reason, boolean remote) {
                ShowLogUtil.debug("onClose--->" + code);
                mOnWebSocketClientListener.onClose();
            }

            @Override
            public 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 SnapKit

class 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 = 1280
    private static let HEIGHT = 720
    private static let FPS = 30
    
    private 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? = nil
    private var tfServerUrl: UITextField? = nil
    private let webSocketHelper = WebSocketClientHelper()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 表明 View 不要扩展到整个屏幕,而是在 NavigationBar 下的区域
        edgesForExtendedLayout = UIRectEdge()
        self.view.backgroundColor = UIColor.black
        // WebSocket 状态文本框
        lbWebSocketState = UILabel()
        lbWebSocketState!.textColor = UIColor.white
        lbWebSocketState!.text = "WebSocket 已断开"
        self.view.addSubview(lbWebSocketState!)
        lbWebSocketState!.snp.makeConstraints({ make in
            make.left.equalToSuperview().offset(30)
            make.right.equalToSuperview().offset(-30)
            make.height.equalTo(40)
        })
        // 服务器地址输入框
        tfServerUrl = UITextField()
        tfServerUrl!.textColor = UIColor.white
        tfServerUrl!.text = "ws://192.168.1.105:8888"
        tfServerUrl!.placeholder = "请输入服务器地址"
        tfServerUrl!.delegate = self
        self.view.addSubview(tfServerUrl!)
        tfServerUrl!.snp.makeConstraints({ make in
            make.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.lightGray
        btnConnect.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 in
            make.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.lightGray
        btnDisconnect.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 in
            make.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.lightGray
        btnCall.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 in
            make.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.lightGray
        btnHangUp.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 in
            make.left.equalToSuperview().offset(30)
            make.width.equalTo(80)
            make.height.equalTo(40)
            make.top.equalTo(btnCall.snp.bottom).offset(10)
        })
        webSocketHelper.setDelegate(delegate: self)
        // 初始化 PeerConnectionFactory
        initPeerConnectionFactory()
        // 创建 EglBase
        // 创建 PeerConnectionFactory
        peerConnectionFactory = createPeerConnectionFactory()
        // 创建音轨
        audioTrack = createAudioTrack(peerConnectionFactory: peerConnectionFactory)
        // 创建视轨
        videoTrack = createVideoTrack(peerConnectionFactory: peerConnectionFactory)
        let tuple = createVideoCapturer(videoSource: videoTrack!.source)
        let captureDevice = tuple.captureDevice
        videoCapturer = tuple.videoCapture
        // 初始化本地视频渲染控件
        localView = RTCEAGLVideoView()
        localView.delegate = self
        self.view.insertSubview(localView,at: 0)
        localView.snp.makeConstraints({ make in
            make.width.equalToSuperview()
            make.height.equalTo(localView.snp.width).multipliedBy(16.0/9.0)
            make.centerY.equalToSuperview()
        })
        videoTrack?.add(localView!)
        // 初始化远端视频渲染控件
        remoteView = RTCEAGLVideoView()
        remoteView.delegate = self
        self.view.insertSubview(remoteView, aboveSubview: localView)
        remoteView.snp.makeConstraints({ make in
            make.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 = nil
        peerConnection?.close()
        peerConnection = nil
        webSocketHelper.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 = true
        return audioTrack
    }
    
    private func createVideoTrack(peerConnectionFactory: RTCPeerConnectionFactory) -> RTCVideoTrack? {
        let videoSource = peerConnectionFactory.videoSource()
        let videoTrack = peerConnectionFactory.videoTrack(with: videoSource, trackId: P2PDemoViewController.VIDEO_TRACK_ID)
        videoTrack.isEnabled = true
        return 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 = c
                break
            }
        }
        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() {
        // 创建 PeerConnection
        peerConnection = createPeerConnection()
        // 为 PeerConnection 添加音轨、视轨
        peerConnection?.add(audioTrack!, streamIds: P2PDemoViewController.STREAM_IDS)
        peerConnection?.add(videoTrack!, streamIds: P2PDemoViewController.STREAM_IDS)
        // 通过 PeerConnection 创建 offer,获取 sdp
        let mandatoryConstraints: [String : String] = [:]
        let optionalConstraints: [String : String] = [:]
        let mediaConstraints = RTCMediaConstraints(mandatoryConstraints: mandatoryConstraints, optionalConstraints: optionalConstraints)
        peerConnection?.offer(for: mediaConstraints, completionHandler: { sessionDescription, error in
            ShowLogUtil.verbose("create offer success.")
            // 将 offer sdp 作为参数 setLocalDescription
            self.peerConnection?.setLocalDescription(sessionDescription!, completionHandler: { _ in
                ShowLogUtil.verbose("set local sdp success.")
                // 发送 offer sdp
                self.sendOffer(offer: sessionDescription!)
            })
        })
    }
    
    private func sendOffer(offer: RTCSessionDescription) {
        var jsonObject = [String : String]()
        jsonObject["msgType"] = "sdp"
        jsonObject["type"] = "offer"
        jsonObject["sdp"] = offer.sdp
        do {
            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) {
        // 创建 PeerConnection
        peerConnection = createPeerConnection()
        // 为 PeerConnection 添加音轨、视轨
        peerConnection?.add(audioTrack!, streamIds: P2PDemoViewController.STREAM_IDS)
        peerConnection?.add(videoTrack!, streamIds: P2PDemoViewController.STREAM_IDS)
        // 将 offer sdp 作为参数 setRemoteDescription
        peerConnection?.setRemoteDescription(offer, completionHandler: { _ in
            ShowLogUtil.verbose("set remote sdp success.")
            // 通过 PeerConnection 创建 answer,获取 sdp
            let mandatoryConstraints : [String : String] = [:]
            let optionalConstraints : [String : String] = [:]
            let mediaConstraints = RTCMediaConstraints(mandatoryConstraints: mandatoryConstraints, optionalConstraints: optionalConstraints)
            self.peerConnection?.answer(for: mediaConstraints, completionHandler: { sessionDescription, error in
                ShowLogUtil.verbose("create answer success.")
                // 将 answer sdp 作为参数 setLocalDescription
                self.peerConnection?.setLocalDescription(sessionDescription!, completionHandler: { _ in
                    ShowLogUtil.verbose("set local sdp success.")
                    // 发送 answer sdp
                    self.sendAnswer(answer: sessionDescription!)
                })
            })
        })
    }
    
    private func sendAnswer(answer: RTCSessionDescription) {
        var jsonObject = [String : String]()
        jsonObject["msgType"] = "sdp"
        jsonObject["type"] = "answer"
        jsonObject["sdp"] = answer.sdp
        do {
            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 作为参数 setRemoteDescription
        peerConnection?.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.sdpMid
        jsonObject["label"] = iceCandidate.sdpMLineIndex
        jsonObject["candidate"] = iceCandidate.sdp
        do {
            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() {
        // 关闭 PeerConnection
        peerConnection?.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 = stream
            if 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? String
            if ("sdp" == msgType) {
                let type = jsonObject["type"] as? String
                if ("offer" == type) {
                    let sdp = jsonObject["sdp"] as! String
                    let offer = RTCSessionDescription(type: .offer, sdp: sdp)
                    receivedOffer(offer: offer)
                } else if ("answer" == type) {
                    let sdp = jsonObject["sdp"] as! String
                    let answer = RTCSessionDescription(type: .answer, sdp: sdp)
                    receivedAnswer(answer: answer)
                }
            } else if ("iceCandidate" == msgType) {
                let id = jsonObject["id"] as? String
                let label = jsonObject["label"] as? Int32
                let candidate = jsonObject["candidate"] as? String
                let 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 Starscream

public 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 in
            switch event {
            case .connected(let headers):
                self.delegate?.onOpen()
                break
            case .disconnected(let reason, let code):
                self.delegate?.onClose()
                break
            case .text(let string):
                self.delegate?.onMessage(message: string)
                break
            case .binary(let data):
                break
            case .ping(_):
                break
            case .pong(_):
                break
            case .viabilityChanged(_):
                break
            case .reconnectSuggested(_):
                break
            case .cancelled:
                self.delegate?.onClose()
                break
            case .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 传送门

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/418868.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

HTTP协议:当下最主流的应用层协议之一,你确定不了解一下吗?

一.HTTP协议的含义http是什么&#xff1f;超文本传输协议&#xff08;Hyper Text Transfer Protocol&#xff0c;HTTP&#xff09;是一个简单的请求-响应协议&#xff0c;它通常运行在TCP之上。‘超’可以理解为除了文本之外的图片&#xff0c;音频和视频&#xff0c;和一些其他…

硬盘、文件系统相关常识

1.硬盘 以机械硬盘为例&#xff0c;下面是机械硬盘的外形结构。 结构图&#xff1a; 每个磁盘分为两个盘面&#xff0c;每个盘面中有很多磁道(Disk Track)&#xff0c;每个磁道上有很多扇区(Sector)&#xff0c;磁道上的一段一段的就是扇区。 扇区是最小的单位&#xff0c;…

Flutter开发日常练习-黑白主题

1.添加了白天黑夜模式 2.country_picker: ^2.0.20 城市信息框架 3.image_picker: ^0.8.53 photo_manager: ^2.3.0 相机和相册的调用 4.shared_preferences: ^2.0.8 sqflite: ^1.3.1 path: 数据异步持久化到磁盘 注:登录的时候记录一下登录状态isLogin,通过isLogin来标记是否…

OCR之论文笔记TrOCR

文章目录TrOCR: Transformer-based Optical Character Recognition with Pre-trained Models一. 简介二. TrOCR2.1. Encoder2.2 Decoder2.3 Model Initialiaztion2.4 Task Pipeline2.5 Pre-training2.6 Fine-tuning2.7 Data Augmentation三. 实验3.1 Data3.2 Settings3.2 Resul…

如何战胜AI?唯努力尔-- DSP算法的FPGA实现指南

如何战胜AI?唯努力尔! DSP算法的FPGA实现指南! 来一集番外。 而这 也是开坑的第一个算法&#xff01;我们先讲案例再谈实现指南 文章目录如何战胜AI?唯努力尔! DSP算法的FPGA实现指南!观前提醒实用算法原理数学原理代码模块划分与实现FIR滤波器误差计算与系数更新模块最终代…

算法 贪心2 || 122.买卖股票的最佳时机II 55. 跳跃游戏 45.跳跃游戏II

122.买卖股票的最佳时机II 如果想到其实最终利润是可以分解的&#xff0c;那么本题就很容易了&#xff01; 如何分解呢&#xff1f; 假如第0天买入&#xff0c;第3天卖出&#xff0c;那么利润为&#xff1a;prices[3] - prices[0]。 相当于(prices[3] - prices[2]) (prices[2…

HBuilderX 开发工具

介绍 uni-app 官方推荐使用 HBuilderX 来开发 uni-app 类型的项目。 主要好处&#xff1a; 模板丰富完善的智能提示一键运行 下载 HBuilderX 1、官网下载地址&#xff1a;https://www.dcloud.io/hbuilderx.html 2、下载正式版&#xff08;根据自己电脑选&#xff09; 安装…

( “树” 之 DFS) 112. 路径总和 ——【Leetcode每日一题】

112. 路径总和 给你二叉树的根节点 root 和一个表示目标和的整数 targetSum 。判断该树中是否存在 根节点到叶子节点 的路径&#xff0c;这条路径上所有节点值相加等于目标和 targetSum 。如果存在&#xff0c;返回 true &#xff1b;否则&#xff0c;返回 false 。 叶子节点…

虚假评论检测可视化系统的实现

菜鸟一枚&#xff0c;大佬勿喷&#xff0c;主要是想分享&#xff0c;希望能帮到像我一样的人。 主要代码是参考&#xff1a;https://github.com/SoulDGXu/NLPVisualizationSystem/tree/master/frontend 他这个代码实现了词云、摘要生成等功能吧。因为我做的是虚假评论检测系统&…

星环科技自研技术,加速大数据从持久化、统一化、资产化、业务化到生态化

从2013年成立开始&#xff0c;星环科技就专注于大数据基础技术与企业数据业务的更好结合&#xff0c;同时面对中国更为复杂的数据应用场景&#xff0c;研发了多种更贴合国内大数据应用需求的大数据管理技术&#xff0c;在大数据技术领域有多项基础技术突破。星环科技在坚持技术…

尚硅谷大数据技术Zookeeper教程-笔记02【服务器动态上下线监听案例、ZooKeeper分布式锁案例、企业面试真题】

视频地址&#xff1a;【尚硅谷】大数据技术之Zookeeper 3.5.7版本教程_哔哩哔哩_bilibili 尚硅谷大数据技术Zookeeper教程-笔记01【Zookeeper(入门、本地安装、集群操作)】尚硅谷大数据技术Zookeeper教程-笔记02【服务器动态上下线监听案例、ZooKeeper分布式锁案例、企业面试真…

Vue3 关于setup与自定义指令

setup语法糖 最大好处就是所有声明部分皆可直接使用&#xff0c;无需return出去 注意&#xff1a;部分功能还不完善&#xff0c;如&#xff1a;name、render还需要单独加入script标签按compositionAPI方式编写 // setup 下还可以附加<script> setup语法糖独有 &…

【CocosCreator入门】CocosCreator组件 | Graphics(绘制)组件

Cocos Creator 是一款流行的游戏开发引擎&#xff0c;具有丰富的组件和工具&#xff0c;其中Graphics组件允许您在游戏中绘制2D图形和几何形状&#xff0c;并通过编写脚本来控制其外观和行为。 目录 一、组件属性 二、组件方法 三、脚本示例 一、组件属性 属性功能说明lineW…

MySQL删除数据1093错误

现象&#xff1a;在进行更新和删除操作的时候&#xff0c;条件语句里面有子查询语句&#xff0c;此时会报1093错误&#xff01; 错误日志&#xff1a;1093 - You can’t specify target table ‘t_suer_study_video’ for update in FROM clause 首先根据条件来查询 SELECT * …

动手学深度学习V2的笔记小记

自动求导 两种方式&#xff1a;正向&#xff0c;反向 内存复杂度&#xff1a;O(n) 计算复杂度&#xff1a;O(n) 线性回归 梯度下降通过不断沿着反梯度方向更新参数求解 两个重要的超参数是批量大小和学习率 小批量随机梯度下降是深度学习默认的求解算法 训练误差和泛化误差 训练…

Matlab论文插图绘制模板第85期—模值赋色的箭头图

在之前的文章中&#xff0c;分享了Matlab箭头图的绘制模板&#xff1a; 进一步&#xff0c;如果我们想对每一个箭头赋上颜色&#xff0c;以更加直观地表示其模值的大小&#xff0c;该怎么操作呢&#xff1f; 那么&#xff0c;来看一下模值赋色的箭头图的绘制模板。 先来看一下…

微短剧的春天里,抖音、快手各有所思

2023年&#xff0c;微短剧将延续爆发之势。 从今年热播的《二十九》《二见钟情》《步步为陷》《倾世小狂医》《开局一座山》《都市至尊赘婿》等作品上&#xff0c;我们看到了微短剧题材的扩展和剧情的张力。相比刚刚兴起时&#xff0c;微短剧脑洞更大、质量更优&#xff0c;还…

CC2642的GGS使用笔记

一、前言 我们了解BLE的GATT之前需要了解一些基本的概念&#xff1a; &#xff08;1&#xff09;Profile,字面意思简介、概述、形象印象、轮廓、配置文件&#xff0c;在BLE中&#xff0c;我们可能把它理解成配置文件较好&#xff0c;Profile有一些是BLE SIG规定的&#xff0c;有…

从Vue2到Vue3的差别学习升级

目录 1 从data,methods到setup 超级NB的写法 2 使用props、emit和context 使用props 使用emit 3 路由变化 4 变量初始化的变化 1 从data,methods到setup <script> export default {components:{NPagination:NPagination,},name: "MyPaging",setup(){//…

Python实现哈里斯鹰优化算法(HHO)优化BP神经网络分类模型(BP神经网络分类算法)项目实战

说明&#xff1a;这是一个机器学习实战项目&#xff08;附带数据代码文档视频讲解&#xff09;&#xff0c;如需数据代码文档视频讲解可以直接到文章最后获取。 1.项目背景 2019年Heidari等人提出哈里斯鹰优化算法(Harris Hawk Optimization, HHO)&#xff0c;该算法有较强的全…