前言
作為一個認為啥都想懂一點的小開發,一直都對WebRTC
很感興趣,這個興趣來源於幾年前公司希望做一個即時通訊的小功能在APP
上,不過最終由於專案最終需求更改而擱置。雖然如此,但是我還是瞭解了一些關於該技術的技術背景,例如P2P
通訊、內網打洞等等。通過幾個晚上的學習和實驗,大體上了解WebRTC
的原理和使用方法,現在分享一下我的學習過程吧。
準備工作
作為一個文件黨,從來都要先看官方文件和文章,這樣才能保證自己拿到最新,最好的一手資訊。WebRTC
官網文件也還算是比較全面,不過貌似都好久沒更新了。推測是,大概很久沒有做功能升級了吧。我這次學習,參考了一些官方例子,加上了自己的理解。有錯誤的地方大家可以指出來呀,一起學習。參考的文章會在文章結尾加上。廢話不多說了,開始吧。
開啟我們的攝像頭
WebRTC
是谷歌開發的,目標是創造一個高質量的、可靠的通訊框架,從字面的意我們可以拆分為了Web
跟RTC
兩部分,Web
很好理解啊,就是基於網路,而RTC
全稱為Real Time Communications
(實時通訊),因此它的作用就是讓我們可以利用瀏覽器(也能用於APP
),進行實時的通訊的一個框架。既然是通訊媒介當然是多種的,包括視訊,語音,文字等多種多媒體資訊,甚至你還能利用它來傳輸各種檔案。下面,我們用最直觀的,視訊通訊來開始我們的學習吧。
用瀏覽器開啟攝像頭很簡單,我們可以直接呼叫JS API
實現。
- HTML
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<h1>獲得視訊流</h1>
<!-- 設定自動播放 -->
<video autoplay playsinline></video>
<script src="js/main.js"></script>
</body>
</html>
複製程式碼
- JavaScript
// 媒體流配置
const mediaStreamConstraints = {
video: true
};
// 獲得 video 標籤元素
const localVideo = document.querySelector("video");
// 媒體流物件
let localStream;
// 回撥儲存視訊流物件並把流傳到 video 標籤
function gotLocalMediaStream(mediaStream) {
localStream = mediaStream;
localVideo.srcObject = mediaStream;
}
// handle 錯誤資訊
function handleLocalMediaStreamError(error) {
console.log("開啟本地視訊流錯誤: ", error)
}
// fire!!
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
.then(gotLocalMediaStream)
.catch(handleLocalMediaStreamError);
複製程式碼
程式碼主要分2步
- 從
navigator.mediaDevices.getUserMedia
中獲得視訊裝置。 - 在
then
的回撥中把視訊流傳到video
標籤。
非常簡單吧
值得注意的是,我用的是Chrome
瀏覽器,新版本的Chrome
加強了獲取裝置的安全策略。如果你想要開啟攝像頭等裝置,你的域名如果不是本地檔案或者 localhost
那必須通過https
訪問。
使用 RTC 進行 P2P 傳輸
既然視訊流我們得到了,第二步,我們來使用WebRTC
的 RTCPeerConnection
來進行本地傳輸吧。這個Demo
不是真實的使用場景,因為不涉及到真實世界的網路傳輸,我們僅僅是在同一個頁面,開啟了兩個 RTCPeerConnection
把一個的內容傳輸到另一個,從而進行通訊。在貼程式碼之前,我們先來簡單的描述一下建立連線的過程吧。
假設現在是A想跟B視訊。他們的 offer/answer (申請?/ 應答?), 機制是這樣的:
1. `A `建立了一個 `RTCPeerConnection` 物件
2. `A` 利用`RTCPeerConnection` 的 `createOffer()` 方法建立了一個 `offer` (一個` SDP` 的會話描述)
3. `A` 在 `offer` 的回撥中使用 `setLocalDescription()` 方法儲存他的 `offer`
4. `A` 把他的 `offer` 字串化,然後通過某一種信令機制發給 `B`
5. `B` 收到 `A` 的 `offer` 後用`setRemoteDescription()` 存起來,如此一來他的 `RTCPeerConnection` 就知道了 `A` 的配置。
6. `B` 呼叫 `createAnswer()` 並用他的成功回撥的傳送他的本地會話描述:這就是 `B` 的`answer`
7. `B` 用 `setLocalDescription()` 設定了他的 `answer` 到本地的會話描述
8. 然後 `B` 用某一種信令機制把他的 `answer` 字串化之後返回給 `A`
9. `A` 把 `B` 的 `answer` 利用`setRemoteDescription()`方法存取為遠端會話描述
複製程式碼
過程看上去很麻煩,不過其實他們就做了個事情
- 建立會話描述(
SDP
) - 交換會話描述(
SDP
) - 儲存自己跟對方的會話描述
有關 SDP
的格式,可以參看文章後面的連結
下面讓我們看程式碼,走起
- HTML
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<h1>RTCPeerConnection 傳輸視訊流</h1>
<!-- 設定自動播放 -->
<video autoplay playsinline id="localVideo"></video>
<video autoplay playsinline id="remoteVideo"></video>
<div>
<button id="startBtn">開始</button>
<button id="callBtn">撥打</button>
<button id="hangupBtn">掛機</button>
</div>
<!-- 墊片,用於統一瀏覽器 API -->
<script src="js/adapter.js"></script>
<script src="js/main.js"></script>
</body>
</html>
複製程式碼
HTML 程式碼比較簡單,我們建立了兩個 video
,一個顯示遠端一個顯示本地,並且加入了三個按鈕進行模擬撥打。細心的同學可能已經發現了,我們引入了一個墊片adapter.js
。經常寫前端的同學對墊片可能熟悉不過了,因為世界上不僅僅只有谷歌的瀏覽器,還有各種各樣別的。然後命名,API
也是各種各樣,所以我們會利用各種墊片,統一我們的API
。不再忍受相容之苦。adapter.js
就是這樣的存在。他是谷歌官方提供給我們的。引入它我們便可以用統一套API
操作。
- JavaScript
由於程式碼比較長,就只貼關鍵程式碼了。全部程式碼連結我會在文章後面貼上。
// 開始按鈕,開啟本地媒體流
function startAction() {
startButton.disabled = true;
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
.then(gotLocalMediaStream).catch(handleLocalMediaStreamError);
trace('本地媒體流開啟中...');
}
複製程式碼
這是響應開始
按鈕的函式。跟第一個例子一樣,主要是用來開啟攝像頭,並且把視訊流傳到id
為localVideo
的視訊標籤。
// 撥打按鈕, 建立 peer connection
function callAction() {
callButton.disabled = true;
hangupButton.disabled = false;
trace("開始撥打...");
startTime = window.performance.now();
// ...
const servers = null; // RTC 伺服器配置
// 建立 peer connetcions 並新增事件
localPeerConnection = new RTCPeerConnection(servers);
trace("建立本地 peer connetcion 物件");
localPeerConnection.addEventListener('icecandidate', handleConnection);
localPeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);
remotePeerConnection = new RTCPeerConnection(servers);
trace("建立遠端 peer connetcion 物件");
remotePeerConnection.addEventListener('icecandidate', handleConnection);
remotePeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);
remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream);
// 新增本地流到連線中並建立連線
localPeerConnection.addStream(localStream);
trace("新增本地流到本地 PeerConnection");
trace("開始建立本地 PeerConnection offer");
localPeerConnection.createOffer(offerOptions)
.then(createdOffer).catch(setSessionDescriptionError);
}
複製程式碼
這部份是撥打
按鈕的響應函式。在這個方法中,我們做了個事情。
-
建立了用於通訊的一對
RTCPeerConnection
物件,localPeerConnection
和remotePeerConnection
-
分別給兩個
RTCPeerConnection
物件註冊了icecandidate(重要)
和iceconnectionstatechange
事件的響應函式 -
給
remotePeerConnection
註冊了addstream
事件的響應。 -
把本地視訊流新增到
localPeerConnection
-
localPeerConnection
建立offer
這裡有一個上面沒有提及的東西ICE Candidate
,ICE
是啥呢?哈哈,他的全稱是 Interactive Connectivity Establishment
互動式連線的建立。他是一個規範,說白了就是建立連線用的規範,由於我們的WebRTC
是要進行P2P
連線的,而我們的網路是非常複雜的,而且大部分都是在內網(需要打洞或者穿越防火牆)。所以我們需要一個機制來建立內網連線。這個我會在後面的文章詳細來說說。現在,簡單理解成就是建立連線用的就好了。而icecandidate
的響應方法,則是當網路可用的情況下,用於儲存和交換各種網路資訊。
// 定義 RTC peer connection
function handleConnection(event) {
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCanidate = new RTCIceCandidate(iceCandidate);
const otherPeer = getOtherPeer(peerConnection);
otherPeer.addIceCandidate(newIceCanidate)
.then(() => {
handleConnectionSuccess(peerConnection);
}).catch((error) => {
handleConnectionFailure(peerConnection, error);
});
trace(`${getPeerName(peerConnection)} ICE candidate:\n` +
`${event.candidate.candidate}.`);
}
}
複製程式碼
這段程式碼正是體現了網路資訊(ICE candidate
),的儲存和交換過程。而儲存Candidate
是通過呼叫RTCPeerConnection
物件的addIceCandidate
方法。這裡可能大家有疑問,這裡就交換了Candidate
資訊了嗎?是的getOtherPeer
方法其實就是用於獲得對方的RTCPeerConnection
物件,因為我們的 Demo 是在同一頁面建立的。所以不需通過其他載體交換。
好的,說完連線建立,我們接著說建立offer
。在建立offer
前,我們已經留意到,其實已經把本地的視訊流新增到RTCPeerConnection
物件中了,因此offer
所帶的SDP
會話描述,已經帶有相關資訊。我們先來createOffer
成功後的回撥方法。
// 建立 offer
function createdOffer(description) {
trace(`Offer from localPeerConnection:\n${description.sdp}`);
trace('localPeerConnection setLocalDescription 開始.');
localPeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
trace('remotePeerConnection setRemoteDescription 開始.');
remotePeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
trace('remotePeerConnection createAnswer 開始.');
remotePeerConnection.createAnswer()
.then(createdAnswer)
}
複製程式碼
簡單明瞭,對於localPeerConnection
來說是本地,所以就是呼叫 setLocalDescription
把offer
資訊儲存。而對於對方就是遠端remotePeerConnection
就是用setRemoteDescription
進行儲存了。這裡跟我章節前說的第4步說的不一樣,這裡沒有轉成字串。聰明的同學可能猜到為什麼了,因為這裡是同一個頁面,不需要傳輸呀。
緊接著馬上remotePeerConnection
就呼叫createAnswer
建立了一個 answer
,讓我們繼續看,
// 建立 answer
function createdAnswer(description) {
trace(`Answer from remotePeerConnection:\n${description.sdp}.`);
trace('remotePeerConnection setLocalDescription 開始.');
remotePeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
trace('localPeerConnection setRemoteDescription 開始.');
localPeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
}
複製程式碼
這裡跟上面的createOffer
回撥做的差不多,把answer
儲存到雙方對應的描述中。
到這裡為止雙方的連線建好,offer
與 answer
也儲存妥當。由於remotePeerConnection
在之前已經已經註冊好addStream
的響應方法了gotRemoteMediaStream
,而正如前文說的,因為建立offer
的時候已經把視訊流帶上了,所以gotRemoteMediaStream
此刻會回撥,通過這個方法,把視訊流顯示在remoteVideo
標籤中。
// 回撥儲存遠端媒體流物件並把流傳到 video 標籤
function gotRemoteMediaStream(event) {
const mediaStream = event.stream;
remoteVideo.srcObject = mediaStream;
remoteStream = mediaStream;
trace("遠端節點連結成功,接收遠端媒體流中...");
}
複製程式碼
現在,我們應該可以看到兩個一模一樣的畫面了。注意哦,右邊那個是通過RTC
傳輸過來的。撒花~
這一篇先到這裡吧,我們下一篇繼續。下一篇會繼續繼續深入WebRTC
架構和ICE
,signling
之類的內容。謝謝大家的閱讀,畢竟我也是個初學者,如果文中有不對的地方,大家可以評論一下,然後一起探討。再次謝過。
程式碼和參考文件