背景

我正在实现一个 FC 游戏网站, PC 用户仅需要配置键盘便能实现小伙伴们一起玩, 但是手机用户就比较麻烦了

传统的网页游戏都是通过 HTTP/WS 的方式实现联机, 对于服务器的负担还是比较重的. 实际上需要一起玩的小伙伴一般都在一块, 也没必要使用远端的服务器转发.

任意一个小伙伴的设备起一个服务也是一个好办法, 但是暂时还没考虑做 APP, 想要用户打开就能玩耍, 所以我坚持仅使用浏览器的功能

有小伙伴喜欢用手柄操作, 我考虑使用蓝牙联机, 但是 Web Bluetooth API 主要用于浏览器与蓝牙设备之间的通信(如智能手表、蓝牙耳机等), 而非直接实现浏览器之间的通信

最后我了解到了 WebRTC, 那就是我要的滑板鞋

什么是 WebRTC ?

WebRTC (Web Real-Time Communication), 网页及时交流

WebRTC 是一项开源技术, 旨在通过网页或移动应用程序实现点对点(P2P)的实时音视频、数据传输。WebRTC 允许用户无需通过中介服务器, 直接在浏览器之间进行音视频通信、文件共享、屏幕共享和实时数据传输, 广泛用于视频通话、在线会议、直播等场景。

简而言之, 这是一项网页之间直接通信的技术

WebRTC 的核心功能

WebRTC 提供了以下核心功能:

音频、视频通信:

WebRTC 能够通过点对点连接传输高质量的音频和视频数据, 支持实时视频通话和音频通话。它支持多种音视频编码器, 如 Opus 和 VP8、VP9 等。

数据传输:

除了音视频, WebRTC 还支持任意数据的传输。通过 RTCDataChannel, 可以进行低延迟的任意格式的数据传输, 如文件传输、聊天信息等。

安全性:

WebRTC 使用强大的加密技术, 所有数据传输都通过 SRTP(安全实时传输协议)和 DTLS(数据报传输层安全协议)加密, 确保通信的安全性。

如何使用 WebRTC ?

浏览器主要提供了 3 个 API

getUserMedia

这个 API 允许从用户的摄像头和麦克风中获取音视频流, 并将其捕获在 MediaStream 对象中。该对象可以通过 WebRTC 传输到远程浏览器, 也可以直接在本地页面播放。

navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => {
// 使用本地视频播放流
document.getElementById("localVideo").srcObject = stream;
})
.catch((error) => {
console.error("Error accessing media devices.", error);
});

RTCPeerConnection

RTCPeerConnection 是 WebRTC 的核心, 用于在两端建立音视频通信和数据通道。它支持网络协商(包括 SDP 会话描述协议)和处理网络中的 NAT(网络地址转换)穿透, 使得两个浏览器即使在不同网络下也可以建立直接连接。

const peerConnection = new RTCPeerConnection();

// 添加本地流
stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream)); // 监听远端流
peerConnection.ontrack = (event) => {
const remoteStream = event.streams[0];
document.getElementById("remoteVideo").srcObject = remoteStream;
};

RTCDataChannel

RTCDataChannel 允许两个浏览器之间的任意数据传输, 适合传输文本、文件、游戏状态、实时聊天消息等内容, 支持低延迟和高性能。

const dataChannel = peerConnection.createDataChannel("chat");
dataChannel.onmessage = (event) => {
console.log("Received message:", event.data);
}; dataChannel.send("Hello!");

WebRTC 连接建立流程

  • 创建 RTCPeerConnection

    两个端点(浏览器)各自创建 RTCPeerConnection 实例, 用于管理 P2P 连接。

  • 信令交换(SDP)

    WebRTC 本身不定义信令机制, 需要借助第三方信令服务器(如 WebSocket、HTTP)来交换 SDP(Session Description Protocol)。SDP 描述了端点的音视频格式、网络信息等, 确保两个端点能够互相理解。

    一方创建 offer, 发送给另一方, 另一方回复 answer。

    // 创建 offer 并发送给远端
    peerConnection.createOffer().then((offer) => {
    return peerConnection.setLocalDescription(offer);
    }); // 接收 answer 并设置为远端描述
    peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
  • ICE(Interactive Connectivity Establishment)候选项交换

    使用 ICE 来发现并交换每个浏览器的候选网络路径(如本地 IP、公共 IP 等), 以帮助浏览器之间建立 P2P 连接。ICE 通过 STUN/TURN 服务器帮助穿透 NAT 和防火墙。

  • 建立 P2P 连接并传输数据

    一旦 SDP 和 ICE 协商完成, 浏览器间建立 P2P 连接, 音视频和数据可以开始实时传输。

简单的案例

这里只调用基本的 API, 不做过多的介绍

