| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> JavaScript知识库 -> 【万字长文】WebRTC浅析与实战 -> 正文阅读 |
|
[JavaScript知识库]【万字长文】WebRTC浅析与实战 |
前言目前市场上音视频技术方案大致分为以下几类,WebRTC因其超低延时、集成音视频采集传输等优点,是在线教育、远程会议等领域首选技术。
WebRTC是 Google 在 2010 年收购 VoIP 软件开发商 GlobalIPSolutions 的 GIPS 引擎后,基于 GIPS 引擎实现的浏览器音视频和数据通信技术,在 2012 年集成到 chrome 浏览器,到目前为止,大部分主流现代浏览器都已经支持。 WebRTC架构一个简单的音视频架构大致如下:
如果我们按照上面的架构实现一个音视频通信系统,相当于至少需要开发7个小模块,想想就费时费力。此时WebRTC就可以闪亮登场了,它内部标准化的实现上述架构,并在此基础上进行拓展,对外只暴露了相关的API,其架构图如下( 官网 的有点旧,重新画的): WebRTC大体可以分为四层:接口层、Session层、引擎层、设备层:
WebRTC音视频通信过程一个正常音视频通信架构如上图所示,通信双方分别是 caller(主叫) 与 callee(被叫),两边的内部逻辑相似,下面以caller端为例,了解内部流程:
下面就是该过程对应的泳道图: 信令服务器信令是实现音视频通信的重要一环,比如创建房间、离开房间、交换双端offer/answer以及candidate信息等。但WebRTC规范文档中并未定义信令相关的内容,因为不同业务,逻辑不同,信令也会千差万别,所以需要各个业务自己实现一套信令服务。
常用API
获取设备
|
属性名 | 描述 |
---|---|
canTrickleIceCandidates | 如果远端支持UDP打洞或支持通过中继服务器连接,则该属性值为true。否则,为false。该属性的值依赖于远端设置且仅在本地的 RTCPeerConnection.setRemoteDescription() 方法被调用时有效,如果该方法没被调用,则其值为null. |
connectionState | 返回由枚举RTCPeerConnectionState指定的字符串值之一来指示对等连接的当前状态。 |
currentLocalDescription | 返回一个描述连接本地端的RTCSessionDescription对象。 |
currentRemoteDescription | 返回一个描述连接远程端的RTCSessionDescription对象。 |
iceConnectionState | 返回与RTCPeerConnection关联的ICE代理的状态类型为RTCIceConnectionState的枚举。 |
iceGatheringState | 返回一个RTCIceGatheringState类型的结构体,它描述了连接的ICE收集状态 |
localDescription | 返回一个 RTCSessionDescription ,它描述了这条连接的本地端的会话控制(用户会话所需的属性以及配置信息)。如果本地的会话控制还没有被设置,它的值就会是null。 |
peerIdentity | 返回一个RTCIdentityAssertion,它由一组信息构成,包括一个域名(idp)以及一个名称(name),它们代表了这条连接的远端机器的身份识别信息。如果远端机器还没有被设置以及校验,这个属性会返回一个null值。一旦被设置,它不能被一般方法改变。 |
remoteDescription | 返回一个 RTCSessionDescription ,它描述了和远程对端之间的会话(包括配置和媒体信息) ,如果还没有被设置过的话,它会是 null. |
signalingState | 返回一个RTC通信状态的结构体,这个结构体描述了本地连接的通信状态。这个 状态描述了一个定义连接配置的SDPoffer。它包含了下列信息,与 MediaStream 类型本地相关的对象的描述,媒体流编码方式或RTP和 RTCP协议的选项 ,以及被ICE服务器收集到的candidates(连接候选者)。当 RTCPeerConnection.signalingState 的值改变时,对象上的 signalingstatechange 事件会被触发。 |
方法名 | 描述 |
---|---|
createOffer | 生成一个offer,它是一个带有特定的配置信息寻找远端匹配机器(peer)的请求。这个方法的前两个参数分别是方法调用成功以及失败的回调函数,可选的第三个参数是用户对视频流以及音频流的定制选项(一个对象)。 |
createAnswer | 在协调一条连接中的两端offer/answers时,根据从远端发来的offer生成一个answer。这个方法的前两个参数分别是方法调用成功以及失败时的回调函数,可选的第三个参数是生成的answer的可供选项。 |
setLocalDescription | 改变与连接相关的本地描述。这个描述定义了连接的属性,例如:连接的编码方式。连接会受到它的改变的影响,而且连接必须能同时支持新的以及旧的描述。这个方法可以接收三个参数,一个 RTCSessionDescription 对象包含设置信息,还有两个回调函数,它们分别是方法调用成功以及失败的回调函数。 |
setRemoteDescription | 改变与连接相关的远端描述。这个描述定义了连接的属性,例如:连接的编码方式。连接会受到它的改变的影响,而且连接必须能同时支持新的以及旧的描述。这个方法可以接收三个参数,一个 RTCSessionDescription 对象包含设置信息,还有两个回调函数,它们分别是方法调用成功以及失败的回调函数。 |
addIceCandidate | 添加iceCandidate时调用的方法 |
getConfiguration | 获取配置信息时调用的方法 |
getLocalStreams | 返回连接的本地媒体流数组。这个数组可能是空数组 |
getRemoteStreams | 返回连接的远端媒体流数组。这个数组可能是空数组 |
getStreamById | 返回连接中与所给id匹配的媒体流 MediaStream ,如果没有匹配项,返回null |
addStream | 添加一个媒体流 MediaStream 作为本地音频或视频源。如果本地端与远端协调已经发生了,那么需要一个新的媒体流,这样远端才可以使用它 |
removeStream | 将一个作为本地音频或视频源的媒体流 MediaStream 移除。如果本地端与远端协调已经发生了,那么需要一个新的媒体流,这样远端才可以停止使用它 |
addTrack | 将一个新的媒体轨道添加到一组轨道中,这些轨道将被传输给另一个对等点。 |
removeTrack | 移除轨道中的某个轨道,停止发送到对等点。 |
close | 关闭一个RTCPeerConnection实例所调用的方法 |
createDataChannel | 在一条连接上建立一个新的 RTCDataChannel (用于数据发送)。这个方法把一个数据对象作为参数,数据对象中包含必要的配置信息 |
getStats | 生成一个新的 RTCStatsReport ,它包含连接相关的统计信息 |
setIdentityProvider | 根据所给的三个参数设置身份提供者(IdP),这三个参数是它的名称,通信所使用的协议(可选),以及一个可选的用户名。只有当一个断言被需要时,这个IdP才会被使用。 |
getIdentityAssertion | 初始化身份断言的收集,只有当 signalingState 的值不为"closed"时,它才有效。它自动完成,在需求发生前调用它是最好的选择。 |
事件名 | 描述 |
---|---|
onaddstream | 当 MediaStream 被远端机器添加到这条连接时,该事件会被触发。 当调用 RTCPeerConnection.setRemoteDescription() 方法时,这个事件就会被立即触发,它不会等待SDP协商的结果。 |
ondatachannel | 当一个 RTCDataChannel 被添加到连接时,这个事件被触发。 |
onicecandidate | 当一个 RTCICECandidate 对象被添加时,这个事件被触发。 |
oniceconnectionstatechange | 当 iceConnectionState 改变时,这个事件被触发。 |
onnegotiationneeded | 浏览器发送该事件以告知在将来某一时刻需要协商。 |
onremovestream | 当一条 MediaStream 从连接上移除时,该事件被触发。 |
onsignalingstatechange | 当 signalingState 的值发生改变时,该事件被触发。 |
ontrack | 当新轨道加入时,该事件被触发。 |
下面以绑定本地音视频数据为例,说明api的使用方法。
目前 RTCPeerConnection 提供了两种方法用来绑定音视频数据:addTrack() 和 addSteam() ,其中 addStream 已经被官方标记为废弃,推荐使用 addTrack() 方法,这两个方法可以转换:
peerConnection.addStream(mediaStream);
// 等价于
mediaStream.getTracks().forEach(track => {
peerConnection.addTrack(track, mediaStream);
});
下面以 addTrack 为例:
let localMediaStream = null;
const setLocalMediaStream = mediaStream => {
localMediaStream = mediaStream;
};
navigator.mediaDevices
.getUserMedia({
video: true,
audio: false,
})
.then(setLocalMediaStream);
const bindTracks = () => {
localMediaStream
.getTracks()
.forEach(track => {
peerConnection.addTrack(track, localMediaStream);
})
};
媒体协商就是在双端通信之前,了解双方具备哪些能力。其协商过程中交换的内容就是SDP协议定义的。
SDP(SessionDescription Protocol)是一个2006年发布的老协议,以 <type>=<value>
的格式描述会话内容,其中 <type>
表示描述的目标,由单个字符构成; <value>
是对 <type>
的描述和约束,包括音视频编解码器类型、传输协议等,详情可以查看 RFC4566 。WebRTC引入SDP来描述媒体信息,用于媒体协商,决定双端使用何种方式通信。
SDP协议的具体格式如下,分为两部分:会话描述和媒体描述。其中带星号(*)的表示可选。
Session description
v= (protocol version)
o= (originator and session identifier)
s= (session name)
i=* (session information)
u=* (URI of description)
e=* (email address)
p=* (phone number)
c=* (connection information -- not required if included in all media)
b=* (zero or more bandwidth information lines)
[...One or more time descriptions ("t=" and "r=" lines)]
z=* (time zone adjustments)
k=* (encryption key)
a=* (zero or more session attribute lines)
[...Zero or more media descriptions]
Time description
t= (time the session is active)
r=* (zero or more repeat times)
Media description, if present
m= (media name and transport address)
i=* (media title)
c=* (connection information -- optional if included at session level)
b=* (zero or more bandwidth information lines)
k=* (encryption key)
a=* (zero or more media attribute lines)
举个🌰:
v=0
o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
s=SDP Seminar
i=A Seminar on the session description protocol
u=[http://www.example.com/seminars/sdp.pdf](http://www.example.com/seminars/sdp.pdf)
[e](mailto:e=j.doe@example.com)[=j.doe@example.com](mailto:e=j.doe@example.com) (Jane Doe)
c=IN IP4 224.2.17.12/127
t=2873397496 2873404696
a=recvonly
m=audio 49170 RTP/AVP 0
m=video 51372 RTP/AVP 99
a=rtpmap:99 h263-1998/90000
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer);
const signalServer = new WebSocket('ws://[xxx.signal.com](http://xxx.signal.com/)');
signalServer.send({
type: 'offer',
data: offer,
});
await peerConnection.setRemoteDescription(offer);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
const signalServer = new WebSocket('ws://[xxx.signal.com](http://xxx.signal.com/)');
signalServer.send({
type: 'answer',
data: answer,
});
peerConnection.setRemoteDescription(answer);
当各端调用 setLocalDescription 后,WebRTC就开始建立网络连接,主要包括收集candidate、交换candidate和按优先级尝试连接,该过程被称为ICE(Interactive Connectivity Establishment,交互式连接建立)。其中每个 candidate 都包含IP地址、端口、传输协议、类型等信息。
根据 RFC5245 协议,WebRTC将 candidate分为了四个类型:host、srflx、prflx、relay,它们的优先级依次降低。
host:Host Candidate,根据主机的网卡数量决定,一般一个网卡对应一个ip地址,然后给每个ip随机分配一个端口生成
srflx:Server Reflexive Candidate,根据STUN服务器获得的ip和端口生成
prflx:Peer Reflexive Candidate,根据对端的ip和端口生成
relay:Relayed Candidate,根据TURN服务器获得的ip和端口生成
NAT在真实网络环境中随处可见,主要由两个用处:
解决IPv4地址不够用的问题,可以让多台主机共用一个公网IP
安全问题,将主机隐藏在内网中,外网就比较难访问到真实主机
根据 RFC3489 协议,NAT总共分成4种类型:完全锥型(Full ConeNAT)、IP限制锥型(Address Restricted ConeNAT)、端口限制锥型(Port Restricted ConeNAT)、对称型(SymmetrictNAT),依次检测越来越严格。
💡所谓“打洞”,其实就是在 NAT 建立一个内外网的映射表。包括内网IP和端口,以及映射的外网IP和端口。
NAT打洞成功后,所有知道该洞的主机都可以通过它与内网主机进行通信。映射表内容如下:
{
内网ip,
内网端口,
映射的外网ip,
映射的外网端口
}
举个栗子:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000 ,192.168.0.8可以收到任意外部主机发到1.2.3.4:62000的数据报。
NAT打洞成功后,只有打洞成功的外网主机才能通过该洞与内网主机通信,其他外网主机即使知道洞口也不能内网主机通信。映射表内容如下:
{
内网ip,
内网端口,
映射的外网ip,
映射的外网端口,
[被访问主机的ip,....]
}
举个栗子:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先给服务器C 6.7.8.9发送一个数据报后,192.168.0.8才能收到6.7.8.9发送到1.2.3.4:62000的数据报。
除了像IP限制锥型一样对IP进行检测以外,还需要检测端口。映射表内容如下:
{
内网ip,
内网端口,
映射的外网ip,
映射的外网端口,
[
{被访问主机的ip,被访问主机的端口},
...
]
}
举个栗子:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先向外部主机地址端口6.7.8.9:8000发送一个数据报后,192.168.0.8才能收到6.7.8.9:8000发送到1.2.3.4:62000的数据报。
内网主机每次访问不同的外网时,都需要打一个新洞,而不像前面三种NAT类型使用的是同一个“洞”,即只有收到过一个数据包的外部主机才能够向该内部主机发送数据包,映射表内容如下:
{
内网ip,
内网端口,
// 不仅访问地址变化,映射ip也要发生变化
映射的外网ip,
// 不仅访问端口变化,映射端口也要发生变化
映射的外网端口,
被访问主机的ip,
被访问主机的端口
}
💡下述算法在 RFC 3489 被提出,但在 RFC 5389 中被删除。因为随着发展,NAT类型比协议中描述的更多种多样,检测过程变得比较脆弱。更详细的原因可以到 RFC 5389 的Page 45中‘ 19.Changes since RFC 3489 ’查看。
下面( 原图 )就是内网主机进行NAT类型检测的算法流程,总共需要2台STUN服务器,每台STUN服务器又需要两块网卡,每块网卡都需要配置公网ip地址。
如果双端都进入红色部分,则表示无法通信,进入黄色或者绿色就有打洞通信的可能性。
客户端建立UDPsocket,然后用这个socket向服务器 Server#1 的(IP-1,Port-1)发送数据包,要求服务器从(IP-1,Port-1)返回客户端的IP和Port,客户端发送请求后立即开始接收数据包。
如果超时收不到服务器的响应,则说明客户端无法进行UDP通信,表明:防火墙阻止UDP通信;
如果能收到回应,则比较服务器返回的客户端(ip:port)与本地的(ip:port)是否一致;
如果完全相同则表明:客户端具有公网IP,然后进行防火墙检测;
如果不同,则表明:客户端在NAT后,要做进一步的NAT类型检测(继续)。
客户端向服务器 Server#1 的(IP-1,PORT-1)发送请求,要求服务器从(IP-2,PORT-2)向客户端发送数据包:
如果客户端能够收到数据包,则认为客户端处在一个开放的网络上,网络类型为公开的互联网IP
否则客户端被前置防火墙拦截,判断为对称型网络;
客户端向服务器的(IP-1,Port-1)发送数据包,并要求服务器从(IP-2,Port-2)向客户端发回一个响应数据包,客户端发送请求后立即开始接受数据包。
如果能够接受到服务器从(IP-2,Port-2)返回的应答UDP包,则说明客户端是一个完全锥型网络。
否则进行下一步检测(继续);
客户端向另一台STUN服务器 Server#2 的 (IP-3,Port-3)发送请求,要求服务器从(IP-3,Port-3)返回客户端的ip和端口。
如果服务端返回的客户端ip与本地ip不一致,则表明是对称型网络;
否则,表明是限制型网络,进行下一步检测(继续);
客户端向另一台STUN服务器 Server#2 的 (IP-3,Port-3)发送请求,要求服务器从(IP-3,Port-4)返回客户端的ip和端口。
如果收到数据,则表明是:IP限制锥形网络;
否则表明是:端口限制锥形网络。
接下来开发一个本地1v1通信的简单demo以及附加的拍照功能.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebRTC Demo</title>
<style>
video {
width: 400px;
}
</style>
</head>
<body>
<video id="localVideo" autoplay></video>
<video id="remoteVideo" autoplay></video>
<div>
<button id="startBtn">打开摄像头</button>
<button id="callBtn">建立远程连接</button>
<button id="hangupBtn">断开远程连接</button>
<button id="photoBtn" disabled>拍照</button>
</div>
<canvas id="photoContainer"></canvas>
</body>
<script>
const startBtn = document.getElementById("startBtn");
const callBtn = document.getElementById("callBtn");
const hangupBtn = document.getElementById("hangupBtn");
const photoBtn = document.getElementById("photoBtn");
const photoContainer = document.getElementById("photoContainer");
const photoCtx = photoContainer.getContext("2d");
startBtn.addEventListener("click", startHandle);
callBtn.addEventListener("click", callHandle);
hangupBtn.addEventListener("click", hangupHandle);
photoBtn.addEventListener("click", photoHandle);
// 本地流
let localStream;
// 远端流
let remoteStream;
// 本地连接对象
let localPeerConnection;
// 远端连接对象
let remotePeerConnection;
// 本地视频
const localVideo = document.getElementById("localVideo");
// 远端视频
const remoteVideo = document.getElementById("remoteVideo");
// 设置约束
const mediaStreamConstraints = {
video: true,
};
// 仅交换视频
const offerOptions = {
offerToReceiveVideo: 1,
};
function startHandle() {
console.log("开启本地摄像头");
startBtn.disabled = true;
navigator.mediaDevices
.getUserMedia(mediaStreamConstraints)
.then(setLocalMediaStream)
.catch((err) => {
console.error("getUserMedia", err);
});
}
async function callHandle() {
console.log("建立远端连接");
callBtn.disabled = true;
hangupBtn.disabled = false;
photoBtn.disabled = false;
// 本地直连,没有STUN服务器
const rtcConfig = null;
// 1. 创建 RTCPeerConnection
createLocalPeerConnection(rtcConfig);
createRemotePeerConnection(rtcConfig);
// 2.添加本地音视频流
addLocalStream();
/** 媒体协商 */
// 2.创建SDP offer
const offer = await createOffer(offerOptions);
// 3.设置本地SDP offer
setLocalDescription(localPeerConnection, offer);
// 4.远端设置远端SDP offer
setRemoteDescription(remotePeerConnection, offer);
// 5.远端创建SDP answer
const answer = await createAnswer();
// 6.远端设置本地SDP answer
setLocalDescription(remotePeerConnection, answer);
// 7.本地设置SDP answer
setRemoteDescription(localPeerConnection, answer);
}
function hangupHandle() {
console.log("断开远端连接");
// 关闭连接并设置为空
localPeerConnection.close();
remotePeerConnection.close();
localPeerConnection = null;
remotePeerConnection = null;
hangupBtn.disabled = true;
callBtn.disabled = false;
photoBtn.disabled = true;
}
function photoHandle() {
photoContainer.setAttribute("width", localVideo.videoWidth);
photoContainer.setAttribute("height", localVideo.videoHeight);
photoCtx.drawImage(localVideo, 0, 0);
}
function createLocalPeerConnection(rtcConfig) {
// 创建本地 RTCPeerConnection 对象
localPeerConnection = new RTCPeerConnection(rtcConfig);
// 监听本地返回的 Candidate
localPeerConnection.addEventListener("icecandidate", handleICEConnection);
// 监听本地 ICE 状态变化
localPeerConnection.addEventListener(
"iceconnectionstatechange",
handleICEConnectionChange
);
}
function createRemotePeerConnection(rtcConfig) {
// 创建远端 RTCPeerConnection 对象
remotePeerConnection = new RTCPeerConnection(rtcConfig);
// 监听远端返回的 Candidate
remotePeerConnection.addEventListener(
"icecandidate",
handleICEConnection
);
// 监听远端 ICE 状态变化
remotePeerConnection.addEventListener(
"iceconnectionstatechange",
handleICEConnectionChange
);
// 监听远端轨道添加
remotePeerConnection.addEventListener("track", setRemoteMediaStream);
}
function addLocalStream() {
localStream.getTracks().forEach((track) => {
localPeerConnection.addTrack(track, localStream);
});
}
// 设置本地媒体流
function setLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
localStream = mediaStream;
callBtn.disabled = false;
}
// 设置本地SDP
function setLocalDescription(peerConnection, description) {
return peerConnection.setLocalDescription(description);
}
// 生成SDP offer
function createOffer(options) {
return localPeerConnection.createOffer(options);
}
// 生成SDP answer
function createAnswer() {
return remotePeerConnection.createAnswer();
}
// 设置远端SDP
function setRemoteDescription(peerConnection, description) {
return peerConnection.setRemoteDescription(description);
}
// 端与端建立连接
function handleICEConnection(event) {
// 获取到触发 icecandidate 事件的 RTCPeerConnection 对象
// 获取到具体的Candidate
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
// 创建 RTCIceCandidate 对象
const newIceCandidate = new RTCIceCandidate(iceCandidate);
// 得到对端的 RTCPeerConnection
const otherPeer = getOtherPeer(peerConnection);
// 将本地获得的 Candidate 添加到远端的 RTCPeerConnection 对象中
otherPeer.addIceCandidate(newIceCandidate);
}
}
// 显示远端媒体流
function setRemoteMediaStream(event) {
if (remoteVideo.srcObject !== event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
remoteStream = event.streams[0];
console.log("开始接收远端音视频流");
}
}
function handleICEConnectionChange(event) {
console.log("ICE连接状态改变: ", event);
}
function getOtherPeer(peerConnection) {
return peerConnection === localPeerConnection
? remotePeerConnection
: localPeerConnection;
}
</script>
</html>
浅聊WebRTC视频通话
从0到1打造一个 WebRTC 应用
前端音视频WebRTC实时通讯的核心
音视频开发基础概述 - PCM、YUV、H264、常用软件介绍
《 WebRTC音视频实时互动技术——李超 》
官网 WebRTC 架构
STUN(RFC3489)的NAT类型检测方法
webRTC连接过程详细剖析,及阶段总结 - github
|
JavaScript知识库 最新文章 |
ES6的相关知识点 |
react 函数式组件 & react其他一些总结 |
Vue基础超详细 |
前端JS也可以连点成线(Vue中运用 AntVG6) |
Vue事件处理的基本使用 |
Vue后台项目的记录 (一) |
前后端分离vue跨域,devServer配置proxy代理 |
TypeScript |
初识vuex |
vue项目安装包指令收集 |
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 | -2024/11/23 17:02:12- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |