一、从一次真实掉线说起

我有个朋友,平时喜欢在地铁上跟人视频通话。他用的是一款基于 WebRTC 做的在线会议 App。每次地铁到站,隧道里信号差,他习惯性地把 Wi-Fi 关掉,让手机自动切到蜂窝网络。按理说现在的手机网络切换已经很成熟了,但他发现一个非常恼人的问题——只要一切换网络,视频通话大概率就会卡住几秒钟,然后画面上出现“连接已断开,正在重连……”,有时候直接失败,需要重新拨号。他问我:这事儿到底有没有救?

答案是有救,而且不只是有救,很多大厂早就用一套成熟机制在解决这个问题。核心思路是:不重新拨号,而是在原通话基础上,重新协商一条新的通信链路,让通话无缝过渡。这套机制就是 ICE Restart,也就是 ICE 重启动。但要让重启动真正管用,你得先理解它背后的状态机,以及什么时候触发才最合适。这篇文章就是用最简单的大白话,把这条技术路线完整的讲清楚。

二、掉线的本质:网络切换后“地址”变了

2.1 先搞清楚 WebRTC 通信的基本模型

两台手机要视频通话,A 和 B 之间传输的音视频数据走的是点对点链路,也就是不经过服务器中转,直接从一个设备发到另一个设备。但问题是,A 的地址和 B 的地址怎么互相知道?这就需要一个叫信令服务器的角色来转达各自的“通信地址”。

而“通信地址”在 WebRTC 的世界里不只是一个 IP 加端口,它叫 ICE Candidate,本质上就是你设备对外暴露的可达地址。一台手机在 Wi-Fi 下有一个内网私网地址,运营商还可能给你分配一个公网映射地址。切到蜂窝网络之后,原来的私网地址失效,公网映射地址也变了。

2.2 为什么切换网络后链路直接断了

当 A 从 Wi-Fi 切到 4G/5G,A 的 IP 变了,但 B 那边还按老地址发数据,数据包就会发到无人的荒野上。B 收不到 A 的新数据,就认为链路故障,于是进入重连流程。如果代码里没有处理 ICE Restart,那这个重连就是瞎等,等不到任何新地址,最后只能超时断开。

换句话说,你看到的现象是“通话断线”,但本质上是你切换网络后没有告诉对方“我的新地址是什么”,对方还在用旧地图找路,当然找不到。

2.3 一个更扎心的事实

还有一层很多人不知道的:手机系统在切换网络时,并不会立刻向应用层发送一个“我切换网络了”的通知,它只会默默地把连接迁移过去。你打开微信、刷网页,感觉不到变化,因为这些应用是基于 TCP 或者 UDP 普通发包,服务端只要能收到新数据包就能自动适应。但 WebRTC 不是这样,它建立的是长连接,而且点对点传输双方都有固定的地址绑定关系,一旦 IP 变了,这个绑定关系就彻底失效。

所以不能指望系统帮你解决问题,必须自己发现、自己处理。

三、认识 RTCPeerConnection 状态机

3.1 状态机是什么

RTCPeerConnection 是 WebRTC 中负责建立和维护媒体连接的核心对象。它内部有一堆状态属性,其中最核心的两个是 connectionState 和 iceConnectionState。它们就是整个连接的“体检报告单”,告诉你当前链路是否健康、有没有问题。

很多开发者只关心 onicecandidate 或者 onaddstream,对状态属性几乎不关注。其实,状态机才是最容易帮你定位问题的工具。我写代码时几乎一半精力都在观察状态变化。

3.2 connectionState 的六个状态

RTCPeerConnection.connectionState 是一个字符串枚举,包含六个取值:

  • new:刚创建对象,还没开始连接。
  • connecting:正在建立连接,比如正在进行 ICE 协商。
  • connected:已经建立了点对点连接,媒体可以正常传输。
  • disconnected:连接暂时不通,但是系统还没判定彻底失败,还有自动恢复的可能。
  • failed:连接彻底失败,不会再自动恢复,必须手动干预。
  • closed:连接被主动关闭。

其中你重点关心的是 disconnected 和 failed。disconnected 意味着还有救,failed 意味着你必须出手干预。

