Appearance
02. 音视频与 WebRTC 实时通信
一句话定位:WebRTC 在移动端落地的核心复杂度在于信令 + ICE 穿透 + 编解码三层分离;本页优先帮 Android / Flutter 工程师建立决策边界与排障思路,当前先不伪造空实验。
代码索引
本主题暂无落地 Lab;当前先收紧边界与承载计划,避免制造只有 README 没源码的空实验。
为什么需要
为什么不直接用 HTTP 传输音视频,而要用 WebRTC?
- 一句话答:HTTP 是请求-响应模型,延迟高(通常 100ms+);WebRTC 基于 UDP,内置 DTLS 加密、SRTP 媒体流、RTCP 拥塞控制与带宽估计,实测端到端延迟可低至 50~150ms,是实时音视频唯一的工业级开放标准。
为什么 WebRTC 需要信令服务器?
- 一句话答:WebRTC 只定义媒体传输,不定义两端如何互相找到对方、交换 SDP(媒体能力描述)和 ICE Candidate(网络地址候选)——这部分交换必须由开发者自行实现,通常用 WebSocket 或 HTTP 长轮询完成。
为什么内网穿透那么麻烦?
- 一句话答:两端都在 NAT 后面时,直接发 UDP 包对方收不到;STUN 服务器帮助双方发现自己的公网地址,TURN 服务器在 P2P 打洞失败时充当中继,ICE 框架自动选最优路径。
为什么 Flutter 项目做 WebRTC,难点常常不在 Dart UI,而在原生媒体与网络边界?
- 一句话答:Flutter 页面只负责承载会话状态和渲染,真正重的工作是摄像头、音频路由、硬编硬解、ICE、前后台切换和弱网收敛,这些都高度依赖宿主平台与原生 SDK。
一、架构分层
┌─────────────────────────────────────┐
│ 应用层(UI / 信令) │
│ SDP Offer/Answer 交换,信令自定义 │
├─────────────────────────────────────┤
│ WebRTC 核心 │
│ PeerConnection MediaStream │
│ ICE Agent(STUN/TURN 穿透) │
│ DTLS(加密握手) SRTP(媒体加密) │
│ 带宽估计(BWE) FEC / NACK 重传 │
├─────────────────────────────────────┤
│ 传输层 │
│ UDP(主)/ TCP fallback │
└─────────────────────────────────────┘工程上最常见的误判,是把这几层混在一个“通话是否成功”的布尔值里。更稳的做法是拆成至少四类状态:
- 信令是否连通
- SDP 是否协商成功
- ICE 是否选出可用通路
- 媒体轨道是否真正发送 / 接收成功
二、关键概念
| 概念 | 说明 | Android / Flutter 工程判断 |
|---|---|---|
| SDP (Session Description Protocol) | 描述媒体能力(编解码器、分辨率、方向),Offer/Answer 模型 | 不要把它当“聊天协议”;它本质是会话能力协商 |
| ICE (Interactive Connectivity Establishment) | 收集网络地址候选,自动选最优路径 | ICE_FAILED 往往不是 UI 问题,而是网络 / 中继问题 |
| STUN | 帮助客户端发现自己的公网 IP:Port(反射地址),轻量 | 成本低,但不保证穿透成功 |
| TURN | NAT 穿透失败时的中继服务器,流量走服务器转发,有带宽成本 | 成本高但最稳定,必须有兜底 |
| DTLS | 基于 UDP 的 TLS,用于 WebRTC 连接握手与密钥协商 | 实时音视频不是明文 UDP 裸奔 |
| SRTP | 基于 DTLS 协商密钥的媒体加密传输协议 | 媒体层加密依赖 DTLS 握手结果 |
| BWE (Bandwidth Estimation) | WebRTC 内置拥塞控制(GCC 算法),动态调整发送码率 | 决定“卡不卡、糊不糊、延迟高不高” |
三、完整通话建立流程
主叫 (Caller) 信令服务器 被叫 (Callee)
| | |
|-- createOffer() ---------->| |
| (生成 SDP Offer) |-- 转发 SDP Offer ----->|
| | |-- createAnswer()
| |<-- 转发 SDP Answer ----|
|<-- setRemoteDescription() -| |
| | |
|-- 收集 ICE Candidate ----->|-- 转发 Candidate ----->|
|<----- 收集 ICE Candidate --|<-- 转发 Candidate -----|
| | |
| ICE 连通性检查(STUN Binding Request) |
|<========= DTLS 握手 ================================>|
|<========= SRTP 媒体流 ==============================>|等价的最小步骤链:
text
createOffer
-> setLocalDescription
-> signaling send offer
-> remote createAnswer
-> exchange ICE candidates
-> ICE connectivity check
-> DTLS handshake
-> SRTP media flowing这也是为什么当前仓库不适合硬造一个“假 WebRTC lab”:如果没有至少以下几项,就很难算真正落地:
- 一个最小信令通道(哪怕是本地回环)
- 至少一套真实
PeerConnection建立链路 - 候选、连接状态、媒体状态的日志面板
- Android / Flutter 宿主层的摄像头、麦克风、前后台切换处理
四、Android 端 WebRTC 核心 API
Android 使用 Google 官方 org.webrtc:google-webrtc 库:
kotlin
// 1. 初始化 PeerConnectionFactory
val initOptions = PeerConnectionFactory.InitializationOptions.builder(context)
.setEnableInternalTracer(true)
.createInitializationOptions()
PeerConnectionFactory.initialize(initOptions)
val factory = PeerConnectionFactory.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBaseContext, true, true))
.createPeerConnectionFactory()
// 2. 创建 PeerConnection
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("turn:your.turn.server:3478")
.setUsername("user").setPassword("pass").createIceServer()
)
val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN // 必须用 UNIFIED_PLAN
}
val peerConnection = factory.createPeerConnection(rtcConfig, peerConnectionObserver)!!
// 3. 创建 Offer(主叫端)
peerConnection.createOffer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription) {
peerConnection.setLocalDescription(this, sdp)
signalingChannel.send(sdp) // 通过信令服务器发送给被叫
}
override fun onCreateFailure(error: String) { /* 处理错误 */ }
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
}, MediaConstraints())
// 4. 添加本地媒体流
val videoSource = factory.createVideoSource(false)
val videoTrack = factory.createVideoTrack("local_video", videoSource)
peerConnection.addTrack(videoTrack) // UNIFIED_PLAN 用 addTrack- 可能输出:
onIceCandidate回调持续返回本地网络地址候选 - 观察重点:
ICE_FAILED状态通常表示 TURN 服务器未配置或凭证错误
五、编解码选型
| 编解码器 | 特点 | Android 硬件加速 |
|---|---|---|
| VP8 | WebRTC 历史默认,兼容性好 | 部分支持 |
| VP9 | 压缩效率高,质量更好 | Android 5+ MediaCodec 支持 |
| H.264 | 与移动端兼容性最佳,硬件加速覆盖率高 | 几乎所有 Android 设备 |
| H.265/HEVC | 压缩率最高,但专利限制 | 需单独授权 |
| AV1 | 开源、效率最高,WebRTC 1.0+ 支持 | Android 10+ 部分支持 |
面试关键:H.264 在 Android 移动端 WebRTC 项目中是实际首选,因为几乎所有设备都有硬件编解码支持,可大幅降低 CPU 功耗。
六、弱网适应机制
WebRTC 内置多层弱网保障:
发包端 收包端
├── BWE(带宽估计)动态调整码率 ├── NACK 丢包重传请求
├── FEC(前向纠错)冗余包 ├── PLI 请求关键帧(画面花屏时)
├── Simulcast(多清晰度同时发送) ├── JitterBuffer 抖动缓冲
└── RTX(重传包) └── NetEQ 音频自适应缓冲- Simulcast:同时发多路不同分辨率的流,接收端根据带宽选择;适合多人视频会议
- SVC (Scalable Video Coding):单流内部分层,VP9/AV1 支持,比 Simulcast 更灵活
Android / Flutter / Web / Backend 对照
| 维度 | Android | Flutter | Web / Backend | 判断重点 |
|---|---|---|---|---|
| 媒体采集 | Camera / Mic / AudioFocus / 路由切换 | 页面渲染与插件接入 | 浏览器 getUserMedia / 服务端信令 | Android 宿主与设备适配最重 |
| 通话状态 | PeerConnection.Observer 回调极多 | UI 只应展示聚合后的业务状态 | 前端可直接跑浏览器 WebRTC | Flutter 不应直接承接全部原生细节 |
| 弱网保障 | 硬编硬解、网络切换、音频路由 | 负责状态收敛与用户提示 | TURN 成本、信令扩展、房间治理 | 端和服务端必须一起设计 |
| iOS 对照 | AVAudioSession / CallKit 边界 | 插件层同样要适配 | — | 这里只保留边界提醒,不深写 |
常见场景
1. 一对一音视频通话
最小链路看似简单,但也至少要回答:
- 信令掉线后会话怎么收口?
- 切到后台后音频是否继续?
- 相机权限、麦克风权限失败如何回退?
- 候选收集慢时 UI 怎么给等待反馈?
2. Flutter 通话页承载的合理边界
Flutter 页更适合承载:成员列表、连接态 / 重连态 / 静音态、画面容器布局、错误提示与用户操作。
不适合把所有原生媒体状态都原样暴露到 Dart 并直接驱动页面;那样会让跨端层承受过多平台噪音。
常见误配、事故后果与排障
| 问题 | 原因 | 排障思路 |
|---|---|---|
| ICE Failed | TURN 未配置或凭证错误、防火墙封 UDP | 检查 TURN 凭证;抓包确认 STUN/TURN 请求是否到达 |
| 画面单向黑屏 | 远端 SDP 设为 recvonly,本端未发送 | 检查 SDP 的 direction 字段 |
| 连接建立但无声音 | 音频 Track 未添加或 SRTP 密钥不匹配 | 检查 addTrack 是否包含 AudioTrack |
| 延迟高 | 走了 TURN 中继(P2P 打洞失败) | 优化 TURN 服务器地理位置;检查防火墙是否允许 UDP |
| 视频卡顿 | 带宽不足触发 BWE 降码率 | 检查 RTCStatsReport 的 availableOutgoingBitrate |
| 只有 STUN,没有 TURN | 误以为“网络好时才需要” | TURN 是商用链路的兜底能力,不是加分项 |
| 把“信令连上”误判为“通话建立成功” | 状态机过粗 | 分别建模 signaling / ICE / media 三层状态 |
| Flutter 直接消费过多底层回调 | 原生回调噪音大、时序复杂 | 插件 / 宿主层先聚合成少量稳定业务事件再上抛 |
对应实验
计划补齐的实验:
labs/flutter/host/topics/specialized-topics/webrtc-signaling-loopback/README.md— Flutter 端最小通话壳 + 本地回环信令状态机(先验证状态收敛)labs/flutter/host/lib/topics/specialized_topics/webrtc_signaling_loopback/— 只在确定引入flutter_webrtc与宿主权限方案后再落真实源码
后续最小落地顺序:状态机回环 → 本地媒体采集 → 真正 PeerConnection,而不是先堆完整云端房间能力。
复习检查题
为什么 WebRTC 需要信令服务器,它负责什么?
答:WebRTC 不定义信令协议,信令服务器负责在两端之间传递 SDP Offer/Answer(媒体能力描述)和 ICE Candidate(网络地址候选);通话建立后信令服务器不再参与媒体传输。
STUN 和 TURN 的区别是什么?什么情况必须用 TURN?
答:STUN 轻量,只帮客户端发现公网地址;当双端都在 Symmetric NAT 后面、P2P 打洞失败时,必须使用 TURN 服务器作为媒体中继,流量成本显著增加(流量走服务器转发)。
SDP Offer/Answer 交换的目的是什么?
答:双端通过 SDP 协商媒体能力,包括:支持的编解码器列表、音视频方向(sendrecv/sendonly/recvonly)、SRTP 加密参数、候选的媒体端口范围;协商后双端选出最优交集。
WebRTC 弱网下如何保证音视频质量?
答:发端用 BWE 动态降码率、FEC 冗余纠错、RTX 重传;收端用 JitterBuffer 平滑抖动、NACK 请求丢包重传、PLI 请求关键帧恢复;音频用 NetEQ 自适应缓冲防止破音。
Android 上 WebRTC 应优先选哪种视频编解码器,为什么?
答:优先选 H.264 Hardware,因为几乎所有 Android 设备都有硬件编解码支持(通过 MediaCodec),可大幅降低 CPU 功耗;纯软编的 VP8/VP9 在低端设备上会导致发热严重和帧率下降。
Flutter 做 WebRTC 时,为什么不应该把所有底层状态直接暴露给页面?
答:因为原生媒体和 ICE 回调噪音极大、时序复杂,直接上抛会让 Dart 页面承受大量平台差异和竞态;更稳的做法是在插件 / 宿主层先聚合成少量稳定业务状态。
速记
- WebRTC 三层:信令(自己实现)→ ICE 穿透(STUN/TURN)→ 媒体传输(DTLS+SRTP)
- P2P 失败必须有 TURN 兜底,否则用户看到「连接失败」
- Android 首选 H.264 硬编,减少 CPU 占用和发热
- UNIFIED_PLAN 是现代标准,Plan B 已废弃
- Flutter 承载页面与状态,原生层承载媒体与设备细节
- 当前先收边界,不造空实验