1. ICE 概述
1.1 什么是 ICE
ICE (Interactive Connectivity Establishment) 全称是"交互式连接建立"。简单来说,ICE 就是帮助两个设备在复杂的网络环境中找到对方并建立连接的技术。
生活中的类比
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 的生活类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
想象你要给一个新朋友寄快递,但你只知道他的名字:
场景 1:你们在同一个小区
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 你:我住在 A 栋 101 │
│ 朋友:我住在 B 栋 202 │
│ 结果:直接送过去就行 → 这就是 host 候选者(直连) │
└─────────────────────────────────────────────────────────────────────────────────────────┘
场景 2:你们在不同城市
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 你:我在北京,地址是 xxx │
│ 朋友:我在上海,地址是 yyy │
│ 结果:通过快递公司中转 → 这就是 relay 候选者(中转) │
└─────────────────────────────────────────────────────────────────────────────────────────┘
场景 3:你不知道自己的公网地址
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 你:我不知道我的公网地址是什么 │
│ STUN 服务器:我来告诉你,你的公网地址是 xxx │
│ 结果:知道了公网地址 → 这就是 srflx 候选者(服务器反射) │
└─────────────────────────────────────────────────────────────────────────────────────────┘
ICE 的工作就是收集所有可能的"地址",然后尝试每一个,找到能连通的那一个。
1.2 为什么需要 ICE
在 WebRTC 视频通话中,双方通常处于不同的网络环境:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 网络连接的挑战 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
用户 A(家里) 用户 B(公司)
┌─────────────┐ ┌─────────────┐
│ 手机 │ │ 电脑 │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ WiFi路由器 │ │ 公司防火墙 │
│ 192.168.1.x │ │ 10.0.0.x │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 运营商NAT │ │ 公司NAT │
│ 公网IP A │ │ 公网IP B │
└──────┬──────┘ └──────┬──────┘
│ │
└──────────────────┬───────────────────────┘
│
▼
┌─────────────┐
│ 互联网 │
└─────────────┘
挑战:
1. A 不知道自己的公网 IP(被 NAT 隐藏了)
2. B 在公司防火墙后面,外部无法主动连接
3. 双方都在 NAT 后面,无法直接通信
ICE 的解决方案:
1. 通过 STUN 服务器获取公网 IP
2. 尝试各种可能的连接方式
3. 如果直连不通,使用 TURN 服务器中转
1.3 ICE 的核心作用
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 的三大核心作用 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 候选者收集 - "我有哪些地址?" │
│ │
│ 收集所有可能的网络地址: │
│ - 本地地址(局域网 IP) │
│ - 公网地址(通过 STUN 获取) │
│ - 中转地址(通过 TURN 获取) │
│ │
│ 通俗理解:列出所有能联系到我的方式 │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ 连接检查 - "哪个地址能用?" │
│ │
│ 尝试每一个候选者对,检查是否能连通: │
│ - 发送 STUN 绑定请求 │
│ - 接收 STUN 绑定响应 │
│ - 验证双向连通性 │
│ │
│ 通俗理解:挨个试,看哪个能打通 │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 3️⃣ 连接选择 - "用哪个最好?" │
│ │
│ 根据优先级选择最佳连接: │
│ - host > srflx > prflx > relay │
│ - 直连优先,中转最后 │
│ │
│ 通俗理解:能直连就直连,实在不行才中转 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2. ICE 候选者类型详解
2.1 四种候选者类型
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 候选者类型 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 类型 │ 英文全称 │ 通俗解释 │ 优先级 │ 示例 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ host │ Host Candidate │ 本地地址 │ 最高 ⭐⭐⭐ │ 192.168.1.100 │
│ srflx │ Server Reflexive │ 服务器反射地址 │ 高 ⭐⭐ │ 203.0.113.10 │
│ prflx │ Peer Reflexive │ 对等反射地址 │ 高 ⭐⭐ │ 动态发现 │
│ relay │ Relayed Candidate │ 中继地址 │ 低 ⭐ │ TURN服务器地址 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2.2 Host 候选者(本地地址)
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Host 候选者详解 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
什么是 Host 候选者?
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 设备本地的网络地址,通常是局域网 IP │
│ │
│ 通俗理解:你家里的"门牌号" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
示例:
a=candidate:1 1 UDP 2122260223 192.168.1.100 5000 typ host
分解:
├── foundation: 1 (候选者标识)
├── component-id: 1 (1=RTP, 2=RTCP)
├── transport: UDP (传输协议)
├── priority: 2122260223 (优先级,最高)
├── IP: 192.168.1.100 (本地 IP 地址)
├── port: 5000 (本地端口)
└── typ: host (类型:本地地址)
使用场景:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ✅ 双方在同一个局域网内(如同一个 WiFi) │
│ ✅ 可以直接通信,不需要经过 NAT │
│ ✅ 延迟最低,速度最快 │
│ │
│ 通俗理解:邻居之间串门,直接走过去就行 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
局限性:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ❌ 不同局域网之间无法使用 │
│ ❌ 无法穿透 NAT │
│ │
│ 通俗理解:不能用来给外地朋友寄快递 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2.3 Server Reflexive 候选者(服务器反射地址)
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ srflx 候选者详解 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
什么是 srflx 候选者?
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 通过 STUN 服务器获取的公网 IP 地址 │
│ │
│ 通俗理解:STUN 服务器告诉你"你在互联网上的地址是什么" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
工作原理:
┌─────────────────┐ ┌─────────────────┐
│ 客户端 A │ │ STUN 服务器 │
│ 192.168.1.100 │ │ 公网 IP │
│ │ │ │
│ "我的公网地址 │ │ │
│ 是什么?" │ ──────────────────>│ │
│ │ │ │
│ │ "你的公网地址是 │ │
│ │ 203.0.113.10" │ │
│ │ <──────────────────│ │
└─────────────────┘ └─────────────────┘
示例:
a=candidate:2 1 UDP 1686052607 203.0.113.10 12345 typ srflx raddr 192.168.1.100 rport 5000
分解:
├── IP: 203.0.113.10 (公网 IP 地址)
├── port: 12345 (公网端口,NAT 映射后的端口)
├── typ: srflx (类型:服务器反射)
├── raddr: 192.168.1.100 (原始本地 IP)
└── rport: 5000 (原始本地端口)
使用场景:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ✅ 穿透 Cone NAT(锥形 NAT) │
│ ✅ 不同网络之间通信 │
│ ✅ 不需要中转服务器 │
│ │
│ 通俗理解:知道了公网地址,可以给外地朋友寄快递了 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
局限性:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ❌ 无法穿透 Symmetric NAT(对称型 NAT) │
│ ❌ 某些防火墙可能阻止 │
│ │
│ 通俗理解:有些小区的门禁太严,还是进不去 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2.4 Peer Reflexive 候选者(对等反射地址)
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ prflx 候选者详解 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
什么是 prflx 候选者?
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 在 ICE 连接检查过程中,对方告诉你的地址 │
│ │
│ 通俗理解:对方说"我看到你的地址是 xxx",这是一个意外发现的地址 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
工作原理:
┌─────────────────┐ ┌─────────────────┐
│ 客户端 A │ │ 客户端 B │
│ │ │ │
│ 发送连接请求 │ ──────────────────>│ │
│ │ │ │
│ │ "我看到你的地址是 │ │
│ │ 203.0.113.20" │ │
│ │ <──────────────────│ │
│ │ │ │
│ "原来我还有这个│ │ │
│ 地址!" │ │ │
└─────────────────┘ └─────────────────┘
使用场景:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ✅ Symmetric NAT 环境下 │
│ ✅ NAT 映射与预期不同时 │
│ ✅ 意外发现的可连通地址 │
│ │
│ 通俗理解:本来不知道这个地址,对方告诉我才知道 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
2.5 Relay 候选者(中继地址)
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Relay 候选者详解 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
什么是 Relay 候选者?
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 通过 TURN 服务器中转的地址 │
│ │
│ 通俗理解:实在连不上,就找个中间人帮忙传话 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
工作原理:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 客户端 A │ │ TURN 服务器 │ │ 客户端 B │
│ │ │ │ │ │
│ 发送数据 │ ────>│ 中转数据 │ ────>│ 接收数据 │
│ │ │ │ │ │
│ 接收数据 │ <────│ 中转数据 │ <────│ 发送数据 │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
示例:
a=candidate:3 1 UDP 41885439 198.51.100.10 60000 typ relay raddr 198.51.100.10 rport 60000
分解:
├── IP: 198.51.100.10 (TURN 服务器的 IP)
├── port: 60000 (TURN 服务器分配的端口)
├── typ: relay (类型:中继)
└── priority: 41885439 (优先级,最低)
使用场景:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ✅ 所有其他方式都失败时 │
│ ✅ 严格的 Symmetric NAT 环境 │
│ ✅ 企业防火墙阻止 P2P │
│ │
│ 通俗理解:最后的选择,保证一定能连通 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
缺点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ❌ 延迟较高(多经过一跳) │
│ ❌ 服务器带宽成本高 │
│ ❌ 不是真正的 P2P │
│ │
│ 通俗理解:找中间人传话,总比直接说慢一点 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
3. ICE 连接流程
3.1 完整的 ICE 连接流程
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 连接完整流程 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
客户端 A STUN/TURN 服务器 客户端 B
│ │ │
│ │ │
│ ═══════════════════════════════════════════════════════════════════════════════════
│ 阶段 1: 收集候选者 (Gathering)
│ ═══════════════════════════════════════════════════════════════════════════════════
│ │ │
│ 1. 收集本地地址 (host) │ │
│ - 192.168.1.100 │ │
│ │ │
│ 2. 向 STUN 服务器查询公网地址 │ │
│ ─────────────────────────────────────────────────>│ │
│ │ │
│ "你的公网地址是 203.0.113.10" │
│ <─────────────────────────────────────────────────│ │
│ │ │
│ 3. 收集 srflx 候选者 │ │
│ - 203.0.113.10 │ │
│ │ │
│ 4. (可选) 向 TURN 服务器请求中继地址 │ │
│ ─────────────────────────────────────────────────>│ │
│ │ │
│ "分配的中继地址是 198.51.100.10" │
│ <─────────────────────────────────────────────────│ │
│ │ │
│ 5. 收集 relay 候选者 │ │
│ - 198.51.100.10 │ │
│ │ │
▼ ▼ ▼
客户端 A 信令服务器 客户端 B
│ │ │
│ ═══════════════════════════════════════════════════════════════════════════════════
│ 阶段 2: 交换候选者 (Exchange)
│ ═══════════════════════════════════════════════════════════════════════════════════
│ │ │
│ 发送候选者给 B │ │
│ - host: 192.168.1.100 │ │
│ - srflx: 203.0.113.10 │ │
│ - relay: 198.51.100.10 │ │
│ ─────────────────────────────────────────────────>│ │
│ │ 转发候选者给 B │
│ │ ───────────────────────────────>│
│ │ │
│ │ B 也收集并发送候选者 │
│ │ <───────────────────────────────│
│ 接收 B 的候选者 │ │
│ <─────────────────────────────────────────────────│ │
│ │ │
▼ ▼ ▼
客户端 A 客户端 B
│ │
│ ═══════════════════════════════════════════════════════════════════════════════════
│ 阶段 3: 连接检查 (Connectivity Check)
│ ═══════════════════════════════════════════════════════════════════════════════════
│ │
│ 尝试所有候选者对: │
│ │
│ A 的 host → B 的 host ❌ 不同网络,不通 │
│ A 的 host → B 的 srflx ❌ NAT 阻挡 │
│ A 的 srflx → B's host ❌ NAT 阻挡 │
│ A 的 srflx → B's srflx ✅ 成功! │
│ ──────────────────────────────────────────────────────────────────────────────────>│
│ │
│ 收到响应,确认连通 │
│ <──────────────────────────────────────────────────────────────────────────────────│
│ │
▼ ▼
│ ═══════════════════════════════════════════════════════════════════════════════════
│ 阶段 4: 连接建立 (Connection Established)
│ ═══════════════════════════════════════════════════════════════════════════════════
│ │
│ 选择最佳候选者对:srflx ↔ srflx │
│ 开始传输音视频数据 │
│ ──────────────────────────────────────────────────────────────────────────────────>│
│ <──────────────────────────────────────────────────────────────────────────────────│
│ │
▼ ▼
3.2 ICE 状态机
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 连接状态 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────┐ │
│ │ NEW │ │
│ │ (新建) │ │
│ └────────┬────────┘ │
│ │ │
│ │ 开始收集候选者 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ GATHERING │ │
│ │ (收集中) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CHECKING │ │ CLOSED │ │ FAILED │ │
│ │ (检查中) │ │ (已关闭) │ │ (失败) │ │
│ └──────┬──────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ │ 至少一对候选者连通 │
│ ▼ │
│ ┌─────────────┐ │
│ │ CONNECTED │ │
│ │ (已连接) │ │
│ └──────┬──────┘ │
│ │ │
│ │ 所有候选者对检查完成 │
│ ▼ │
│ ┌─────────────┐ │
│ │ COMPLETED │ │
│ │ (完成) │ │
│ └──────┬──────┘ │
│ │ │
│ │ 连接断开 │
│ ▼ │
│ ┌─────────────┐ │
│ │ DISCONNECTED│ │
│ │ (断开) │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
状态说明:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 状态 │ 通俗解释 │ 说明 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ NEW │ 刚创建 │ PeerConnection 刚创建 │
│ GATHERING │ 正在收集地址 │ 正在收集 ICE 候选者 │
│ CHECKING │ 正在尝试连接 │ 正在进行连接检查 │
│ CONNECTED │ 连上了 │ 至少有一对候选者连通 │
│ COMPLETED │ 全部检查完了 │ 所有检查完成,选择了最佳路径 │
│ FAILED │ 连接失败 │ 所有候选者对都无法连通 │
│ DISCONNECTED │ 连接断开 │ 之前连通,现在断了 │
│ CLOSED │ 已关闭 │ PeerConnection 已关闭 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4. STUN 协议详解
4.1 什么是 STUN
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ STUN 协议概述 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
STUN (Session Traversal Utilities for NAT)
通俗理解:STUN 就像是一个"镜子",告诉你"你在互联网上是什么样子"
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 客户端 STUN 服务器 │
│ ┌─────────┐ ┌─────────┐ │
│ │ 本地IP: │ │ 公网IP │ │
│ │ 192.168 │ │ 已知 │ │
│ │ .1.100 │ │ │ │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
│ │ "我的公网地址是什么?" │ │
│ │ ─────────────────────────────>│ │
│ │ │ │
│ │ "你的公网地址是 203.0.113.10" │ │
│ │ <─────────────────────────────│ │
│ │ │ │
│ │ "知道了!" │ │
│ ▼ ▼ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
STUN 的作用:
1. 告诉客户端它的公网 IP 和端口
2. 帮助穿透 NAT
3. 检测 NAT 类型
4.2 STUN 消息格式
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ STUN 消息格式 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
STUN 消息结构:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 0 1 2 3 │
│ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 │
│ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │
│ |0 0| STUN Message Type | Message Length | │
│ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │
│ │ Magic Cookie │ │
│ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │
│ │ │ │
│ │ Transaction ID (96 bits) │ │
│ │ │ │
│ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │
│ │ Attributes │ │
│ │ ... │ │
│ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
字段解释:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 字段 │ 长度 │ 通俗解释 │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ Message Type │ 2 字节 │ 消息类型(请求/响应/错误) │
│ Message Length │ 2 字节 │ 消息体长度 │
│ Magic Cookie │ 4 字节 │ 固定值 0x2112A442,用于识别 STUN 消息 │
│ Transaction ID │ 12 字节 │ 请求标识符,用于匹配请求和响应 │
│ Attributes │ 变长 │ 各种属性,如 IP 地址、端口等 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
常见消息类型:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 类型 │ 值 │ 通俗解释 │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ Binding Request │ 0x0001 │ "告诉我我的公网地址是什么" │
│ Binding Response │ 0x0101 │ "你的公网地址是 xxx" │
│ Binding Error │ 0x0111 │ "出错了" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
4.3 本项目中的 STUN 配置
kotlin
// PeerConnectionManager.kt 中的 STUN 配置
private fun buildIceServers(config: JSONObject?): List<PeerConnection.IceServer> {
val iceServers = mutableListOf<PeerConnection.IceServer>()
config?.let { cfg ->
val stunConfig = cfg.optJSONObject("stun")
stunConfig?.let { stun ->
if (stun.optBoolean("enabled", false)) {
val server = stun.optString("server", "")
val port = stun.optInt("port", 3478)
val stunUrl = "stun:$server:$port"
iceServers.add(PeerConnection.IceServer.builder(stunUrl).createIceServer())
Log.i(TAG, "[webrtc] 添加STUN服务器: $stunUrl")
}
}
}
return iceServers
}
5. TURN 协议详解
5.1 什么是 TURN
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ TURN 协议概述 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
TURN (Traversal Using Relays around NAT)
通俗理解:TURN 就像是"传话筒",当两个人无法直接对话时,通过中间人传话
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 客户端 A TURN 服务器 客户端 B │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 无法直接│ │ 中转站 │ │ 无法直接│ │
│ │ 连接 B │ │ │ │ 连接 A │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ │ "帮我转发数据给 B" │ │ │
│ │ ─────────────────────────────>│ │ │
│ │ │ "这是 A 发来的数据" │ │
│ │ │ ─────────────────────────────>│ │
│ │ │ │ │
│ │ │ "这是 B 发来的数据" │ │
│ │ <─────────────────────────────│ <─────────────────────────────│ │
│ │ "收到 B 的数据" │ │ │
│ ▼ ▼ ▼ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
TURN 的作用:
1. 当 P2P 连接无法建立时,提供中转服务
2. 保证 100% 的连通性
3. 适用于严格的 NAT/防火墙环境
5.2 TURN 认证机制
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ TURN 认证流程 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
TURN 需要认证,防止滥用:
┌─────────────────┐ ┌─────────────────┐
│ 客户端 │ │ TURN 服务器 │
│ │ │ │
│ 用户名: user1 │ │ 预配置: │
│ 密码: pass123 │ │ user1/pass123 │
│ │ │ │
│ 1. 请求分配端口│ │ │
│ (带认证信息) │ ──────────────────>│ │
│ │ │ │
│ │ │ 验证用户名密码 │
│ │ │ 计算 HMAC │
│ │ │ │
│ │ 2. 分配成功 │ │
│ │ 端口: 60000 │ │
│ │ <──────────────────│ │
│ │ │ │
└─────────────────┘ └─────────────────┘
本项目中的 TURN 认证配置:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ // stun_turn.go │
│ turnServer, err := turn.NewServer(turn.ServerConfig{ │
│ Realm: "videocall", │
│ AuthHandler: func(username string, realm string, srcAddr net.Addr) ([]byte, bool) {│
│ if username == gConfig.Turn.Username { │
│ key := turn.GenerateAuthKey(username, realm, gConfig.Turn.Password) │
│ return key, true │
│ } │
│ return nil, false │
│ }, │
│ }) │
└─────────────────────────────────────────────────────────────────────────────────────────┘
5.3 本项目中的 TURN 配置
kotlin
// PeerConnectionManager.kt 中的 TURN 配置
turnConfig?.let { turn ->
if (turn.optBoolean("enabled", false)) {
val server = turn.optString("server", "")
val port = turn.optInt("port", 3479)
val username = turn.optString("username", "")
val password = turn.optString("password", "")
val turnUrl = "turn:$server:$port"
iceServers.add(
PeerConnection.IceServer.builder(turnUrl)
.setUsername(username)
.setPassword(password)
.createIceServer()
)
Log.i(TAG, "[webrtc] 添加TURN服务器: $turnUrl")
}
}
6. NAT 类型与穿透策略
6.1 NAT 类型分类
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ NAT 类型详解 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ Full Cone NAT (完全锥形 NAT) │
│ │
│ 特点:一旦内部地址端口映射到外部地址端口,任何外部主机都可以发送数据 │
│ │
│ 通俗理解:像是一个公开的信箱,任何人都可以往里投信 │
│ │
│ 内部地址:端口 外部地址:端口 │
│ 192.168.1.100:5000 → 203.0.113.10:12345 │
│ │
│ 任何外部主机 → 203.0.113.10:12345 → 192.168.1.100:5000 ✅ │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ Restricted Cone NAT (限制锥形 NAT) │
│ │
│ 特点:只有内部主机曾经发送过数据的外部主机才能发送数据回来 │
│ │
│ 通俗理解:像是一个有门禁的小区,只有你联系过的人才能进来 │
│ │
│ 内部先发送数据给 A → A 可以回复 │
│ 内部未发送数据给 B → B 不能发送数据进来 ❌ │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 3️⃣ Port Restricted Cone NAT (端口限制锥形 NAT) │
│ │
│ 特点:只有内部主机曾经发送过数据的外部主机和端口才能发送数据回来 │
│ │
│ 通俗理解:像是一个更严格的门禁,不仅认人还认门 │
│ │
│ 内部发送给 A:5000 → A:5000 可以回复 │
│ 内部发送给 A:5000 → A:5001 不能回复 ❌ │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 4️⃣ Symmetric NAT (对称型 NAT) │
│ │
│ 特点:对不同的目标地址,会分配不同的外部映射端口 │
│ │
│ 通俗理解:像是有多个分机,打给不同的人用不同的分机号 │
│ │
│ 内部发送给 A → 映射为 203.0.113.10:12345 │
│ 内部发送给 B → 映射为 203.0.113.10:12346 (不同端口!) │
│ │
│ 这导致 STUN 获取的地址对 B 来说可能不正确 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
6.2 NAT 穿透策略
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ NAT 穿透策略 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ NAT 组合 │ 穿透方法 │ 成功率 │ 说明 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Cone + Cone │ STUN 即可 │ 高 │ 双方都能获取正确的公网地址 │
│ Cone + Symmetric │ STUN + ICE │ 中 │ 可能需要 prflx 候选者 │
│ Symmetric + Symmetric│ 需要 TURN │ 低 │ 很难直连,需要中转 │
│ 严格防火墙 │ 必须用 TURN │ - │ 只能中转 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
穿透优先级:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 优先尝试 host 候选者 │
│ - 同一局域网内可能直接连通 │
│ - 通俗理解:先看看是不是邻居 │
│ │
│ 2️⃣ 尝试 srflx 候选者 │
│ - 通过 STUN 获取公网地址 │
│ - 通俗理解:用公网地址试试 │
│ │
│ 3️⃣ 尝试 prflx 候选者 │
│ - 意外发现的地址 │
│ - 通俗理解:对方告诉我的新地址 │
│ │
│ 4️⃣ 最后使用 relay 候选者 │
│ - 通过 TURN 中转 │
│ - 通俗理解:实在不行就找中间人 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
7. 本项目 ICE 实现
7.1 IceCandidateManager 实现
kotlin
// IceCandidateManager.kt
class IceCandidateManager {
private val pendingIceCandidates = mutableListOf<IceCandidate>()
private val localIceCandidates = mutableListOf<IceCandidate>()
var isRemoteDescriptionSet = false
var onIceCandidateReady: ((String) -> Unit)? = null
// 添加本地 ICE 候选者
fun onLocalIceCandidate(candidate: IceCandidate) {
localIceCandidates.add(candidate)
// 转换为 JSON 格式发送给对端
val candidateJson = JSONObject().apply {
put("sdpMid", candidate.sdpMid)
put("sdpMLineIndex", candidate.sdpMLineIndex)
put("candidate", candidate.sdp)
}
onIceCandidateReady?.invoke(candidateJson.toString())
logCandidateInfo(candidate, "本地")
}
// 处理远程 ICE 候选者
fun handleRemoteIceCandidate(candidateJson: String) {
val candidates = parseRemoteIceCandidates(candidateJson)
candidates.forEach { candidate ->
if (isRemoteDescriptionSet) {
// 远程描述已设置,直接添加
peerConnectionManager?.addIceCandidate(candidate)
} else {
// 远程描述未设置,先缓存
addPendingIceCandidate(candidate)
}
}
}
// 处理缓存的候选者
fun processPendingIceCandidates(peerConnectionManager: PeerConnectionManager) {
pendingIceCandidates.forEach { candidate ->
peerConnectionManager.addIceCandidate(candidate)
}
pendingIceCandidates.clear()
}
}
7.2 ICE 候选者交换流程
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 本项目 ICE 交换流程 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
客户端 A 信令服务器 客户端 B
│ │ │
│ 1. setLocalDescription() 后开始收集 ICE │ │
│ │ │
│ 2. 收集到 host 候选者 │ │
│ onIceCandidate 回调 │ │
│ ─────────────────────────────────────────────────>│ │
│ │ 转发 │
│ │ ───────────────────────────────>│
│ │ │
│ 3. 收集到 srflx 候选者 │ │
│ ─────────────────────────────────────────────────>│ │
│ │ ───────────────────────────────>│
│ │ │
│ 4. 收集完成 (COMPLETE) │ │
│ 发送 endOfCandidates 信号 │ │
│ ─────────────────────────────────────────────────>│ │
│ │ ───────────────────────────────>│
│ │ │
│ 5. B 收到候选者后: │ │
│ - 如果远程描述已设置 → 直接添加 │ │
│ - 如果远程描述未设置 → 缓存等待 │ │
│ │ │
▼ ▼ ▼
7.3 ICE 连接状态处理
kotlin
// WebRTCClient.kt 中的 ICE 状态处理
peerConnectionManager?.apply {
onIceConnectionChange = { state ->
Log.i(TAG, "[webrtc] ICE连接状态: $state")
onConnectionStateChanged?.invoke(state)
when (state) {
PeerConnection.IceConnectionState.CONNECTED -> {
// 连接成功
Log.i(TAG, "[webrtc] ICE连接成功")
}
PeerConnection.IceConnectionState.FAILED -> {
// 连接失败
Log.e(TAG, "[webrtc] ICE连接失败")
}
PeerConnection.IceConnectionState.DISCONNECTED -> {
// 连接断开
Log.w(TAG, "[webrtc] ICE连接断开")
}
else -> {}
}
}
}
8. Trickle ICE
8.1 什么是 Trickle ICE
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Trickle ICE 机制 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
传统 ICE(等待所有候选者):
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 客户端 A │
│ │ │
│ ├── 收集 host 候选者... 等待 │
│ ├── 收集 srflx 候选者... 等待 │
│ ├── 收集 relay 候选者... 等待 │
│ │ │
│ └── 全部收集完成,一次性发送所有候选者 │
│ │
│ 问题:需要等待所有候选者收集完成,连接建立慢 │
│ 通俗理解:等所有快递都到了才一起发货,太慢了 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
Trickle ICE(逐步发送):
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 客户端 A │
│ │ │
│ ├── 收集到 host 候选者 → 立即发送 ────────────────────────────────────────> 对方 │
│ │ 通俗理解:本地地址找到了,先发过去 │
│ │ │
│ ├── 收集到 srflx 候选者 → 立即发送 ────────────────────────────────────────> 对方 │
│ │ 通俗理解:公网地址找到了,再发过去 │
│ │ │
│ └── 收集到 relay 候选者 → 立即发送 ────────────────────────────────────────> 对方 │
│ 通俗理解:中转地址找到了,最后发过去 │
│ │
│ 优势:可以立即开始连接检查,加快连接建立速度 │
│ 通俗理解:有一个地址就发一个,不用等 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
8.2 本项目中的 Trickle ICE 实现
kotlin
// IceCandidateManager.kt
fun onLocalIceCandidate(candidate: IceCandidate) {
// 每收集到一个候选者就立即发送
localIceCandidates.add(candidate)
val candidateJson = JSONObject().apply {
put("sdpMid", candidate.sdpMid)
put("sdpMLineIndex", candidate.sdpMLineIndex)
put("candidate", candidate.sdp)
}
// 立即回调发送
onIceCandidateReady?.invoke(candidateJson.toString())
}
// 收集完成时发送结束信号
fun onLocalIceCandidatesComplete() {
val endSignal = JSONObject().apply {
put("endOfCandidates", true)
}
onIceCandidateReady?.invoke(endSignal.toString())
}
9. ICE 调试技巧
9.1 日志分析
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 日志分析 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
本项目中的 ICE 日志:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ [webrtc] 添加STUN服务器: stun:videocall.itgcs.tech:3478 │
│ [webrtc] 添加TURN服务器: turn:videocall.itgcs.tech:3479 │
│ [ice][本地] 候选 | 类型: host | IP: 192.168.1.100 | 端口: 5000 │
│ [ice][本地] 候选 | 类型: srflx | IP: 203.0.113.10 | 端口: 12345 │
│ [webrtc][observer] ICE收集状态变化: COMPLETE │
│ [webrtc][observer] ICE连接状态变化: CHECKING │
│ [webrtc][observer] ICE连接状态变化: CONNECTED │
└─────────────────────────────────────────────────────────────────────────────────────────┘
关键检查点:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 1️⃣ 检查 ICE 服务器配置 │
│ - STUN/TURN 服务器地址是否正确? │
│ - TURN 用户名密码是否正确? │
│ │
│ 2️⃣ 检查候选者收集 │
│ - 是否收集到 host 候选者? │
│ - 是否收集到 srflx 候选者?(STUN 工作正常) │
│ - 是否收集到 relay 候选者?(TURN 工作正常) │
│ │
│ 3️⃣ 检查连接状态 │
│ - 是否进入 CHECKING 状态? │
│ - 是否最终 CONNECTED? │
│ - 如果 FAILED,检查候选者交换是否完整 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
9.2 常见问题排查
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 常见问题排查 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
问题 1: ICE 状态一直是 CHECKING,无法 CONNECTED
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - 候选者没有正确交换 │
│ - NAT 类型导致无法直连 │
│ - 防火墙阻止 UDP 通信 │
│ │
│ 排查方法: │
│ 1. 检查双方是否都收到了对方的候选者 │
│ 2. 检查是否有 srflx 或 relay 候选者 │
│ 3. 尝试配置 TURN 服务器 │
│ │
│ 通俗理解:双方都在尝试,但找不到对方的位置 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
问题 2: 只收集到 host 候选者
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - STUN 服务器不可达 │
│ - STUN 服务器配置错误 │
│ - 网络阻止了 STUN 请求 │
│ │
│ 排查方法: │
│ 1. 检查 STUN 服务器地址和端口 │
│ 2. 使用工具测试 STUN 服务器连通性 │
│ 3. 检查防火墙设置 │
│ │
│ 通俗理解:STUN 服务器没响应,不知道公网地址 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
问题 3: ICE 连接经常断开重连
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - 网络不稳定 │
│ - NAT 映射超时 │
│ - ICE 连接保活失败 │
│ │
│ 排查方法: │
│ 1. 检查网络质量 │
│ 2. 配置 ICE 保活参数 │
│ 3. 考虑使用 TURN 中转提高稳定性 │
│ │
│ 通俗理解:连接不稳定,经常掉线 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
10. ICE 最佳实践
10.1 配置建议
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 配置最佳实践 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 同时配置 STUN 和 TURN
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - STUN 用于获取公网地址,成本低 │
│ - TURN 作为备用,保证连通性 │
│ - 通俗理解:先尝试自己解决,实在不行找中间人 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2️⃣ 使用 Trickle ICE
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - 加快连接建立速度 │
│ - 边收集边发送边检查 │
│ - 通俗理解:有一个地址就试一个,不用等 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
3️⃣ 正确处理候选者时机
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - 远程描述设置后才能添加远程候选者 │
│ - 使用缓存机制处理提前到达的候选者 │
│ - 通俗理解:等对方准备好了,再告诉他你的地址 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4️⃣ 监控 ICE 状态
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - 记录 ICE 状态变化 │
│ - 记录使用的候选者类型 │
│ - 记录连接建立时间 │
│ - 通俗理解:记录连接过程,方便排查问题 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
10.2 性能优化
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 性能优化建议 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 减少 ICE 收集时间
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - 使用就近的 STUN/TURN 服务器 │
│ - 配置合理的超时时间 │
│ - 通俗理解:服务器越近,响应越快 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2️⃣ 优先使用直连
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - host 候选者优先级最高 │
│ - 同一局域网内直连延迟最低 │
│ - 通俗理解:能直连就直连,最快 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
3️⃣ 合理使用 TURN
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - TURN 会增加延迟和带宽成本 │
│ - 只在必要时使用 │
│ - 通俗理解:中转是最后的选择 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
11. 参考资料
11.1 相关 RFC 文档
| RFC 编号 | 标题 | 说明 |
|---|---|---|
| RFC 8445 | Interactive Connectivity Establishment (ICE) | ICE 协议规范 |
| RFC 8489 | STUN | STUN 协议规范 |
| RFC 8656 | TURN | TURN 协议规范 |
| RFC 8839 | SDP Offer/Answer Procedures for ICE | ICE 的 SDP 处理 |
11.2 本项目相关文件
| 文件路径 | 说明 |
|---|---|
| IceCandidateManager.kt | ICE 候选者管理,包含收集、缓存、发送 |
| PeerConnectionManager.kt | PeerConnection 管理,ICE 服务器配置 |
| WebRTCClient.kt | WebRTC 客户端,ICE 状态处理 |
| stun_turn.go | 服务端 STUN/TURN 服务器实现 |
本系列文章:
【P2P音视频通信系统】之方案架构详解
【P2P音视频通信系统】之呼叫完整时序图
【P2P音视频通信系统】之STUN服务详解
【P2P音视频通信系统】之TURN 服务详解
【P2P音视频通信系统】WebRTC ICE 候选类型详解:对等反射候选者(Peer Reflexive Candidate)
【P2P音视频通信系统】WebRTC 之 SDP 详解