3.3 iceConnectionState 的七个状态

iceConnectionState 属性更细致,有七个状态:

  • new:ICE 代理刚创建。
  • checking:正在检查候选地址对是否可用。
  • connected:选出了一对可用的候选地址,媒体可以发送。
  • completed:ICE 检查全部结束,已经选出最优地址对。
  • disconnected:某个地址对开始收不到数据,但还有其他备选路径。
  • failed:所有路径都失败,没有可用的候选地址对。
  • closed:ICE 代理已关闭。

这里的 disconnected 很微妙,它可能只是暂时的网络抖动,也可能马上要断。而 failed 就是彻底凉了。

3.4 用代码观察状态变化

建议你在开发阶段,先把这套状态日志挂上,这在定位问题的时候能省很多时间。下面这个示例就是用 JavaScript 写的状态监听代码,完整展示状态变化过程:

// 技术栈:纯 JavaScript(浏览器原生 WebRTC API)

// 创建 RTCPeerConnection 实例
// 这里没有传入 ICE 服务器配置,真实环境中通常需要 STUN 服务器
const pc = new RTCPeerConnection();

// 监听整个连接层级的整体状态变化
// 用 console.info 打印,方便和 iceConnectionState 对比观察
pc.addEventListener('connectionstatechange', () => {
  console.info('[State] connectionState 变成了:', pc.connectionState);

  // 如果变成了 failed,说明连接已经彻底断了
  // 这时候必须触发 ICE 重启动,否则通话无法恢复
  if (pc.connectionState === 'failed') {
    console.warn('连接失败,准备触发 ICE 重启');
  }
});

// 监听 ICE 层的连接状态变化
// 大多数情况下,问题先出现在 ICE 层,随后才会反映到 connectionState 上
pc.addEventListener('iceconnectionstatechange', () => {
  console.info('[ICE] iceConnectionState 变成了:', pc.iceConnectionState);

  // 注意:这里不要一看到 disconnected 就触发重启
  // 因为可能过几百毫秒就自动恢复了
  // 如果立即重启,反而可能打断恢复过程
  if (pc.iceConnectionState === 'disconnected') {
    console.warn('ICE 暂时断开,持续观察中,不急于重启');
  }

  // 真正的严重后果是这个
  // 说明所有候选地址都试过了,没有一条路能通
  if (pc.iceConnectionState === 'failed') {
    console.error('ICE 协商彻底失败,必须触发重启');
  }
});

这里的 console.infoconsole.warn 只是示例用途。真在项目里,你要考虑把这些状态上报到监控平台,这样出现了线上故障你也能第一时间知道。

3.5 状态机之间是怎么协作的

简单说,iceConnectionState 是底层运输队的状况,connectionState 是整条运输大动脉的状况。ICE 层先出问题,然后影响上层。它们的关系很像马路上某一段路塌方了,底层看这段路是堵塞的,上层看的是整条交通路线都断了。

理解了这套状态关系,你就理解了触发 ICE 重启动的基本逻辑:当底层 ICE 层已经 failed,或者上层连接已经 failed,就说明当前地址对已经不可用,必须换新地址对重新协商。

四、ICE 重启动的底层逻辑

4.1 什么是 ICE 重启动

ICE(Interactive Connectivity Establishment)是 WebRTC 用来打通点对点连接的协议。它负责收集候选地址、探测可用性、选择最优路径。

ICE 重启动,本质上是把之前协商出来的所有旧地址作废,重新执行一轮 ICE 收集和探测流程,并在现有的 RTCPeerConnection 对象上重新完成一次 offer/answer 协商。关键是,它不需要重新创建 RTCPeerConnection,也不需要重新建立媒体轨道,所以音视频数据可以被完整保留下来,最终实现不中断通话。

4.2 触发方式一:修改 ufrag 和 pwd

ICE 协商双方在这轮协商中会携带两个关键标志:ufrag(用户名片段)和 pwd(密码)。只要任意一方改掉这两个值,对方就知道:“哦,你这是在要求重启 ICE 协商。”

