大家好,我是楊成功。
之前介紹過 WebRTC,簡單來說它是一個點對點的實時通訊技術,主要基於瀏覽器來實現音影片通訊。這項技術目前已經被廣泛應用於實時視訊通話,多人會議等場景。
不過 WebRTC 因為其過於優秀的表現,其應用範圍已經不限於 Web 端,移動 App 也基本實現了 WebRTC 的 API。在跨平臺框架中,Flutter 和 React Native 都實現了對 WebRTC 的支援。
我們以 App(React Native)為呼叫端,Web(React)為接收端,分別介紹兩端如何進行視訊通話。
接收端 React 實現
React 執行在瀏覽器中,無需引用任何模組,可以直接使用 WebRTC API。下面分幾個步驟,逐步介紹在 Web 端如何獲取、傳送、接受遠端影片流。
1. 獲取本地攝像頭流,並儲存。
const stream = null;
const getMedia = async () => {
let ori_stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
stream = ori_stream;
};
2. 建立 video 標籤用於播放影片。
建立兩個 video 標籤,分別用於播放本地影片和遠端影片。
const local_video = useRef();
const remote_video = useRef();
const Player = () => {
return (
<div>
<video ref={local_video} autoPlay muted />;
<video ref={remote_video} autoPlay muted />;
</div>
);
};
// stream 是上一步獲取的本地影片流
if (local_video.current) {
local_video.current.srcObject = stream;
}
3. 建立 RTC 連線例項。
每一個使用 WebRTC 通訊的客戶端,都要建立一個 RTCPeerConnection 連線例項,該例項是真正負責通訊的角色。WebRTC 通訊的實質就是 RTCPeerConnection 例項之間的通訊。
// 1. 建立例項
let peer = new RTCPeerConnection();
// 2. 將本地影片流新增到例項中
stream.getTracks().forEach((track) => {
peer.addTrack(track, stream);
});
// 3. 接收遠端影片流並播放
peer.ontrack = async (event) => {
let [remoteStream] = event.streams;
remote_video.current.srcObject = remoteStream;
};
例項建立之後,別忘記將上一步獲取的攝像頭流新增到例項中。
當兩端連線完全建立之後,在 peer.ontrack 之內就能接收到對方的影片流了。
4. 連線信令伺服器,準備與 App 端通訊。
WebRTC 的通訊過程需要兩個客戶端實時進行資料交換。交換內容分為兩大部分:
- 交換 SDP(媒體資訊)。
- 交換 ICE(網路資訊)。
因此我們需要一個 WebSocket 伺服器來連線兩個客戶端進行傳輸資料,該伺服器在 WebRTC 中被稱為信令伺服器
。
我們已經基於 socket.io 搭建了信令伺服器,現在需要客戶端連線,方式如下。
(1)安裝 socket.io-client:
$ yarn add socket.io-client
(2)連線伺服器,並監聽訊息。
連線伺服器時帶上驗證資訊(下面的使用者 ID、使用者名稱),方便在通訊時可以找到對方。
import { io } from 'socket.io-client';
const socket = null;
const socketInit = () => {
let sock = io(`https://xxxx/webrtc`, {
auth: {
userid: '111',
username: '我是接收端',
role: 'reader',
},
});
sock.on('connect', () => {
console.log('連線成功');
});
socket = sock;
};
useEffect(() => {
socketInit();
}, []);
經過上面 4 個步驟,我們的準備工作已經做好了。結下來可以進行正式的通訊步驟了。
5. 接收 offer,交換 SDP。
監聽 offer 事件(呼叫端發來的 offer 資料),然後建立 answer 併發回呼叫端。
// 接收 offer
socket.on('offer', (data) => {
transMedia(data);
});
// 傳送 answer
const transMedia = async (data: any) => {
let offer = new RTCSessionDescription(data.offer);
await peer.setRemoteDescription(offer);
let answer = await peer.createAnswer();
socket.emit('answer', {
to: data.from, // 呼叫端 Socket ID
answer,
});
await peer.setLocalDescription(answer);
};
6. 接收 candidate,交換 ICE。
監聽 candid 事件(呼叫端發來的 candidate 資料)並新增到本地 peer 例項,然後監聽本地的 candidate 資料併發回給呼叫端。
上一步執行 peer.setLocalDescription() 之後,就會觸發 peer.onicecandidate
事件。
// 接收 candidate
socket.on('candid', (data) => {
let candid = new RTCIceCandidate(data.candid);
peer.addIceCandidate(candid);
});
// 傳送 candidate
peer.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('candid', {
to: data.from, // 呼叫端 Socket ID
candid: event.candidate,
});
}
};
至此,整個通訊過程就完成了。如果沒有意外,此時在第 3 步的 peer.ontrack 事件內拿到的對端影片流開始傳輸資料,我們可以看到對端的影片畫面了。
呼叫端 React Native 實現
在 React Native 端並不能直接使用 WebRTC API,我們需要一個第三方模組 react-native-webrtc
來實現,它提供了和 Web 端幾乎一致的 API。
幸運的是,React Native 可以複用 Web 端的大多數邏輯性資源,socket.io-client 可以直接安裝使用,和 Web 端完全一致。
不幸的是,App 開發少不了原生的環境配置、許可權配置,這些比較繁瑣,接下來介紹如何實現吧。
1. 建立 React Native 專案。
建立專案之前需要配置開發環境,我們以 Android 為例,具體的配置可以看這篇文章。
配置完成之後,直接透過 npx 命令建立專案,命名為 RnWebRTC
:
$ npx react-native init RnWebRTC --template react-native-template-typescript
提示:如果不想使用 TypeScript,將 --template 選項和後面的內容去掉即可。
我安裝的最新版本如下:
- react: "18.1.0"
- react-native: "0.70.6"
建立之後,檢視根目錄的 index.js
和 App.tsx
兩個檔案,分別是入口檔案和頁面元件,我們就在 App.tsx 這個元件中編寫程式碼。
我們以安卓為例,直接用手機資料線連線電腦,開啟 USB 除錯模式,然後執行以下命令:
$ yarn run android
執行該命令會安裝 Gradle(安卓包管理)依賴,並編譯打包 Android 應用。第一次執行耗時比較久,耐心等待打包完成後,手機上會提示安裝該 App。
該命令在打包的同時,還會單獨啟動一個終端,執行 Metro 開發伺服器。Metro 的作用是監 JS 程式碼修改,並打包成 js bundle 交給原生 App 去渲染。
單獨啟動 Metro 伺服器,可執行 yarn run start
。
2. 安裝 react-native-webrtc。
直接執行安裝命令:
$ yarn add react-native-webrtc
安裝之後並不能直接使用,需要在 android 資料夾中修改兩處程式碼。
第一處:因為 react-native-webrtc 需要最低的安卓 SDK 版本為 24
,而預設生成的最低版本是 21,所以修改 android/build.gradle
中的配置:
buildscript {
ext {
minSdkVersion = 24
}
}
第二處:webrtc 需要攝像頭、麥克風等許可權,所以我們把許可權先配齊嘍。在 android/app/src/main/AndroidManifest.xml
中的 <application>
標籤前新增如下配置:
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" />
接著執行命令重新打包安卓:
$ yarn run android
現在就可以在 App.tsx 中匯入和使用 WebRTC 的相關 API 了:
import {
ScreenCapturePickerView,
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
RTCView,
MediaStream,
MediaStreamTrack,
mediaDevices,
} from 'react-native-webrtc';
3. 連線信令伺服器,準備與 Web 端通訊。
因為 React Native 可以直接使用 socket.io-client
模組,所以這一步和 Web 端的程式碼一致。
$ yarn add socket.io-client
連線信令伺服器時,只是傳遞的驗證資訊不同:
import { io } from 'socket.io-client';
const socket = null;
const socketInit = () => {
let sock = io(`https://xxxx/webrtc`, {
auth: {
userid: '222',
username: '我是呼叫端',
role: 'sender',
},
});
sock.on('connect', () => {
console.log('連線成功');
});
socket = sock;
};
useEffect(() => {
socketInit();
}, []);
4. 使用 RTCView 元件播放影片。
建立兩個 RTCView 元件,分別用於播放本地影片和遠端影片。
import { mediaDevices, RTCView } from 'react-native-webrtc';
var local_stream = null;
var remote_stream = null;
// 獲取本地攝像頭
const getMedia = async () => {
local_stream = await mediaDevices.getUserMedia({
audio: true,
video: true,
});
};
// 播放影片元件
const Player = () => {
return (
<View>
<RTCView style={{ height: 500 }} streamURL={local_stream.toURL()} />
<RTCView style={{ height: 500 }} streamURL={remote_stream.toURL()} />
</View>
);
};
5. 建立 RTC 連線例項。
這一步與 Web 端基本一致,接收到遠端流直接賦值。
// 1. 建立例項
let peer = new RTCPeerConnection();
// 2. 將本地影片流新增到例項中
local_stream.getTracks().forEach((track) => {
peer.addTrack(track, local_stream);
});
// 3. 接收遠端影片流
peer.ontrack = async (event) => {
let [remoteStream] = event.streams;
remote_stream = remoteStream;
};
6. 建立 offer,開始交換 SDP。
App 端作為呼叫端,需要主動建立 offer 併發給接收端,並監聽接收端發回的 answer:
// 傳送 offer
const peerInit = async (socket_id) => {
let offer = await peer.createOffer();
peer.setLocalDescription(offer);
socket.emit('offer', {
to: socket_id, // 接收端 Socket ID
offer: offer,
});
};
// 接收 answer
socket.on('answer', (data) => {
let answer = new RTCSessionDescription(data.answer);
peer.setRemoteDescription(answer);
});
7. 監聽 candidate,交換 ICE。
這一步依然與 Web 端一致,分別是傳送本地的 candidate 和接收遠端的 candidate:
// 傳送 candidate
peer.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('candid', {
to: socket_id, // 接收端 Socket ID
candid: event.candidate,
});
}
};
// 接收 candidate
socket.on('candid', (data) => {
let candid = new RTCIceCandidate(data.candid);
peer.addIceCandidate(candid);
});
至此,App 端與 Web 端的視訊通話流程已經完成,現在兩端可以互相看到對方的影片畫面了。
搭建 socket.io 信令伺服器
在 Web 和 App 兩端視訊通話時用到了信令伺服器,該服務使用 socket.io 實現,程式碼並不複雜,下面介紹下信令伺服器如何編寫:
1. 建立專案,安裝需要的依賴。
建立 socket-server
資料夾,並執行以下命令生成 package.json:
$ npm init
接著安裝必要的模組:
$ npm install koa socket.io
2. 建立入口檔案,啟動 socket 服務。
建立 app.js 檔案,寫入以下程式碼:
const Koa = require('koa');
const http = require('http');
const SocketIO = require('socket.io');
const SocketIoApi = require('./io.js');
const app = new Koa();
const server = http.createServer(app.callback());
const io = new SocketIO.Server(server, {
cors: { origin: '*' },
allowEIO3: true,
});
app.context.io = io; // 將socket例項存到全域性
new SocketIoApi(io);
server.listen(9800, () => {
console.log(`listen to http://localhost:9800`);
});
程式碼中使用 Koa
框架執行一個伺服器,並且接入了 socket.io
,一個基本的 WebSocket 伺服器就寫好了。接著將它執行起來:
$ node app.js
此時 Koa 執行的 HTTP 伺服器和 socket.io 執行的 WebSocket 伺服器共享 9800 埠。
3. 建立 io.js,編寫信令伺服器邏輯。
上一步在入口檔案 app.js 中引用了 io.js,該檔案匯出一個類,我們來編寫具體的邏輯:
// io.js
class IoServer {
constructor(io) {
this.io = io;
this.rtcio = io.of('/webrtc');
this.rtcio.on('connection', (socket) => {
this.rtcListen(socket);
});
}
rtcListen(socket) {
// 傳送端|傳送 offer
socket.on('offer', (json) => {
let { to, offer } = json;
let target = this.rtcio.sockets.get(to);
if (target) {
target.emit('offer', {
from: socket.id,
offer,
});
} else {
console.error('offer 接收方未找到');
}
});
// 接收端|傳送 answer
socket.on('answer', (json) => {
let { to, answer } = json;
let target = this.rtcio.sockets.get(to);
// console.log(to, socket)
if (target) {
target.emit('answer', {
from: socket.id,
answer,
});
} else {
console.error('answer 接收方未找到');
}
});
// 傳送端|傳送 candidate
socket.on('candid', (json) => {
let { to, candid } = json;
let target = this.rtcio.sockets.get(to);
// console.log(to, socket)
if (target) {
target.emit('candid', {
from: socket.id,
candid,
});
} else {
console.error('candid 接收方未找到');
}
});
}
}
module.exports = IoServer;
上面程式碼的邏輯中,當客戶端連線到伺服器,就開始監聽 offer
,answer
,candid
三個事件。當有客戶端傳送訊息,這裡負責將訊息轉發給另一個客戶端。
兩個客戶端透過唯一的 Socket ID 找到對方。在 socket.io 中,每一次連線都會產生一個唯一的 Socket Id。
4. 獲取已連線的接收端列表。
因為每次重新整理瀏覽器 WebSocket 都要重新連線,因此每次的 Socket ID 都不相同。為了在呼叫端準確找到線上的接收端,我們寫一個獲取接收端列表的介面。
在入口檔案中透過 app.context.io = io
將 SocketIO 例項全域性儲存到了 Koa 中,那麼獲取已連線的接收端方式如下:
app.get('/io-clients', async (ctx, next) => {
let { io } = ctx.app.context;
try {
let data = await io.of('/webrtc').fetchSockets();
let resarr = data
.map((row) => ({
id: row.id,
auth: row.handshake.auth,
data: row.data,
}))
.filter((row) => row.auth.role == 'reader');
ctx.body = { code: 200, data: resarr };
} catch (error) {
ctx.body = error.toString();
}
});
然後在呼叫端使用 GET 請求 https://xxxx/io-clients
可拿到線上接收端的 Socket ID 和其他資訊,然後選擇一個接收端發起連線。
注意:線上上獲取攝像頭和螢幕時要求域名必須是 https,否則無法獲取。因此切記給 Web 端和信令伺服器都配置好 https 的域名,可以避免通訊時發生異常。
TURN 跨網路影片通訊
前面我們實現了 App 端和 Web 端雙端通訊,但是通訊成功有一個前提:兩端必須連線同一個 WIFI
。
這是因為 WebRTC 的通訊是基於 IP 地址和埠來找到對方,如果兩端連線不同的 WIFI(不在同一個網段),或是 App 用流量 Web 端用 WIFI,那麼兩端的 IP 地址誰都不認識誰,自然無法建立通訊。
當兩端不在一個區域網時,優先使用 STUN 伺服器,將兩端的本地 IP 轉換為公網 IP 進行連線。STUN 伺服器我們直接使用谷歌的就可以,但是因為防火牆等各種原因,實際測試 STUN 基本是連不通的。
當兩端不能找到對方,無法直接建立連線時,那麼我們就要使用兜底方案 ——— 用一箇中繼伺服器轉發資料,將媒體流資料轉發給對方。該中繼伺服器被稱為 TURN 伺服器。
TURN 伺服器因為要轉發資料流,因此對頻寬消耗比較大,需要我們自己搭建。目前有很多開源的 TURN 伺服器方案,經過效能測試,我選擇使用 Go 語言開發的 pion/turn 框架。
1. 安裝 Golang:
使用 pion/turn 的第一步是在你的伺服器上安裝 Golang。我使用的是 Linux Centos7 系統,使用 yum
命令安裝最方便。
$ yum install -y epel-release # 新增 epel 源
$ yum install -y golang # 直接安裝
安裝之後使用如下命令檢視版本,測試是否安裝成功:
$ go version
go version go1.17.7 linux/amd64
2. 執行 pion/turn:
直接將 pion/turn 的原始碼拉下來:
$ git clone https://github.com/pion/turn ./pion-turn
原始碼中提供了很多案例可以直接使用,我們使用 examples/turn-server/simple
這個目錄下的程式碼:
$ cd ./pion-turn/examples/turn-server/simple
$ go build
使用 go build
編譯後,當前目錄下會生成一個 simple 檔案,該檔案是可執行檔案,使用該檔案啟動 TURN 伺服器:
$ ./simple -public-ip 123.45.67.89 -users ruidoc=123456
上面命令中的 123.45.67.89
是你伺服器的公網 IP,並配置一個使用者名稱和密碼分別為 ruidoc
和 123456
,這幾項配置根據你的實際情況設定。
預設情況下該命令不會後臺執行,我們用下面的方式讓它後臺執行:
$ nohup ./simple -public-ip 123.45.67.89 -users ruidoc=123456 &
此時,該 TURN 服務已經在後臺執行,並佔用一個 3478
的 UDP 埠。我們檢視一下埠占用:
$ netstat -nplu
從上圖可以看出,該服務已經在執行中。
3. 配置安全組、檢測 TURN 是否可用。
上一步已經啟動了 TURN 伺服器,但是預設情況下從外部連線不上(這裡是個坑),因為阿里雲需要在安全組的入方向新增一條 UPD 3478 埠(其他雲服務商也差不多),表示該埠允許從外部訪問。
安全組新增後,我們就可以測試 TURN 伺服器的連通性了。
開啟 Trickle ICE,新增我們的 TURN 伺服器,格式為 turn:123.45.67.89:3478
,然後輸入上一步配置的使用者名稱和密碼:
點選 Gather candidates 按鈕,會列出以下資訊。如果包含 relay
這一條,說明我們的 TURN 伺服器搭建成功,可以使用了。
4. 客戶端新增 ICE 配置。
在 App 端和 Web 端建立 RTC 例項時,加入以下配置:
var turnConf = {
iceServers: [
{
urls: 'stun:stun1.l.google.com:19302', // 免費的 STUN 伺服器
},
{
urls: 'turn:123.45.67.89:3478',
username: 'ruidoc',
credential: '123456',
},
],
};
var peer = new RTCPeerConnection(turnConf);
現在關閉手機 WIFI,使用流量與 Web 端發起連線,不出意外可以正常通訊,但是延遲好像高了一些(畢竟走中轉肯定沒有直連快)。此時再開啟手機 WIFI 重新連線,發現延遲又變低了。
這就是 WebRTC 智慧的地方。它會優先嚐試直連,如果直連不成功,最後才會使用 TURN 轉發。
App 端螢幕共享
視訊通話一般都是共享攝像頭,然而有的時候會有共享螢幕的需求。
在 Web 端共享螢幕很簡單,將 getUserMedia
改成 getDisplayMedia
即可。然而在 App 端,可能因為隱私和安全問題,實現螢幕共享比較費勁。
安卓原生端使用 mediaProjection 實現共享螢幕。在 Android 10+ 之後,如果想正確共享螢幕,必須要有一個持續存在的“前臺程式”來保證螢幕共享的程式不被系統自動殺死。
如果沒有配置前臺程式,則螢幕流無法傳輸。下面我們來介紹下在如何 App 端共享螢幕。
1. 安裝 Notifee。
Notifee 是 React Native 實現通知欄訊息的第三方庫。我們可以啟動一個持續存在的訊息通知作為前臺程式,從而使螢幕流正常推送。
使用命令安裝 Notifee:
$ yarn add @notifee/react-native@5
注意這裡安裝 Notifee 5.x
的版本,因為最新版的 7.x
需要 Android SDK 的 compileSdkVersion
為 33,而我們建立的專案預設為 31,使用 5.x 不需要修改 SDK 的版本號。
2. 註冊前臺服務。
在入口檔案 index.js 中,我們使用 Notifee 註冊一個前臺服務:
import notifee from '@notifee/react-native';
notifee.registerForegroundService((notification) => {
return new Promise(() => {});
});
這裡只需要註冊一下,不需要其他操作,因此比較簡單。接著還需要在 Android 程式碼中註冊一個 service,否則前臺服務不生效。
開啟 android/app/src/main/AndroidManifest.xml
檔案,在 <application>
標籤內新增如下程式碼:
<service
android:name="app.notifee.core.ForegroundService"
android:foregroundServiceType="mediaProjection|camera|microphone" />
3. 建立前臺通知。
註冊好前臺服務之後,接著我們在獲取螢幕前建立前臺通知,程式碼如下:
import notifee from '@notifee/react-native';
const startNoti = async () => {
try {
let channelId = await notifee.createChannel({
id: 'default',
name: 'Default Channel',
});
await notifee.displayNotification({
title: '螢幕錄製中...',
body: '在應用中手動關閉該通知',
android: {
channelId,
asForegroundService: true, // 通知作為前臺服務,必填
},
});
} catch (err) {
console.error('前臺服務啟動異常:', err);
}
};
該方法會建立一個通知訊息,具體 API 可以參考 Notifee 文件。接著在捕獲螢幕之前呼叫該方法即可:
const getMedia = async () => {
await startNoti();
let stream = await mediaDevices.getDisplayMedia();
};
最終經過測試,可以看到 App 端和 Web 端的螢幕都實現了共享。App 端效果如下:
Web 端效果如下:
攝像頭與螢幕影片混流
在一些直播教學的場景中,呼叫端會同時共享兩路影片 ——— 螢幕畫面和攝像頭畫面。在我們的經驗中,一個 RTC 連線例項中只能新增一條影片流。
如果要同時共享螢幕和攝像頭,我們首先想到的方案可能是在一個客戶端建立兩個 peer 例項,每個例項中新增一路影片流,發起兩次 RTC 連線。
事實上這種方案是低效的,增加複雜度的同時也增加了資源的損耗。那麼能不能在一個 peer 例項中新增兩路影片呢?其實是可以的。
總體思路:在呼叫端將兩條流合併為一條,在接收端再將一條流拆分為兩條。
1. 呼叫端組合流。
組合流其實很簡單。因為流是由多個媒體軌道組成,我們只需要從螢幕和攝像頭中拿到媒體軌道,再將它們塞到一個自定義的流中,一條包含兩個影片軌道的流就組合好了。
var stream = new MediaStream();
const getMedia = async () => {
let camera_stream = await mediaDevices.getUserMedia();
let screen_stream = await mediaDevices.getDisplayMedia();
screen_stream.getVideoTracks().map((row) => stream.addTrack(row));
camera_stream.getVideoTracks().map((row) => stream.addTrack(row));
camera_stream.getAudioTracks().map((row) => stream.addTrack(row));
};
程式碼中為一個自定義媒體流新增了三條媒體軌道,分別是螢幕影片、攝像頭影片和攝像頭音訊。記住這個順序,在接收端按照該順序拆流。
接著將這條媒體流新增到 peer 例項中,後面走正常的通訊邏輯即可:
stream.getTracks().forEach((track) => {
peer.addTrack(track, stream);
});
2. 接收端拆解流。
接收端在 ontrack 事件中拿到組合流,進行拆解:
peer.ontrack = async (event: any) => {
const [remoteStream] = event.streams;
let screen_stream = new MediaStream();
let camera_stream = new MediaStream();
remoteStream.getTracks().forEach((track, ind) => {
if (ind == 0) {
screen_stream.addTrack(track);
} else {
camera_stream.addTrack(track);
}
});
video1.srcObject = camera_stream; // 播放攝像頭音影片
video2.srcObject = screen_stream; // 播放螢幕影片
};
這一步中,定義兩條媒體流,然後將接收到的混合流中的媒體軌道拆分,分別新增到兩條流中,這樣螢幕流和攝像頭流就拆開了,分別在兩個 video 中播放即可。
總結
本篇比較系統的介紹了 App 端和 Web 端使用 WebRTC 通訊的全流程,可以看到整個流程還是比較複雜的。尤其是我們前端不擅長的地方,比如 TURN 搭建和連通,App 端的各種版本許可權問題,坑還是很多的。
如果有小夥伴在這個流程中遇到了問題,歡迎加我微信 ruidoc
拉你進入跨端與音影片討論群提問,或者關注我的公眾號 程式設計師成功 檢視更多文章。
再次感謝您的閱讀~