ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 与 WebRTC 音视频直播:从信令到调优实战

Vue 与 WebRTC 音视频直播:从信令到调优实战 音视频直播这块我从早年的 Flash 加 RTMP 一路做到现在的 WebRTC说句实在话真正让前端开发者觉得门槛降下来了的是 Vue 和 WebRTC 这两样东西凑一起的时候。以前做直播前端基本只负责放个播放器推流、转码、分发全在后端和一堆媒体服务器里绕前端能碰的东西很少。而 WebRTC 把采集、编码、传输、渲染这一整条链路都塞进了浏览器Vue 又刚好把组件化、响应式、生命周期这套东西做得足够顺手两者结合一个前端工程师就能独立搭出一套可用的实时音视频直播 Demo甚至小规模生产应用。这篇内容我打算聊的是用 Vue 做界面与状态管理、用 WebRTC 做点对点或小规模多人音视频直播的完整实现思路。核心关键词就是 Vue、WebRTC、音视频直播适合已经会一点 Vue 基础、想往实时音视频方向摸一摸的开发者也适合做了几年业务、想补上 WebRTC 这块短板的老手。整篇会从方案选型、环境搭建、核心链路、参数调优一路讲到排错实录尽量把每一步为什么要这么干讲清楚而不是丢一堆 API 让你自己猜。1. 选型之前Vue 和 WebRTC 各自在直播链路里干什么很多人一上来就写代码结果写到一半发现架构错了得推倒重来。所以先把谁负责什么这件事理清楚后面少走一大半弯路。1.1 WebRTC 到底解决了哪些问题WebRTC 的全称是 Web Real-Time Communication翻译过来就是网页实时通信。它最核心的价值是浏览器原生支持音视频采集、编解码、网络传输不需要装插件、不需要额外客户端。在直播场景里它主要承担四件事。第一件是媒体采集。通过navigator.mediaDevices.getUserMedia()拿到摄像头和麦克风的音视频流这是所有直播的起点。第二件是编解码。视频用 VP8、VP9、H.264、AV1音频用 Opus浏览器内部完成你不用自己实现编码器。第三件是网络传输。这里用的是 SRTP 加密传输配合 ICE 框架做网络地址协商能在复杂的网络环境里找到一条能通的路。第四件是渲染。把远端来的流塞进video标签浏览器自己解码播放一行srcObject就搞定。这四件事里真正难的是第三件。采集和渲染是 API 调用编解码是浏览器内功只有网络传输会因为用户处在不同的网络环境公司内网、家用宽带、移动蜂窝而千变万化。后面第 5 章讲排查大部分坑其实都出在这一块。注意WebRTC 默认要求 HTTPS 或 localhost 环境HTTP 域名下getUserMedia会直接报错开发阶段用localhost可以绕过上线必须配证书。1.2 Vue 在直播项目里承担的角色有人会问用原生 JS 不也能写 WebRTC 吗当然能但一旦直播涉及多人、涉及房间、涉及设备切换状态管理就会爆炸。Vue 在这里的价值主要体现在三个层面。组件化拆解。一个完整的直播页面通常包含本地预览区、远端画面区、房间控制栏、设备选择器、消息面板。用 Vue 拆成独立组件后每个组件只关心自己的渲染逻辑PeerConnection这种有状态的对象可以单独抽成 composable 或 service 层不跟 UI 混在一起。响应式状态同步。连接状态connecting/connected/failed、成员列表、静音状态、摄像头开关这些都是典型的数据变了 UI 要跟着变。用 Vue 的ref、reactive管理比手动操作 DOM 省心太多。比如远端成员加入或离开只要改动成员数组画面区域自动增删不需要写一堆appendChild。生命周期绑定。WebRTC 连接是有生命周期的创建、协商、连接、断开、销毁。Vue 组件的onMounted、onUnmounted刚好能对应上组件卸载时自动关闭连接、释放摄像头避免资源泄漏。这一点在单页应用里尤其重要切换路由不清理摄像头指示灯会一直亮着。1.3 三种直播链路先确定你要哪一种在动手之前先想清楚你的直播属于哪一类因为这直接决定架构。直播类型典型场景延迟推荐方案一对一通话客服、远程协助100ms 以内纯 P2P WebRTC小房间多人在线会议、连麦200ms 以内WebRTC SFU大并发直播秀场、教育大班课1-3sWebRTC 推流 CDN 分发一对一最简单直接 P2P两个浏览器协商好就通了服务端只需要一个信令服务器牵线。小房间多人一般 6 人以内也能用 P2P 组网但每个客户端要维持N-1条连接上行带宽压力大超过 6 人基本就得引入 SFUSelective Forwarding Unit选择性转发单元每个客户端只上传一路流到服务器服务器负责分发。大并发直播则是 WebRTC 负责低延迟推流到边缘节点再由 CDN 做大规模分发这时候前端其实就是个推流端 拉流端的组合。我下面讲的实现以一对一 P2P 小房间可扩展为主线这套骨架理解透了往上叠 SFU 也好接 CDN 也好都不会太吃力。2. 项目骨架搭建Vue 工程与信令服务环境这块我不打算罗列一堆版本号就完事而是想说清楚每一个依赖为什么装、每一个目录为什么这么分。2.1 Vue 工程初始化与依赖取舍推荐用 Vite 初始化 Vue 3 项目命令是npm create vuelatest或者直接用npm create vitelatest my-live -- --template vue。为什么选 Vite 不选 Webpack因为 WebRTC 项目在开发阶段会频繁改代码、频繁热更新Vite 的冷启动和 HMR 速度在这种边调边测的场景里体感差别很明显尤其你要反复在真实摄像头下测试的时候。依赖方面其实很轻量。Vue 3 本身、一个 UI 库Element Plus 或 Naive UI看你习惯、一个信令用的 WebSocket 客户端原生WebSocket就够不用引 socket.io除非你需要自动重连和房间广播这些高级特性。不需要装任何 WebRTC 的 npm 包因为浏览器原生支持装了反而是多余的封装。这一点和 Vue 2 时代的一些老教程不一样那时候有人会用webrtc-adapter做兼容现在现代浏览器基本不需要只有在要兼容老旧环境时才考虑。npm create vitelatest vue-webrtc-live -- --template vue cd vue-webrtc-live npm install npm install element-plus npm run dev2.2 为什么信令服务必须单独搭这是新手最容易困惑的点WebRTC 不是点对点吗为什么还要服务器要理解这件事可以把 WebRTC 建立连接想象成两个人打电话。你光知道对方的名字没用你得知道他的电话号码而且双方得同时在线、同时拿起话筒。WebRTC 的两个浏览器之间就是这种关系它们需要先交换我是谁、我在哪、我支持什么编码格式这些信息这个交换过程就叫信令。而信令本身WebRTC 标准里根本没有规定用什么协议你可以用 WebSocket、可以用 HTTP 轮询、甚至可以用邮件虽然不现实所以这部分必须自己实现。信令要传的东西主要有三类SDPSession Description Protocol描述这次会话的媒体信息包括编解码器、分辨率、加密方式。发起方生成 offer接收方回应 answer。ICE Candidate候选的网络地址每发现一个可能的连接路径就发一个双方互相试探哪条能通。业务信令房间加入、成员列表、挂断通知这些自定义消息。信令服务器只负责转发不碰音视频数据所以负载很轻。我用 Node.js 起一个 WebSocket 服务几十行代码就能用。// server/signaling.js import { WebSocketServer } from ws; const wss new WebSocketServer({ port: 8080 }); const rooms new Map(); wss.on(connection, (ws) { ws.on(message, (raw) { const msg JSON.parse(raw); if (msg.type join) { const { roomId, userId } msg; if (!rooms.has(roomId)) rooms.set(roomId, new Map()); const room rooms.get(roomId); // 把已有成员告诉新来的 ws.send(JSON.stringify({ type: members, list: [...room.keys()] })); room.set(userId, ws); broadcast(room, { type: peer-joined, userId }, userId); } if (msg.type offer || msg.type answer || msg.type candidate) { const room rooms.get(msg.roomId); const target room?.get(msg.targetId); if (target target.readyState 1) { target.send(JSON.stringify({ ...msg, fromId: msg.fromId })); } } }); ws.on(close, () { rooms.forEach((room, roomId) { room.forEach((conn, userId) { if (conn ws) { room.delete(userId); broadcast(room, { type: peer-left, userId }); if (room.size 0) rooms.delete(roomId); } }); }); }); }); function broadcast(room, data, excludeId) { const text JSON.stringify(data); room.forEach((conn, userId) { if (userId ! excludeId conn.readyState 1) conn.send(text); }); } console.log(信令服务运行在 8080);这段代码很朴素但逻辑完整加入房间、同步成员、转发协商消息、处理断开。生产环境你还得加上心跳保活、重连、鉴权但骨架就是这个。2.3 目录结构怎么划分才不乱我见过太多 WebRTC 项目把所有逻辑堆在一个.vue文件里写到后面 800 行改一处崩三处。合理的分层应该是这样src/ ├── components/ │ ├── LocalVideo.vue # 本地预览 │ ├── RemoteVideo.vue # 远端画面 │ └── ControlBar.vue # 开关控制 ├── composables/ │ ├── useMediaDevices.js # 设备枚举与切换 │ └── usePeerConnection.js# 连接管理核心 ├── services/ │ └── signaling.js # 信令客户端封装 ├── stores/ │ └── liveStore.js # Pinia 状态 └── views/ └── LiveRoom.vue # 页面组装核心思想是UI 归 UI状态归状态连接逻辑独立成 composable。这样测试的时候连接逻辑可以脱离界面单独跑排查问题也能快速定位是渲染层还是连接层的问题。3. 核心链路实现从打开摄像头到看见对方这一章是重头戏我把从采集到播放的完整流程拆成几段每段都说明白为什么这么写。3.1 媒体采集约束参数怎么定采集的第一原则是能不加约束就不加需要精确控制再逐个加。因为约束越复杂不同设备的行为差异越大反而容易出问题。// composables/useMediaDevices.js import { ref } from vue; export function useMediaDevices() { const localStream ref(null); const devices ref([]); async function startCapture(options {}) { const constraints { audio: { echoCancellation: true, // 回声消除通话必开 noiseSuppression: true, // 噪声抑制 autoGainControl: true, // 自动增益 sampleRate: 48000, }, video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 }, facingMode: user, // 前置摄像头移动端常用 }, }; const stream await navigator.mediaDevices.getUserMedia(constraints); localStream.value stream; return stream; } async function listDevices() { const all await navigator.mediaDevices.enumerateDevices(); devices.value all.filter(d d.kind videoinput || d.kind audioinput); return devices.value; } function stopCapture() { localStream.value?.getTracks().forEach(track track.stop()); localStream.value null; } return { localStream, devices, startCapture, listDevices, stopCapture }; }这里有几个点值得展开。为什么用ideal而不是exactexact是硬性要求达不到会直接抛错ideal是期望值设备不支持会自动降级到最接近的分辨率。直播场景里用户的摄像头千奇百怪用ideal容错率高得多。为什么音频要开 echoCancellation因为直播里最常见的灾难就是回声对方的声音从你的扬声器出来又被你的麦克风收进去来回震荡体验极差浏览器原生的回声消除效果已经很不错一定要开。还有一个坑enumerateDevices在未授权前拿到的deviceId是空的。必须先调一次getUserMedia拿到权限再枚举才能拿到真实的设备 ID。这个顺序错了设备列表就是一片空白。提示移动端浏览器切到后台会暂停摄像头采集回来时需要重新获取流记得监听visibilitychange事件做恢复。3.2 创建连接RTCPeerConnection 的完整流程RTCPeerConnection是整个 WebRTC 的心脏。它的使用流程可以总结成一条固定套路记住了基本不会错。// composables/usePeerConnection.js import { ref } from vue; export function usePeerConnection(iceServers) { const pc ref(null); const remoteStream ref(new MediaStream()); const connectionState ref(new); function create() { pc.value new RTCPeerConnection({ iceServers, // 例如 [{ urls: stun:stun.l.google.com:19302 }] iceCandidatePoolSize: 10, }); // 收到远端流交给 UI 渲染 pc.value.ontrack (event) { event.streams[0].getTracks().forEach(track { remoteStream.value.addTrack(track); }); }; pc.value.oniceconnectionstatechange () { connectionState.value pc.value.iceConnectionState; }; return pc.value; } function addLocalTracks(stream) { stream.getTracks().forEach(track { pc.value.addTrack(track, stream); }); } async function createOffer() { const offer await pc.value.createOffer(); await pc.value.setLocalDescription(offer); return offer; } async function acceptOffer(offer) { await pc.value.setRemoteDescription(new RTCSessionDescription(offer)); const answer await pc.value.createAnswer(); await pc.value.setLocalDescription(answer); return answer; } async function acceptAnswer(answer) { await pc.value.setRemoteDescription(new RTCSessionDescription(answer)); } async function addCandidate(candidate) { if (candidate) await pc.value.addIceCandidate(new RTCIceCandidate(candidate)); } function close() { pc.value?.close(); pc.value null; remoteStream.value new MediaStream(); } return { pc, remoteStream, connectionState, create, addLocalTracks, createOffer, acceptOffer, acceptAnswer, addCandidate, close, }; }这套流程的为什么是这样的addTrack必须在createOffer之前调用否则 offer 里根本不含媒体信息对方什么也收不到。setLocalDescription必须在createOffer之后立刻调用因为 ICE 候选的收集是在拿到本地描述之后才开始的顺序错了候选就收集不全。iceServers的配置也很关键。STUN 服务器负责让双方发现自己的公网地址提示一下公共的 STUN 服务虽然方便测试但在有些网络环境下会返回不准确的地址自己搭一个或者用云服务商提供的会更靠谱。如果双方都在严格的对称型 NAT 后面光靠 STUN 打不通就需要上 TURN 中继服务器做流量转发这部分要用带宽换连通性成本得提前算进预算。3.3 信令交互把 SDP 和 Candidate 串起来连接建立是双方协同的过程顺序不能乱。发起方offer 方和接收方answer 方的流程我列成表照着走就行。步骤发起方接收方1创建 pc、添加本地轨道创建 pc、添加本地轨道2createOffer setLocalDescription等待3发送 offer收到 offersetRemoteDescription4等待createAnswer setLocalDescription5收到 answersetRemoteDescription发送 answer6收集到 candidate 就发送收集到 candidate 就发送7收到对方 candidate 就 addIceCandidate同左在 Vue 里我把这套逻辑和信令客户端绑定// services/signaling.js export class SignalingClient { constructor(url) { this.ws new WebSocket(url); this.handlers new Map(); } on(type, fn) { this.handlers.set(type, fn); } send(data) { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } connect(onOpen) { this.ws.onopen () onOpen?.(); this.ws.onmessage (e) { const msg JSON.parse(e.data); this.handlers.get(msg.type)?.(msg); }; this.ws.onclose () console.warn(信令连接已关闭); } }然后在业务层把信令事件和 pc 操作对应起来收到offer就acceptOffer并回发 answer收到candidate就addCandidate。这里有个容易被忽略的点——offer 和 candidate 可能乱序到达。如果 candidate 先到而远端描述还没设置addIceCandidate会报错。稳妥的做法是用一个队列把早到的候选缓存起来等setRemoteDescription完成后再统一添加。3.4 在 Vue 组件里管理连接生命周期组件卸载时必须清理否则连接残留、摄像头不关。这是我最想强调的一处工程习惯。// views/LiveRoom.vue 中的 script setup 部分 import { onMounted, onUnmounted, ref } from vue; import { useMediaDevices } from /composables/useMediaDevices; import { usePeerConnection } from /composables/usePeerConnection; import { SignalingClient } from /services/signaling; const { localStream, startCapture, stopCapture } useMediaDevices(); const { remoteStream, create, addLocalTracks, createOffer, acceptOffer, acceptAnswer, addCandidate, close } usePeerConnection([ { urls: stun:stun.l.google.com:19302 }, ]); let signaling null; const pendingCandidates []; onMounted(async () { const stream await startCapture(); signaling new SignalingClient(ws://localhost:8080); signaling.connect(() { signaling.send({ type: join, roomId: demo, userId: me }); }); signaling.on(peer-joined, async () { create(); addLocalTracks(stream); const offer await createOffer(); signaling.send({ type: offer, offer, targetId: peer, fromId: me }); }); signaling.on(offer, async (msg) { create(); addLocalTracks(stream); const answer await acceptOffer(msg.offer); signaling.send({ type: answer, answer, targetId: msg.fromId, fromId: me }); }); signaling.on(answer, async (msg) { await acceptAnswer(msg.answer); // 冲刷早到的候选 while (pendingCandidates.length) { await addCandidate(pendingCandidates.shift()); } }); signaling.on(candidate, async (msg) { if (!pc.value?.remoteDescription) { pendingCandidates.push(msg.candidate); } else { await addCandidate(msg.candidate); } }); }); onUnmounted(() { stopCapture(); close(); signaling?.ws?.close(); });这段代码里onUnmounted做三件事停采集、关连接、断信令。顺序别搞反先停采集再关连接避免在关闭过程中还有轨道数据往里写。这个习惯在多页面应用里能省掉大量切了页面摄像头还亮着的诡异问题。4. 画质、带宽与延迟参数调优的取舍逻辑能跑通只是一半真正决定体验的是参数调优。这一章我讲几个关键参数背后的原理。4.1 视频编码参数的设定逻辑WebRTC 默认会自动调整码率但你可以通过RTCRtpSender.setParameters干预async function tuneBitrate(pc, maxBitrate) { const sender pc.getSenders().find(s s.track?.kind video); if (!sender) return; const params sender.getParameters(); if (!params.encodings || params.encodings.length 0) { params.encodings [{}]; } params.encodings[0].maxBitrate maxBitrate; // 单位 bps params.encodings[0].maxFramerate 30; // 优先保帧率还是保清晰度用降级偏好控制 params.degradationPreference balanced; await sender.setParameters(params); }这里的计算过程值得说明。假设你要传 720p30fps经验码率大约是分辨率像素数乘以每像素比特数和帧率再除以压缩比简化估算下来 720p1280×720 ≈ 92 万像素常用码率区间在 1500kbps 到 2500kbps 之间如果升到 1080p约 207 万像素码率大致翻倍到 3000kbps 到 4500kbps。这不是精确公式而是工程经验值实际还要看画面复杂度——静止的人像和快速运动的场景同样的分辨率码率需求能差一倍以上。degradationPreference这个参数特别重要它决定带宽不足时牺牲谁。设成maintain-framerate会优先保帧率、降分辨率画面流畅但会糊设成maintain-resolution则保清晰度、降帧率画面清楚但会卡顿。直播连麦一般选前者因为人对卡顿更敏感展示文档、屏幕共享选后者更合适。4.2 带宽估计与动态降码率WebRTC 内部有一套拥塞控制机制会根据丢包和延迟动态调整发送码率它用的算法会计算链路的可用容量估算结果是动态浮动的。前端能做的辅助优化有两点一是监听getStats()拿到实时数据二是在界面给出画质档位让用户手动兜底。async function monitorStats(pc) { const stats await pc.getStats(); stats.forEach(report { if (report.type inbound-rtp report.kind video) { console.log(接收码率参考, report.bytesReceived); console.log(丢包数, report.packetsLost); console.log(抖动, report.jitter); } }); }我一般会定时比如每 3 秒拉一次统计如果丢包率持续超过 5%就主动调低maxBitrate或者引导用户切换到音频优先模式。实测下来与其等浏览器自己慢慢降不如在丢包刚起来的时候主动介入用户体验会好很多。4.3 音频处理的取舍音频这块我建议默认全开三项回声消除、噪声抑制、自动增益。但有两个例外场景要注意。一是音乐直播。如果主播是在唱歌、弹奏乐器噪声抑制和自动增益会破坏音质把音乐的动态范围压扁这时候应该关掉这两个只留回声消除。用applyConstraints动态切换async function switchToMusicMode(stream) { const audioTrack stream.getAudioTracks()[0]; await audioTrack.applyConstraints({ echoCancellation: true, noiseSuppression: false, autoGainControl: false, }); }二是音频码率。默认 Opus 是自适应码率通话场景够用但如果要传高保真音乐需要手动指定更高的码率通过 SDP 修改或setParameters调整maxBitrate一般音乐场景建议给到 96kbps 以上通话场景 32kbps 左右就够了。5. 常见问题与排查实录这一章全是踩坑经验也是我认为最值钱的部分。WebRTC 的问题往往没有明确报错只能靠一套系统的方法逐步缩小范围。5.1 画面黑屏、只有声音、完全连不上这三类现象覆盖了绝大多数初次调试的问题我按概率从高到低列出来。黑屏但有声音通常是视频轨道没加上或者加上了但没渲染。先确认pc.getSenders()里有没有 video 类型的 sender再看video标签的srcObject有没有赋值还要检查autoplay和playsinline属性——移动端 Safari 不写playsinline会强制全屏看起来就像没渲染。只有画面没声音多半是自动播放策略导致的。浏览器要求媒体播放必须有用户交互要么加muted属性先静音播放要么等用户点一下再播。这在直播里很常见一个点击进入房间的按钮其实就是在解决这个问题。完全连不上先看pc.iceConnectionState如果是failed八成是网络地址没打通。这时候要分情况同一个局域网内应该能直连跨公网连不上大概率需要 TURN 中继。排查方法是用getStats()看candidate-pair的状态找到nominated为 true 的那对看它的state是不是succeeded。5.2 网络环境相关的疑难杂症不同网络环境的表现差异极大我整理了一张速查表对照着排。现象可能原因排查方向内网正常公网失败缺少 TURN 中继检查网络地址协商日志连接时好时坏候选地址不稳定增加 ICE 服务器数量连上后几秒断开心跳超时或候选过期检查保活机制画面卡顿但音频正常视频码率过高调低 maxBitrate双方都在移动网络下失败对称型网络限制必须上 TURN 中继这里想强调一句别迷信公网 STUN 免费用。公共 STUN 服务在高峰期响应慢甚至丢包会拖长连接建立时间。如果项目要上线自己部署一套 STUN 和 TURN 是值得的投入成本不高但连接成功率提升明显。5.3 Vue 层面的专属坑WebRTC 本身的问题排完还有一批坑是 Vue 特有的。响应式包裹了 pc 对象。RTCPeerConnection是一个复杂的原生对象如果你用reactive()去包裹它Vue 的 Proxy 会代理它的内部属性和方法导致调用addIceCandidate时this指向错误报各种奇怪的错。正确做法是用shallowRef或者直接存在普通变量里不要用reactive包 WebRTC 原生对象。video的 ref 拿到太晚。如果你在onMounted里立刻给videoEl.srcObject赋值但此时 DOM 可能还没完全挂载会赋不上。稳妥做法是nextTick之后再操作或者用watch监听流的变化再赋值。热更新导致连接残留。Vite 的 HMR 在改动代码时会重新执行模块但不会触发onUnmounted于是摄像头和连接就一直挂着。解决方法是开发阶段把清理逻辑也写进import.meta.hot.dispose里或者手动刷新页面。这个坑我踩了好几次桌面上一堆摄像头被占用的提示。Vue 相关问题表现解决pc 被 reactive 包裹方法调用报错改用 shallowRefvideoEl 赋值时机错画面不显示nextTick 后赋值HMR 未清理摄像头一直占用手动刷新或加 dispose组件切换未卸载连接泄漏onUnmounted 强制清理6. 上线前的收尾与个人补充功能跑通之后到真正能用中间还有一段距离。我自己在项目里会重点检查三件事一是权限降级处理用户拒绝摄像头权限时要有明确的引导文案而不是白屏二是弱网兜底检测到持续高丢包时主动降档或提示切换三是资源回收反复进出房间多次后用chrome://webrtc-internals这个诊断页面看有没有连接残留正常情况下关闭后应该全部释放。关于扩展方向如果人数超过 6 个就得上 SFU推荐的开源方案可以搜一下 mediasoup、Janus、LiveKit 这几个它们的思路都是客户端只推一路流给服务器服务器选择性转发前端改动其实不大主要是把RTCPeerConnection对端的角色从另一个浏览器换成媒体服务器。如果要做超大规模直播则是 WebRTC 负责低延迟上行接一层转码和 CDN 分发前端变成推流端加普通播放器的组合这部分和纯 P2P 的思路就完全分开了。最后分享一个我调试时的小习惯浏览器地址栏输入chrome://webrtc-internals打开这个页面之后再开始你的直播流程它会详细记录每一次连接的所有事件、SDP 内容、候选地址、统计曲线。绝大多数怎么连不上为什么卡的问题答案都在这里面比在代码里打console.log高效得多。养成这个习惯之后我在处理连接类问题时基本能做到心里有数而不是靠猜。
返回列表