所以触发 ICE 重启动最简单的方式,就是调用 createOffer() 方法,并在选项里传入 iceRestart: true,然后重新执行一次 offer/answer 交换。

4.3 触发方式二:手动修改描述对象的参数

另一种方式是自己改 Session Description,比如:

const offer = await pc.createOffer();
offer.sdp = offer.sdp.replace(/a=ice-ufrag:(.*)/, 'a=ice-ufrag:新的值');
// 重新设置本地描述并发送给对方
await pc.setLocalDescription(offer);

这种方式比较暴力,容易出问题。实际项目里还是优先使用 iceRestart: true,因为浏览器内部会自动帮你去调整 ufrag 和 pwd。

4.4 触发 ICE 重启动后发生了什么

触发之后,会有一个明显的现象:iceConnectionState 状态先变成 disconnectedfailed,然后通过 createOffer 产生一个新的 SDP Offer,这个 Offer 里带上了新的 ICE 参数,再通过信令通道发给对端。对端收到后,回一个 Answer,两边重新进行候选地址的收集和连通性检测。整个过程跟最初建立连接很像,但复用已有的媒体通道,因此实际上音频和视频的传输不会被中断。

我来用一个简单的生命周期图来说明整个过程:

初始连接建立成功(connected)
        ↓
网络切换发生,原 IP 失效
        ↓
ICE 层探测到候选地址不可达(disconnected)
        ↓
持续一段时间仍无法恢复(failed)
        ↓
触发 ICE Restart(createOffer + iceRestart: true)
        ↓
新 SDP 通过信令服务器发给远端
        ↓
远端返回 Answer,双方重新执行 ICE 探测
        ↓
新的候选地址对可用(connected)
        ↓
通话继续,体验上几乎没有中断

这个流程就是解决“切网不掉线”的核心原理。

五、完整修复实战:一套代码跟到底

光讲理论不够用,我们直接写一段完整的 JavaScript 代码,把 ICE 重启动的全部逻辑串起来。这段代码适合运行在浏览器环境,用的也是浏览器原生的 WebRTC API。

5.1 先搭一个最小通话框架

假设你已经建立了两个 RTCPeerConnection 之间的连接,这里为了演示,我直接模拟一个本地回环连接,也就是同一个页面里创建两个 PeerConnection,互相传输音视频,然后展示如何触发 ICE 重启。

// 技术栈:JavaScript(浏览器原生 WebRTC API)

// 创建两个 PeerConnection,模拟两端设备
// pc1 相当于本地设备,pc2 相当于远端设备
const pc1 = new RTCPeerConnection();
const pc2 = new RTCPeerConnection();

// 给两端挂上 ICE 候选日志,方便观察状态流转
pc1.addEventListener('icecandidate', (event) => {
  // 正常情况下,需要把候选通过信令服务器发给对端
  // 这里因为是本地回环,直接塞给 pc2 即可
  if (event.candidate) {
    pc2.addIceCandidate(event.candidate).catch(console.error);
  }
});

pc2.addEventListener('icecandidate', (event) => {
  if (event.candidate) {
    pc1.addIceCandidate(event.candidate).catch(console.error);
  }
});

// 监听 pc1 的 ICE 状态变化
pc1.addEventListener('iceconnectionstatechange', () => {
  console.log('pc1 ICE 状态:', pc1.iceConnectionState);
});

// 监听 pc1 的 connectionState 变化
pc1.addEventListener('connectionstatechange', () => {
  console.log('pc1 连接状态:', pc1.connectionState);
});

5.2 加入媒体轨道

WebRTC 通话必须有媒体流。为了演示方便,这里用 getUserMedia 获取摄像头或者麦克风,再把轨道加进连接里。

// 技术栈:JavaScript(浏览器原生 WebRTC API)

// 获取本地媒体流
// 为了演示,这里只拿音频,实际视频通话时可以用 video: true
const mediaStream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: false
});

// 把获取到的音轨和视轨添加到 pc1 中
mediaStream.getTracks().forEach((track) => {
  pc1.addTrack(track, mediaStream);
});

// pc2 负责接收轨道
pc2.addEventListener('track', (event) => {
  console.log('pc2 收到媒体轨道:', event.track.kind);
});