创建 2 个 html 文件, 1.html2.html, 用浏览器打开, 咱们直接控制台撸代码体验流程

创建 RTCPeerConnection

// 1.html
const p1 = new RTCPeerConnection();
// 2.html
const p2 = new RTCPeerConnection();

信令交换 SDP(Session Description Protocol)

这个过程通常是通过 WS 服务转发, 咱们这里主要体验流程, 所以手动操作

创建 offer 并设置为本地描述

// 1.html
p1.createOffer().then((offer) => {
// 设置为本地描述, 手动复制offer对象
p1.setLocalDescription(offer);
});

接收 offer 并设置为远端描述

// 2.html
// 将刚刚的offer设置为远端描述
p2.setRemoteDescription(offer);

创建 answer 并设置为本地描述

// 2.html
// 创建应答answer, 将answer设置为本地描述, 复制answer
p2.createAnswer().then((answer) => {
p2.setLocalDescription(answer);
});

接收 answer 并设置为远端描述

// 1.html
// 将刚刚的answer设置为远端描述
p1.setRemoteDescription(answer);

ICE(Interactive Connectivity Establishment)候选项交换

监听 icecandidate 事件

监听 icecandidate 事件, 获取 candidate

// 1.html
p1.onicecandidate = (event) => {
if (event.candidate) {
// 复制candidate
}
};

添加到对端

// 2.html
p2.addIceCandidate(candidate);

同理

// 2.html
p2.onicecandidate = (event) => {
if (event.candidate) {
// 复制candidate
}
};
// 1.html
p1.addIceCandidate(event.candidate);

这样, 基本的连接流程就完成了

一个简易聊天室

这个流程手动操作起来也挺麻烦的, 这里简化一下操作

打开a.html会生成带参数的链接打开b.html, 复制b.html生成的信息填入a.html, 这就是交换 SDP 和 ice 的过程

相关代码

a.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="config">
<a id="open" href="" target="_blank">打开新页面</a>
<p>打开页面复制sdp相关信息填入</p> <textarea id="sdp" style="width: 100%; height: 200px"></textarea> <button id="add">add sdp</button>
</div>
<div class="chat" style="display: none">
<div class="chat-box"></div>
<textarea class="chat-input"></textarea>
<button id="send">send</button>
</div>
<script>
const p1 = new RTCPeerConnection(); function send(msg) {
dataChannel.send(msg);
const div = document.createElement("div");
div.innerText = "我: " + msg;
const chat = document.querySelector(".chat-box");
chat.appendChild(div);
}
const dataChannel = p1.createDataChannel("chatChannel"); p1.ondatachannel = (event) => {
console.log(event);
};
dataChannel.onopen = () => {
console.log("DataChannel 已打开,可以发送消息");
const chat = document.querySelector(".chat");
const config = document.querySelector(".config");
chat.style.display = "block";
config.style.display = "none"; const btn = document.querySelector("#send");
btn.addEventListener("click", () => {
const input = document.querySelector(".chat-input");
send(input.value); input.value = "";
});
}; dataChannel.onmessage = (event) => {
console.log("收到消息:", event.data);
const div = document.createElement("div");
div.innerText = "对方: " + event.data;
const chat = document.querySelector(".chat-box");
chat.appendChild(div);
};
p1.createOffer().then((offer) => {
p1.setLocalDescription(offer);
p1.onicecandidate = (event) => {
if (event.candidate) {
console.log(offer);
console.log(event.candidate);
const url = `${location.origin}/b.html?offer=${encodeURIComponent(
JSON.stringify(offer)
)}&candidate=${encodeURIComponent(
JSON.stringify(event.candidate)
)}`; const open = document.querySelector("#open");
open.href = url;
}
};
}); const add = document.querySelector("#add");
add.addEventListener("click", () => {
const { answer, candidate } = JSON.parse(
document.querySelector("#sdp").value
); p1.setRemoteDescription(answer);
p1.addIceCandidate(candidate);
});
</script>
</body>
</html>

b.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="config">
<button id="copy" disabled title="复制SDP ice信息">复制信息</button>
</div> <div class="chat" style="display: none">
<div class="chat-box"></div> <textarea class="chat-input"></textarea> <button id="send">send</button>
</div>
<script>
const query = new URLSearchParams(location.search);
const offer = JSON.parse(decodeURIComponent(query.get("offer")));
const candidate = JSON.parse(decodeURIComponent(query.get("candidate"))); const p2 = new RTCPeerConnection(); p2.setRemoteDescription(offer);
p2.addIceCandidate(candidate); p2.createAnswer().then((answer) => {
p2.setLocalDescription(answer);
p2.onicecandidate = (event) => {
if (event.candidate) {
console.log("生成的 ICE 候选者:", event.candidate);
const json = JSON.stringify({ candidate: event.candidate, answer }); const copy = document.querySelector("#copy"); copy.addEventListener("click", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.value = json;
input.select();
document.execCommand("copy");
document.body.removeChild(input);
});
copy.disabled = false;
}
};
}); let receiveChannel; p2.ondatachannel = (event) => {
receiveChannel = event.channel; receiveChannel.onopen = () => {
const config = document.querySelector(".config");
config.style.display = "none";
const chat = document.querySelector(".chat");
chat.style.display = "block";
console.log("DataChannel 已打开,可以接收消息");
const send = document.querySelector("#send");
send.addEventListener("click", () => {
const input = document.querySelector(".chat-input");
receiveChannel.send(input.value);
const div = document.createElement("div");
div.textContent = "我: " + input.value;
document.querySelector(".chat-box").appendChild(div);
input.value = "";
});
}; receiveChannel.onmessage = (event) => {
const div = document.createElement("div");
div.textContent = "对方: " + event.data;
document.querySelector(".chat-box").appendChild(div);
};
};
</script>
</body>
</html>

体验案例: https://webrtcchat.surge.sh/a.html

简易流媒体通信

既然 RTCPeerConnection 是个对象, 咱们可以一个页面创建两个对象来体验功能, 这样 SDP 和 ice 交换就简单了, 机智如我啊

相关代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<canvas width="500" height="400" id="canvas"></canvas> <video id="video" autoplay muted></video>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d"); let x = 50; // 圆的初始 x 坐标
let y = 50; // 圆的初始 y 坐标
let radius = 30; // 圆的半径
let dx = 2; // 圆在 x 方向上的增量
let dy = 2; // 圆在 y 方向上的增量 // 定义动画的绘制函数
function draw() {
// 清空 canvas,防止绘制的图形叠加
ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制圆
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = "blue";
ctx.fill();
ctx.closePath(); // 更新圆的坐标
x += dx;
y += dy; // 碰撞检测,使圆在边缘反弹
if (x + radius > canvas.width || x - radius < 0) {
dx = -dx; // 水平方向反弹
}
if (y + radius > canvas.height || y - radius < 0) {
dy = -dy; // 垂直方向反弹
} // 请求下一帧动画
requestAnimationFrame(draw);
} // 启动动画
draw(); const p1 = new RTCPeerConnection();
const stream = canvas.captureStream(60);
stream.getTracks().forEach((track) => {
p1.addTrack(track, stream);
console.log(track, stream);
}); const p2 = new RTCPeerConnection();
p2.ontrack = (event) => {
console.log("event", event);
const video = document.querySelector("#video");
video.srcObject = event.streams[0];
video.muted = true;
video.autoplay = true;
}; let receiveChannel; p2.ondatachannel = (event) => {
receiveChannel = event.channel; receiveChannel.onopen = () => {
console.log("DataChannel 已打开,可以接收消息");
}; receiveChannel.onmessage = (event) => {
console.log("收到消息:", event.data);
receiveChannel.send("Hello from Browser B");
};
}; const channel = p1.createDataChannel("channel"); channel.onopen = () => {
console.log("DataChannel 已打开,可以发送消息");
channel.send("Hello from Browser A");
};
channel.onmessage = (event) => {
console.log("收到消息:", event.data);
}; p1.onicecandidate = (event) => {
if (event.candidate) {
p2.addIceCandidate(event.candidate);
}
}; p2.onicecandidate = (event) => {
if (event.candidate) {
p1.addIceCandidate(event.candidate);
}
}; p1.createOffer().then((offer) => {
p1.setLocalDescription(offer);
p2.setRemoteDescription(offer);
p2.createAnswer().then((answer) => {
p2.setLocalDescription(answer);
p1.setRemoteDescription(answer);
console.log(offer, answer);
});
});
</script>
</body>
</html>

体验案例: https://webrtcchat.surge.sh/

参考文献

WebRTC API

实现 WebRTC 群聊会议室

WebRTC 浅谈(一)概述与架构

