- 基本按照Real time communication with WebRTC搭建(下面簡稱該網站為官方tutorial)
- 本文重視WebRTC的基於同頁面通信的代碼實現,主要講述順序是WebRTC的三大API順序,一些原理、拓展的部分在鏈接和后續中
基本環境搭建
已有環境
- Mac OS 10 & Windows 10 & Ubuntu 18.04 (均實現,WebRTC支持跨平台)
- Chrome 76 & Firefox
- Webstorm IDE
搭建需要環境
下載源碼
git clone https://github.com/googlecodelabs/webrtc-web
getUserMedia
- 源碼的Step01跑一下,瀏覽器獲取前置攝像頭就能成功,不展示具體效果了,看看源碼和一些其他的應用
源碼分析
- 源碼項目所給的代碼結構,多是如下圖,所以常會看到
js/main.js
css/main.css
這種src
- 分析源碼關鍵調用部分
<!-- core src code of index.html -->
<head>
<title>Realtime communication with WebRTC</title>
<link rel="stylesheet" href="css/main.css" />
</head>
<body>
<h1>Realtime communication with WebRTC</h1>
<!-- add video and script element in this .html file -->
<video autoplay playsinline></video>
<script src="js/main.js"></script>
</body>
/* core src code of main.css */
body {
font-family: sans-serif;
}
video {
max-width: 100%;
width: 800px;
}
html
css
作為標記型語言,了解其基本語法特征與調用(我是通過閱讀DOM Sripting的前三章后比較清楚的,閱讀這部分還有一個好處是,把我不理解的簡潔代碼到頁面奇幻效果的轉化,推鍋給了DOM和瀏覽器廠商~),上面的兩個代碼就不難理解,着重分析下面js
代碼
'use strict';
// On this codelab, you will be streaming only video (video: true).
const mediaStreamConstraints = {
video: true,
};
// Video element where stream will be placed.
const localVideo = document.querySelector('video');
// Local stream that will be reproduced on the video.
let localStream;
// Handles success by adding the MediaStream to the video element.
function gotLocalMediaStream(mediaStream) {
localStream = mediaStream;
localVideo.srcObject = mediaStream;
}
// Handles error by logging a message to the console with the error message.
function handleLocalMediaStreamError(error) {
console.log('navigator.getUserMedia error: ', error);
}
// Initializes media stream.
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
.then(gotLocalMediaStream).catch(handleLocalMediaStreamError);
-
在官方的tutorial中,對於代碼第一行就有解釋ECMAScript 5 Strict Mode, JSON, and More,可以認為是一種語法、異常檢查更嚴格的模式
-
對於第3行之后的代碼部分,功能上可以看作
- 一個constraint只讀變量
gotLocalMediaStream()
處理視頻流函數handleLocalMediaStreamError()
異常處理函數getUserMedia()
調用
-
看MediaDevices.getUserMedia()的API調用規則,也就大體明白了上面接近30行代碼的架構
navigator.mediaDevices.getUserMedia(constraints)
/* produces a MediaStream */
.then(function(stream) {
/* use the stream */
})
.catch(function(err) {
/* handle the error */
});
-
在看第14-18行,how to use the mediaStream?
- 先看
mediaStream
的相關API
The MediaStream interface represents a stream of media content. A stream consists of several tracks such as video or audio tracks. Each track is specified as an instance of MediaStreamTrack
-
看代碼,從整個
main.js
文件中,我沒有看出let localStream
有什么特殊的用途,這一行注釋掉對網頁也沒有什么影響(也許在之后的源碼中有用) -
但17行的代碼就相當關鍵了(可以把這一行的代碼注釋看看是個什么效果~獲取了媒體流,但是網頁上沒有視頻顯示)
-
從第9行的
const localVideo = document.querySelector('video')
說起const
只讀變量- Document.querySelector() 理解這個函數,需要對DOM有一些認識
- DOM(Document Object Model),既然是model就會有一定的邏輯表達形式,DOM文檔的表示就是一棵家譜樹
querySelector(selectors)
也正是基於樹形數據結構,來對document
中的object
進行深度優先的前序遍歷,來獲取document
中符合selectors
的HTMLElement
並返回
The matching is done using depth-first pre-order traversal of the document's nodes starting with the first element in the document's markup and iterating through sequential nodes by order of the number of child nodes.
-
17行的HTMLMediaElement.srcObject 則是對
'video'
流媒體的賦值,使頁面顯示video
- 先看
getUserMedia()++
- 在step01的demo里,前置攝像頭的調用非常成功,但要刨根問底,step01中的代碼並沒有說明要調什么攝像頭,什么類型的視頻流(constraints里面只要求
video: true
) - 在官方tutorial里面有Bonus points,回答理解這些問題來加深對
getUserMedia()
的理解 - 由於不想把這篇博文寫的太長,上面兩個問題,都會在基於瀏覽器的WebRTC的getUserMedia()相關補充中補充說明
RTCPeerConnection
- Let's move on to Step-02
源碼分析
HTML
<body>
<h1>Realtime communication with WebRTC</h1>
<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
<div>
<button id="startButton">Start</button>
<button id="callButton">Call</button>
<button id="hangupButton">Hang Up</button>
</div>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script src="js/main.js"></script>
</body>
- 在HTML文檔中
<head>
以及調用main.css
的部分和Step-01相比幾乎沒有改變 - 新加入的
video
button
script
其id
&src
命名都有很好的解釋說明效果,在下文對main.js
的分析中,相關內容會有更清楚的解釋 - 代碼接近300行的樣子,按頁面的操作順序,分析一下相關代碼
三個button
- 從三個
button
開始,這是代碼183-192行
// Define and add behavior to buttons.
// Define action buttons.
const startButton = document.getElementById('startButton');
const callButton = document.getElementById('callButton');
const hangupButton = document.getElementById('hangupButton');
// Set up initial action buttons status: disable call and hangup.
callButton.disabled = true;
hangupButton.disabled = true;
- 上面的代碼和
querySelector()
有類似功能,比較清晰 - 代碼259-262行
// Add click event handlers for buttons.
startButton.addEventListener('click', startAction);
callButton.addEventListener('click', callAction);
hangupButton.addEventListener('click', hangupAction);
- EventTarget.addEventListener()熟悉API之后,就來看看
startAction()
callAction()
hangupAction()
這三個函數
startAction()
- 從
startAction
開始
// Handles start button action: creates local MediaStream.
function startAction() {
startButton.disabled = true;
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
.then(gotLocalMediaStream).catch(handleLocalMediaStreamError);
trace('Requesting local stream.');
}
- 一經頁面開啟
startButton
只能click一次,之后獲取getUserMedia()
mediaStreamConstraints()
函數幾乎沒有變化,gotLocalMediaStream()
&handleLocalMediaStreamError()
有些許變化,在19-43部分行
// Define peer connections, streams and video elements.
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
let localStream;
let remoteStream;
// Define MediaStreams callbacks.
// Sets the MediaStream as the video element src.
function gotLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
localStream = mediaStream;
trace('Received local stream.');
callButton.disabled = false; // Enable call button.
}
// Handles error by logging a message to the console.
function handleLocalMediaStreamError(error) {
trace(`navigator.getUserMedia error: ${error.toString()}.`);
}
- 代碼的整體邏輯非常清晰,唯獨新加入的
trace()
函數比較新穎,看看
// Logs an action (text) and the time when it happened on the console.
function trace(text) {
text = text.trim();
const now = (window.performance.now() / 1000).toFixed(3);
console.log(now, text);
}
- 幾個API調用String.prototype.trim() console.log() 可以在各種瀏覽器的控制台查看(Chrome的是開發者工具的console),以及performance.now(),總體來說,這是一個控制台信息輸出函數
callAction()
- 再看
callAction()
,代碼203-246行
// Code from line 16-17
// Define initial start time of the call (defined as connection between peers).
let startTime = null;
// Code from line19-27
// Define peer connections
let localPeerConnection;
let remotePeerConnection;
// Handles call button action: creates peer connection.
function callAction() {
callButton.disabled = true; // disenable call button
hangupButton.disabled = false; // enable hangup button
trace('Starting call.');
startTime = window.performance.now(); // assign startTime with concrete time
// Get local media stream tracks.
const videoTracks = localStream.getVideoTracks();
const audioTracks = localStream.getAudioTracks();
if (videoTracks.length > 0) {
trace(`Using video device: ${videoTracks[0].label}.`);
}
if (audioTracks.length > 0) {
trace(`Using audio device: ${audioTracks[0].label}.`);
}
const servers = null; // Allows for RTC server configuration.
// Create peer connections and add behavior.
localPeerConnection = new RTCPeerConnection(servers);
trace('Created local peer connection object localPeerConnection.');
localPeerConnection.addEventListener('icecandidate', handleConnection);
localPeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection = new RTCPeerConnection(servers);
trace('Created remote peer connection object remotePeerConnection.');
remotePeerConnection.addEventListener('icecandidate', handleConnection);
remotePeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream);
// Add local stream to connection and create offer to connect.
localPeerConnection.addStream(localStream);
trace('Added local stream to localPeerConnection.');
trace('localPeerConnection createOffer start.');
localPeerConnection.createOffer(offerOptions)
.then(createdOffer).catch(setSessionDescriptionError);
}
- 按上文代碼的函數,從第28行開始,就是極其關鍵的
RTCPeerConnection
,解析下面所說的三個步驟,以建立連接時序展開
Setting up a call between WebRTC peers involves three tasks:
- Create a RTCPeerConnection for each end of the call and, at each end, add the local stream from getUserMedia().
- Get and share network information: potential connection endpoints are known as ICE candidates.
- Get and share local and remote descriptions: metadata about local media in SDP format.
RTCPeerConnection關鍵部分——Local & Remote peer建立
getUserMedia()
部分,不再贅述
let localPeerConnection;
const servers = null; // Allows for RTC server configuration. This is where you could specify STUN and TURN servers.
// Create peer connections and add behavior.
localPeerConnection = new RTCPeerConnection(servers);
remotePeerConnection = new RTCPeerConnection(servers);
- 關於servers,官網tutorial給了一篇說明WebRTC in the real world: STUN, TURN and signaling,這個我也會在隨着項目系統通信搭建的深入,學習實踐到servers層面再記錄
- 可以認為在上述代碼之后,一個RTCPeerConnection的端就實例化成功了
// Add local stream to connection and create offer to connect.
localPeerConnection.addStream(localStream);
trace('Added local stream to localPeerConnection.');
- 在
addStream()
之后,可以認為Local & Remote Peer已經全部建好(RTCPeerConnection實例化成功,media傳輸也可以開始進行)
RTCPeerConnection關鍵部分——ICE candidate建立
localPeerConnection.addEventListener('icecandidate', handleConnection);
localPeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
addEventListener()
method在button相關中已經了解,關於'icecandidate'
Event,看RTCPeerConnection: icecandidate event,而其中的setLocalDescription()
在下面一個section中有介紹- 這一部分,需要對計算機網絡有一些了解,以及對WebRTC signaling的過程爛熟於心,我初學是非常費解的,探索后其中內容解釋在WebRTC的RTCPeerConnection()原理探析(鏈接中文章重原理,這篇重視基礎的代碼實現)
- 同樣Remote Peer的建立也是類似
remotePeerConnection.addEventListener('icecandidate', handleConnection);
remotePeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream);
- 繼續看ICE candidate建立過程中用到的三個函數
// Connects with new peer candidate.
function handleConnection(event) {
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCandidate = new RTCIceCandidate(iceCandidate);
const otherPeer = getOtherPeer(peerConnection);
otherPeer.addIceCandidate(newIceCandidate)
.then(() => {
handleConnectionSuccess(peerConnection);
}).catch((error) => {
handleConnectionFailure(peerConnection, error);
});
trace(`${getPeerName(peerConnection)} ICE candidate:\n` +
`${event.candidate.candidate}.`);
}
}
// Logs changes to the connection state.
function handleConnectionChange(event) {
const peerConnection = event.target;
console.log('ICE state change event: ', event);
trace(`${getPeerName(peerConnection)} ICE state: ` +
`${peerConnection.iceConnectionState}.`);
}
// Handles remote MediaStream success by adding it as the remoteVideo src.
function gotRemoteMediaStream(event) {
const mediaStream = event.stream;
remoteVideo.srcObject = mediaStream;
remoteStream = mediaStream;
trace('Remote peer connection received remote stream.');
}
- 我猜測前兩個函數,是針對於本機連本機的特殊應用搭建的,不具有普遍性,所以不具體分析
gotRemoteMediaStream()
函數,最終將Local Peer的addStream()
顯示- 還有一個API值得看一下,就是RTCPeerConnection.addIceCandidate()
RTCPeerConnection關鍵部分——Get and share local and remote descriptions
- 開啟一個SDP offer,以進行遠程連接
trace('localPeerConnection createOffer start.');
localPeerConnection.createOffer(offerOptions)
.then(createdOffer).catch(setSessionDescriptionError);
- RTCPeerConnection.createOffer()的API,看完就在源碼里面找到了
offerOptions
createdOffer()
setSessionDescriptionError()
這三個對應內容
// Set up to exchange only video.
const offerOptions = {
offerToReceiveVideo: 1,
};
// Logs offer creation and sets peer connection session descriptions.
function createdOffer(description) {
trace(`Offer from localPeerConnection:\n${description.sdp}`);
trace('localPeerConnection setLocalDescription start.');
localPeerConnection.setLocalDescription(description)
.then(() => { // The parameter list for a function with no parameters should be written with a pair of parentheses.
setLocalDescriptionSuccess(localPeerConnection);
// just logs successful info on the console
}).catch(setSessionDescriptionError);
}
// Logs error when setting session description fails.
function setSessionDescriptionError(error) {
trace(`Failed to create session description: ${error.toString()}.`);
}
- 解釋一下
createOffer()
函數 - 先看
setLocalDescription
,API中也沒有講的特別清楚,簡單的說,可以認為這個函數經過調用后,Local Peer的offer就發送成功(可參見RTCPeerConnection.signalingState),但實際上發送的信息是什么、向誰發...等一系列問題,都是在官方教程中的源碼里面未涉及的,這部分我寫在了WebRTC的RTCPeerConnection()原理探析中 - Local Peer已經提供了offer,來而不往非禮也,下面就是Remote Peer的回應了
trace('remotePeerConnection setRemoteDescription start.');
remotePeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
trace('remotePeerConnection createAnswer start.');
remotePeerConnection.createAnswer()
.then(createdAnswer)
.catch(setSessionDescriptionError);
function createdAnswer(description) {
trace(`Answer from remotePeerConnection:\n${description.sdp}.`);
trace('remotePeerConnection setLocalDescription start.');
remotePeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
trace('localPeerConnection setRemoteDescription start.');
localPeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
}
- Remote Peer的createAnswer()的API以及
setRemoteDescription
的API,Local Peer與Remote Peer之間的互相通信基本建立了 - 在Google Dev Tools Console里面截取了一張SDP的圖片,感覺比較復雜,之前有做WebRTC底層優化的准備,現在覺得...可能在十分十分需要的時候才會去做QAQ
hangupAction()
- 最后來看
hangup
button對應什么函數
// Handles hangup action: ends up call, closes connections and resets peers.
function hangupAction() {
localPeerConnection.close();
remotePeerConnection.close();
localPeerConnection = null;
remotePeerConnection = null;
hangupButton.disabled = true;
callButton.disabled = false;
trace('Ending call.');
}
- 非常清晰易懂,不解釋
如何PC 2 PC
- 源碼分析終於分析完了~
- 但還有一些問題,源碼中的網頁本地P2P通信如何改為PC 2 PC的通信?這個我記錄在原理一文中
RTCDataChannel
- RTCPeerConnection部分需要寫的實在太多了,到這里,全文長度已經超過3000.orz...這部分腳步代碼量略少一些,也盡量寫的簡潔一點,其余拓展見補充
- 還是從源碼分析開始
源碼分析
HTML
- HTML代碼部分較RTCPeerConnection部分,增加了兩個文本區
<textarea id="dataChannelSend" disabled
placeholder="Press Start, enter some text, then press Send."></textarea>
<textarea id="dataChannelReceive" disabled></textarea>
- 標記性語言,語法、效果非常容易理解,可見
- 在HTML語言中,我們也看到了和上一節類似的三個button,還是按button順序來分析
三個button
- 這次三個button的寫法較上一節的有比較新奇的改變
var startButton = document.querySelector('button#startButton');
var sendButton = document.querySelector('button#sendButton');
var closeButton = document.querySelector('button#closeButton');
startButton.onclick = createConnection;
sendButton.onclick = sendData;
closeButton.onclick = closeDataChannels;
startButton
var localConnection;
var remoteConnection;
var sendChannel;
var dataConstraint;
var dataChannelSend = document.querySelector('textarea#dataChannelSend');
// Offerer side
function createConnection() {
dataChannelSend.placeholder = '';
var servers = null;
pcConstraint = null;
dataConstraint = null;
trace('Using SCTP based data channels');
// For SCTP, reliable and ordered delivery is true by default.
// Add localConnection to global scope to make it visible
// from the browser console.
window.localConnection = localConnection =
new RTCPeerConnection(servers, pcConstraint); // constructor
trace('Created local peer connection object localConnection');
sendChannel = localConnection.createDataChannel('sendDataChannel',
dataConstraint);
trace('Created send data channel');
localConnection.onicecandidate = iceCallback1;
sendChannel.onopen = onSendChannelStateChange;
sendChannel.onclose = onSendChannelStateChange;
// Add remoteConnection to global scope to make it visible
// from the browser console.
window.remoteConnection = remoteConnection =
new RTCPeerConnection(servers, pcConstraint);
trace('Created remote peer connection object remoteConnection');
remoteConnection.onicecandidate = iceCallback2;
remoteConnection.ondatachannel = receiveChannelCallback;
localConnection.createOffer().then(
gotDescription1,
onCreateSessionDescriptionError
);
startButton.disabled = true;
closeButton.disabled = false;
}
function iceCallback1(event) {
trace('local ice callback');
if (event.candidate) {
remoteConnection.addIceCandidate(
event.candidate
).then(
onAddIceCandidateSuccess,
onAddIceCandidateError
);
trace('Local ICE candidate: \n' + event.candidate.candidate);
}
}
function iceCallback2(event) {
trace('remote ice callback');
if (event.candidate) {
localConnection.addIceCandidate(
event.candidate
).then(
// print out info on the console
onAddIceCandidateSuccess,
onAddIceCandidateError
);
trace('Remote ICE candidate: \n ' + event.candidate.candidate);
}
}
function onSendChannelStateChange() {
var readyState = sendChannel.readyState;
trace('Send channel state is: ' + readyState);
if (readyState === 'open') {
dataChannelSend.disabled = false;
dataChannelSend.focus();
sendButton.disabled = false;
closeButton.disabled = false;
} else {
dataChannelSend.disabled = true;
sendButton.disabled = true;
closeButton.disabled = true;
}
}
The createDataChannel() method on the RTCPeerConnection interface creates a new channel linked with the remote peer, over which any kind of data may be transmitted. This can be useful for back-channel content such as images, file transfer, text chat, game update packets, and so forth.
This happens whenever the local ICE agent needs to deliver a message to the other peer through the signaling server. This lets the ICE agent perform negotiation with the remote peer without the browser itself needing to know any specifics about the technology being used for signaling; simply implement this method to use whatever messaging technology you choose to send the ICE candidate to the remote peer.
The read-only property candidate on the RTCIceCandidate interface returns a DOMString describing the candidate in detail. Most of the other properties of RTCIceCandidate are actually extracted from this string.
When a web site or app using RTCPeerConnection receives a new ICE candidate from the remote peer over its signaling channel, it delivers the newly-received candidate to the browser's ICE agent by calling RTCPeerConnection.addIceCandidate(). This adds this new remote candidate to the RTCPeerConnection's remote description, which describes the state of the remote end of the connection.
The RTCDataChannel.onopen property is an EventHandler which specifies a function to be called when the open event is fired; this is a simple Event which is sent when the data channel's underlying data transport—the link over which the RTCDataChannel's messages flow—is established or re-established.
- HTMLElement.focus(),這個功能比我想的有趣,而且我們在平時使用瀏覽器的時候,遇見的特別多~但是,有一點問題,就是基於DOM的Web前端開發太“高級”了,以至於我看不到底層的實現...
The HTMLElement.focus() method sets focus on the specified element, if it can be focused. The focused element is the element which will receive keyboard and similar events by default.
- 之后的部分就是和RTCPeerConnection相類似的
createOffer()
createAnswer()
部分,在學習完RTCPeerConnection之后,是非常容易的,所以不贅述
sendButton
function sendData() {
var data = dataChannelSend.value;
sendChannel.send(data);
trace('Sent Data: ' + data);
}
- 基於上一步建立的連接上,實現data傳輸功能
closeButton
function closeDataChannels() {
trace('Closing data channels');
sendChannel.close();
trace('Closed data channel with label: ' + sendChannel.label);
receiveChannel.close();
trace('Closed data channel with label: ' + receiveChannel.label);
localConnection.close();
remoteConnection.close();
localConnection = null;
remoteConnection = null;
trace('Closed peer connections');
startButton.disabled = false;
sendButton.disabled = true;
closeButton.disabled = true;
dataChannelSend.value = '';
dataChannelReceive.value = '';
dataChannelSend.disabled = true;
disableSendButton();
enableStartButton();
}
function enableStartButton() {
startButton.disabled = false;
}
function disableSendButton() {
sendButton.disabled = true;
}
- 這部分基本上也是明晰的,不贅述
And then...
- 關於WebRTC官網代碼三大模塊同一網頁實現的分析,就寫到這個地方,也寫了很多很多了,換一個文本繼續寫跨PC的WebRTC實現,請見PC 2 PC的WebRTC實現