5.3 建立初始连接

接下来,让 pc1 创建 Offer,pc2 创建 Answer,建立初始连接。这个过程通话就通了。

// 技术栈:JavaScript(浏览器原生 WebRTC API)

// pc1 发起 offer
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(offer);

// pc2 返回 answer
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(answer);

此刻,pc1 和 pc2 已经处于 connected 状态了。假设现在发生了网络切换,手机从 Wi-Fi 切到了蜂窝网络,pc1 的 IP 地址变了。这时候我们需要探测到异常,并触发 ICE 重启动。

5.4 网络切换的检测机制

有一个非常关键的问题:怎么知道网络切换了?最简单的方法是通过 navigator.connectionchange 事件,或者自己监听网络信息变化。

// 技术栈:JavaScript(浏览器原生 WebRTC API)

// 监听网络类型变化
// 比如从 wifi 变为 cellular 时,触发回调
navigator.connection.addEventListener('change', () => {
  const networkType = navigator.connection.type;
  console.log('网络切换了,新的网络类型:', networkType);

  // 网络切换后,大概率 IP 变了
  // 我们主动触发一轮 ICE 重启
  handleNetworkSwitch();
});

但要注意,这个 API 不是所有浏览器都支持,而且它只能告诉你网络类型变了,不能确切告诉你 IP 是否真的变了。更稳妥的办法是把网络切换信号和状态机判断结合起来。

5.5 结合状态机的判断策略

最保险的触发策略是:网络切换事件 + ICE 状态持续异常,两者同时满足才算真正需要重启。

// 技术栈:JavaScript(浏览器原生 WebRTC API)

// 用一个标志位记录是否发生了网络切换
let isNetworkSwitched = false;

navigator.connection.addEventListener('change', () => {
  isNetworkSwitched = true;
  console.log('检测到网络切换,等待 ICE 状态反馈');
});

// 在 ICE 状态变化里做深度判断
pc1.addEventListener('iceconnectionstatechange', () => {
  const state = pc1.iceConnectionState;
  console.log('当前 ICE 状态:', state);

  // 只有同时满足两个条件才触发重启
  // 条件1:确实发生过网络切换
  // 条件2:ICE 状态已经进入 failed
  // 这两个条件同时成立,基本可以确定旧地址已经不可用
  if (isNetworkSwitched && state === 'failed') {
    console.log('网络已切,ICE 已失败,触发重启');
    restartIce();
  }
});

// 触发 ICE 重启动的核心方法
async function restartIce() {
  console.log('开始 ICE 重启动...');

  // 关键动作:创建 offer 时传入 iceRestart: true
  const newOffer = await pc1.createOffer({ iceRestart: true });

  // 设置本地描述
  await pc1.setLocalDescription(newOffer);

  // 通过信令通道把新 offer 发给对端
  // 这里因为是本地回环,直接设置给 pc2
  await pc2.setRemoteDescription(newOffer);

  // 对端创建 answer 并返回
  const newAnswer = await pc2.createAnswer();
  await pc2.setLocalDescription(newAnswer);
  await pc1.setRemoteDescription(newAnswer);

  console.log('ICE 重启动完成');
}

这段代码就是完整的修复策略核心。实际项目里,信令的传输要用 WebSocket 或者其他消息通道发送给对端,但逻辑一模一样。

5.6 更完整的版本

实际开发中,你还需要处理几种复杂情况。比如:在 disconnected 状态下等待一段时间再判断,避免误触发;在 failed 后立即重启,但在重启之前要清理定时器;还要防止重复触发。下面给出一个更完整的封装:

// 技术栈:JavaScript(浏览器原生 WebRTC API)

class WebRTCConnection {
  constructor() {
    this.pc = new RTCPeerConnection();
    this.hasNetworkSwitched = false;
    this.restarting = false;
    this.failTimer = null;

    // 绑定状态监听
    this.pc.addEventListener('iceconnectionstatechange', () => {
      this.onIceStateChange();
    });
  }

  // 网络切换时调用
  onNetworkChange() {
    this.hasNetworkSwitched = true;
    console.log('检测到网络切换');
  }