WebRTC 初探的更多相关文章

  1. 下周二推出“音视频技术WebRTC初探”公开课,欢迎捧场!

     下周二推出"音视频技术WebRTC初探"公开课,欢迎捧场! 公开课课程链接:http://edu.csdn.net/huiyiCourse/detail/90 课程的解说资料 ...

  2. webrtc初探之一对一的连接过程(一)

    说明,我研究的是muan-khan的一个github项目,针对的是chrome对chrome,也就是pc对pc的一对一,一对多通话,感兴趣的可以继续往下看. github地址:https://gith ...

  3. webrtc初探

    0.闲来无事,想研究webrtc,看了一些网上的文章之后,觉得谬误较多,以讹传讹的比较多,自己试验了一把,记录一下. 官网的写的教程在实践中也觉得不用那么复杂,有种落伍与繁冗的感觉. 1.我想看的是w ...

  4. freeswitch编译安装,初探, 以及联合sipgateway, webrtc server的使用场景。

    本文主要记录freeswitch学习过程. 一 安装freeswitch NOTE 以下两种安装方式,再安装的过程中遇到了不少问题,印象比较深刻的就是lua库找到不到这个问题.这个问题发生在make ...

  5. WebRTC手记之初探

    转载请注明出处:http://www.cnblogs.com/fangkm/p/4364553.html WebRTC是HTML5支持的重要特性之一,有了它,不再需要借助音视频相关的客户端,直接通过浏 ...

  6. (一)WebRTC手记之初探

    转自:http://www.cnblogs.com/fangkm/p/4364553.html WebRTC是HTML5支持的重要特性之一,有了它,不再需要借助音视频相关的客户端,直接通过浏览器的We ...

  7. 初探Electron,从入门到实践

    本文由葡萄城技术团队于博客园原创并首发 转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具.解决方案和服务,赋能开发者.   在开始之前,我想您一定会有这样的困惑:标题里的Electron ...

  8. 初探领域驱动设计(2)Repository在DDD中的应用

    概述 上一篇我们算是粗略的介绍了一下DDD,我们提到了实体.值类型和领域服务,也稍微讲到了DDD中的分层结构.但这只能算是一个很简单的介绍,并且我们在上篇的末尾还留下了一些问题,其中大家讨论比较多的, ...

  9. CSharpGL(8)使用3D纹理渲染体数据 (Volume Rendering) 初探

    CSharpGL(8)使用3D纹理渲染体数据 (Volume Rendering) 初探 2016-08-13 由于CSharpGL一直在更新,现在这个教程已经不适用最新的代码了.CSharpGL源码 ...

  10. 从273二手车的M站点初探js模块化编程

    前言 这几天在看273M站点时被他们的页面交互方式所吸引,他们的首页是采用三次加载+分页的方式.也就说分为大分页和小分页两种交互.大分页就是通过分页按钮来操作,小分页是通过下拉(向下滑动)时异步加载数 ...

随机推荐

  1. 吐血整理如何在Google Earth Engine上写循环 五个代码实例详细拆解

    在这里同步一篇本人的原创文章.原文发布于2023年发布在知乎专栏,转移过来时略有修改.全文共计3万余字,希望帮助到GEE小白快速进阶. 引言 这篇文章主要解答GEE中.map()和.iterate() ...

  2. 学习 React 需要具备的 JavaScript 知识

    学习 React 需要具备的 JavaScript 知识 为什么要学习 React? React 可以与任何其他库或框架无缝集成,因为 React 是一个仅视图库(它是 Model View C on ...

  3. 2023/4/18 SCRUM个人博客

    1.我昨天的任务 初步学习dlib的安装,了解dlib的基础组件 2.遇到了什么困难 对pandas库了解不到位,需要学习其中的基础 3.我今天的任务 初步了解了pandas库,对series和dat ...

  4. 假期小结1学习安装VMware以及linux

    学习VMware是一项使我能够创建和管理虚拟机的技能.VMware 是一家知名的虚拟化解决方案供应商,它提供了一系列工具和软件,使我能够在一台物理计算机上创建多个独立的虚拟环境. 首先,我获取了VMw ...

  5. 使用AWS存储数据并下载遥感影像Landsat为例

    使用AWS存储数据并下载遥感影像Landsat为例 一.步骤: 创建s3存储桶(具体创建账号方式请问"度娘",当时忘记录了) 创建用户--配置策略 用该用户创建访问密钥--记录 访 ...

  6. 7、SpringMVC之RESTful概述

    创建名为spring_mvc_rest的新module,过程参考5.2节和6.6节 7.1.简介 RESTful 也称为REST(英文:Representational State Transfer) ...

  7. 【Java】Collection子接口:其二 Set 组接口

    Collection子接口:其二 Set 组接口 - Set接口是Collection的子接口,Set没有提供额外的方法 - Set集合中不允许包含重复的元素,如果重复添加,只保留最新添加的那一个 - ...

  8. 【Uni-App】API笔记 P2

    8.路由,跳转 一.保留当前页面并跳转到指定页面 使用uni.navigateBack可以返回到原页面. uni.navigateTo(OBJECT) OBJECT参数说明 参数 类型 必填 默认值 ...

  9. 【Spring-Security】Re14 Oauth2协议P4 整合SSO单点登陆

    创建一个SSO单点登陆的客户端工程 需要的依赖和之前的项目基本一致: <?xml version="1.0" encoding="UTF-8"?> ...

  10. 【Layui】13 轮播 Carousel

    文档地址: https://www.layui.com/demo/carousel.html 基础轮播: <style> /* 为了区分效果 */ div[carousel-item]&g ...