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 传送门