  // ICE 状态变化处理器
  onIceStateChange() {
    const state = this.pc.iceConnectionState;
    console.log('ICE 状态:', state);

    // 如果已经在重启过程中,不再重复触发
    if (this.restarting) return;

    // 对于 disconnected 状态,给出一个缓冲期
    // 很多时候网络切换后几百毫秒内会自动恢复
    if (state === 'disconnected') {
      // 设置一个 3 秒的定时器
      // 如果 3 秒后状态仍然没有恢复,再触发重启
      clearTimeout(this.failTimer);
      this.failTimer = setTimeout(() => {
        if (this.pc.iceConnectionState === 'disconnected') {
          console.log('状态持续异常,主动重启');
          this.restartIce();
        }
      }, 3000);
      return;
    }

    // 如果已经彻底失败,立即重启
    if (state === 'failed') {
      clearTimeout(this.failTimer);
      this.restartIce();
      return;
    }

    // 状态恢复到 connected,说明链路已经恢复
    // 重置网络切换标记
    if (state === 'connected') {
      this.hasNetworkSwitched = false;
    }
  }

  // 执行 ICE 重启动
  async restartIce() {
    if (this.restarting) return;
    this.restarting = true;

    try {
      console.log('执行 ICE 重启');
      const offer = await this.pc.createOffer({ iceRestart: true });
      await this.pc.setLocalDescription(offer);

      // 通过信令发送给对端
      sendSignalingMessage({
        type: 'offer',
        sdp: offer
      });
    } catch (error) {
      console.error('ICE 重启失败:', error);
    } finally {
      // 延迟重置 restarting 状态,避免并发触发
      setTimeout(() => {
        this.restarting = false;
      }, 1000);
    }
  }

  // 对端回传 answer 后,设置远端描述
  async handleAnswer(answer) {
    await this.pc.setRemoteDescription(answer);
    console.log('已经设置远端描述,重启完成');
  }
}

5.7 信令消息怎么设计

上面代码里出现了一些函数,比如 sendSignalingMessage,虽然这只是一个抽象函数,但信令消息的设计值得单独说一下。

当触发 ICE 重启动后,你生成的 Offer 是一个 SDP 字符串。你需要把这个 SDP 字符串通过 WebSocket 发送给对端。对端收到后,创建 Answer,再回传给你。消息格式可以设计成这样:

{
  "type": "ice-restart-offer",
  "sdp": "v=0 ... 此处省略具体的 SDP 文本 ..."
}
{
  "type": "ice-restart-answer",
  "sdp": "v=0 ... 此处省略具体的 SDP 文本 ..."
}

注意,这里有sdpFragmentssession id 等细节,但大体上设计思路就是复用原来的信令通道,把新生成的 SDP 传过去即可。

这里还需要提醒一下,在信令交互过程中,要处理好历史候选和新候选的并存。一旦触发 ICE Restart,两端必须把之前积累的所有 ICE Candidate 列表清空,很多实现里由 setRemoteDescription 自动完成。如果收到旧的候选地址,直接忽略即可。

5.8 对端也要做同样的处理

有些人以为只有网络切换的那一端需要处理,实际上对端也要处理 ICE 状态变化。因为对端收到新的 Offer 后,也需要重新进行 ICE 探测。如果对端代码写得不支持这种动态变化,一样会失败。所以两端都要具备响应 ICE Restart 的能力。

对端要做的就是收到一个带有新的 ICE 参数的 Offer 时,正常 setRemoteDescription,然后 createAnswer,返回。

六、应用场景与方案优缺点分析

6.1 典型应用场景

ICE 重启动的应用场景非常广,主要集中在移动端通话场景,比如:

  • 手机地铁通勤时,Wi-Fi 切换到蜂窝网络。
  • 家里 Wi-Fi 断网,手机自动切到热点。
  • 公司网络和访客网络切换,IP 网段变化。
  • 运营商 NAT 映射失效,即使 IP 没变也可能断链。
  • 长时间静默后,某些路由器把 UDP 映射表清掉了,导致原本通畅的地址对失效。

6.2 方案优点

这个方案最大的优点是不需要用户干预,也不需要重建连接,整个重协商过程很快。正常情况下,ICE 重启动的耗时在几百毫秒到几秒之间,远小于重新拨号的数秒钟。而且音频和视频的发送可以做到几乎不中断,最终体验很接近“无缝切换”。

第二个优点是实现成本不算太高,只需要在原有 WebRTC 逻辑上增加 ICE 状态监听和信令消息类型扩展即可,不需要重写底层传输逻辑。

6.3 方案缺点

它不能完全百分百保证所有场景都恢复。比如如果真的没有一个可用于通信的新地址(比如设备被限制在了一个完全隔离的网络里),那重启动再多次也没用。

另外一个缺点是,如果信令服务器本身不可用,那 ICE 重启动的 Offer 和 Answer 就传不出去,自然无法协商。因此重度依赖信令通道的稳定性。

七、注意事项与最佳实践

7.1 不要对 disconnected 状态过度反应

我前面已经提到一次,但这里要再强调一遍。disconnected 状态太常见了,网络抖动几十毫秒就可能触发。你如果一看到这个状态就执行 ICE 重启,反而会造成无谓的媒体中断。最佳实践是设置一个延迟定时器,等 3 到 5 秒再判断,如果状态还没有恢复,再触发重启。

7.2 避免重复触发

ICE 重启动不是轻量操作,它涉及新一轮的 ICE 收集和探测,同时也会打断正在传输的媒体流。如果你在 restarting 状态下又收到了新的失败事件,一定要有锁机制拦住。

7.3 一定要配合退化方案

ICE 重启动之后如果还是没有恢复,说明点对点路径可能完全不可达。这种情况下可以考虑使用 TURN 服务器做数据中转,也就是通过服务器转发数据。开发时一定要配置好 TURN,这样在极端网络环境下还有最后一张王牌可用。

TURN 服务器的配置方式和 STUN 差别不大,格式如下:

{
  "iceServers": [
    {
      "urls": "turn:turn.example.com:3478",
      "username": "user",
      "credential": "password"
    }
  ]
}

7.4 注意信令消息的时序

ICE 重启动过程中,信令消息有可能乱序。比如旧的 Offer 还没处理完,新的 Offer 就到了。所以两端都要维护一个简单的信令序号或者版本号,对过期的消息直接忽略,不然会互相覆盖描述对象,导致协商失败。

7.5 及时清理监听器

如果你在页面上创建了多个 RTCPeerConnection 对象,网络切换事件触发后,所有连接都会尝试重启。这时要统一管理,避免多个连接同时重启导致信令通道拥堵。同时,页面销毁时要及时清理事件监听器。

7.6 用日志留存现场

不管代码写得多漂亮,没有日志的调试都是瞎猜。我强烈建议把每一次 ICE 状态变化、每一次网络切换、每一次重启动的触发参数都输出成结构化日志。这样线上出了问题,你能快速定位是主动触发还是被动触发,是状态机判断错了还是信令丢包了。

八、文章总结

手机在 Wi-Fi 和蜂窝网络之间切换,对普通应用来说无所谓,但对 WebRTC 这类点对点实时通信来说就是一次“搬家”,搬家之后地址全变了,旧链路自然就断了。解决思路不是重建一套通话系统,而是利用现有的 RTCPeerConnection 状态机,在准确判断“链接已经彻底无效”的条件下,触发一轮 ICE 重启动,在新的网络环境下重新协商出一套可用的链路。

这篇文章把核心链条拆开来看:先从状态机入手建立了对连接状态的感知能力,然后顺着 ICE 重启动的触发原理,走到了完整实践代码,最后还分析了适用场景和注意事项。希望你看完之后再遇到类似问题,不再慌张,而是拿代码说话。

说到底,WebRTC 这套机制虽然复杂,但只要你理解了状态机,并且掌握 ICE 重启动的触发时机,就相当于握住了一把解决“切网断线”问题的钥匙。以后再遇到朋友抱怨视频又断了,你也可以拍拍胸脯说:小问题,ICE 重启动走一轮就好。