React Native + WebRTC 实战:云对讲模块设计

技术栈:React Native 0.75 + WebRTC + 海康 SDK + 阿里云 Push
适用:可视对讲、视频通话、远程开门

一、功能是什么

业主在小区的楼下门口机家里室内机上按呼叫键 → 云端平台转发 → 业主手机 App 收到来电、响铃 → 接听 → 视频通话 → 远程开门。

核心场景

场景说明
来电通知门口机呼叫 → App 收到来电(实时)
视频通话接听后可看门口画面、可对讲
远程开门通话中点"开锁"按钮,门口机开门
多人同步A 用户和 B 用户都收到呼叫,一方接听后另一方自动退出

关键要求

  • 实时性极强(30s 超时自动挂断)
  • 杀进程也能收到来电(业主在抖音也能收到门口呼叫)
  • 状态一致性(被他人接听、对方挂断、手势返回都要正确退出)

二、用到的技术栈

技术角色为什么用它
阿里云 Push + APNs/厂商通道系统级推送,杀进程兜底唯一能在 App 进程被杀时送达通知的通道
WebSocket #1(自家后端)云对讲来电通知通道App 在前台时实时推送 {type:1, 3} 来电事件
URL: wss://CLOUD_IP/newhomewss/ws/app/{token}
WebSocket #2(思悦云平台)思悦 WebRTC 的信令通道交换 SDP offer/answer + ICE 候选
URL: wss://webrtc.insprid.cn/new_wss?token=xxx&type=access
海康萤石 SDK(CloudOpenSDK)海康对讲的音视频 + 控制闭源私有协议,老小区硬件兼容
WebRTC + ICE/STUN/TURN思悦对讲的音视频传输P2P 传输,延迟 50-200ms,自带编解码 + NAT 穿透
React Native 原生模块桥接把海康 SDK 暴露给 JS海康 SDK 是 OC/Java 代码,JS 无法直接调用,必须桥接
双重锁(global.callStatus + global._isNavigatingToCall)防重复跳转阿里云 Push 和 WebSocket #1 都可能触发跳转
setInterval 轮询(2 秒)检测通话状态变化海康 SDK 是同步拉取接口,没事件订阅

💡 重要:项目里的 WebSocket 是两个不同的连接

  • WebSocket #1 = 通知通道(自家后端,海康+思悦共用,因为它只发"有呼叫"事件)
  • WebSocket #2 = 思悦 WebRTC 信令通道(思悦云平台,只思悦在用

三、核心架构:两个云对讲供应商共存

项目里同时对接了两家云对讲供应商(海康萤石 + 思悦),不同小区用的硬件不同,App 层要兼容两套。

3.0 两个供应商对比

维度海康萤石思悦
协议海康私有协议(闭源 SDK)WebRTC(开源标准)
音视频传输通过 CloudOpenSDK 内部实现WebRTC P2P(client ↔ client)
信令通道海康 SDK 内部处理(不需要 JS 关心)额外开一个 WebSocketwss://webrtc.insprid.cn/new_wss
通知通道⚠️ 同一个 WebSocket(自家后端)⚠️ 同一个 WebSocket(自家后端)
鉴权accessToken(萤石 SDK)token(思悦云)
设备标识doorDeviceSerial + deviceCodepid + room
适用小区老小区(已有海康设备)新小区(思悦设备)

3.0.1 共用 WebSocket #1(关键设计)

                      ┌─────────────────────────┐
                      │   自家后端 WebSocket #1    │
                      │   wss://CLOUD_IP/        │
                      │   newhomewss/ws/app/     │
                      └─────────┬───────────────┘
                                │
                  ┌─────────────┴─────────────┐
                  │                            │
                  ▼                            ▼
         ┌──────────────────┐         ┌──────────────────┐
         │ 海康小区          │         │ 思悦小区          │
         │ 收到 type=1      │         │ 收到 type=3      │
         │ → 跳 HikPlayer   │         │ → 跳 SiruisCall  │
         └──────────────────┘         └──────────────────┘

为什么两个供应商共用一个 WebSocket?

因为 WebSocket 通知通道只关心"有呼叫"事件,不关心具体是哪个供应商。
前端收到 {type:1}{type:3} 后,根据 videoCallPlatform 字段路由到对应页面。

// src/pages/Home/index.js resolveCallNavigation
const platform = callingDevice.videoCallPlatform;
switch (platform) {
  case 3:  // 思悦
    return {
      screenName: 'SiruisCloudCall',   // WebRTC 通话页
      params: {pid, token, room, ...},  // 思悦专属参数
    };
  default:  // 海康(默认)
    return {
      screenName: 'HikCloudTalkPlayer', // 海康通话页
      params: {device, user},           // 海康专属参数
    };
}

3.0.2 JS 层面对的差异

虽然后端选了 Sea Talk 或海康,对 JS 代码来说只是路由到不同的页面

// src/pages/Home/index.js
{modalProps?.type == 'AC' && <AC {...modalProps} />}
{modalProps?.type == 'Curtain' && <Curtain {...modalProps} />}

云对讲的路由也是一样的模式:

// 同一个 WebViewModal,根据 type 渲染不同组件
<DeviceModal>
  {modalProps?.type === 'HikCloudTalkPlayer' && <HikCloudTalkPlayer {...modalProps} />}
  {modalProps?.type === 'SiruisCloudCall' && <SiruisCloudCall {...modalProps} />}
</DeviceModal>

四、核心技术原理(面试必备)

4.1 WebRTC 是什么

WebRTC = Web Real-Time Communication

  • W3C 标准,浏览器/客户端实时通信 API
  • 三大核心能力:
    • MediaStream:获取摄像头/麦克风
    • RTCPeerConnection:建立 P2P 连接(处理 NAT 穿透)
    • RTCDataChannel:P2P 数据传输

WebRTC 不带信令:SDP/ICE 候选交换需要应用层自己实现(用 WebSocket)。

4.2 WebRTC vs WebSocket 对比

维度WebRTCWebSocket
模型P2P(点对点)C/S(客户端-服务器)
传输内容音视频流 + 数据文本/任意数据
编解码✅ Opus/H.264 自带❌ 不带
NAT 穿透✅ 内置 STUN/TURN❌ 没有
延迟50-200ms200ms+(经服务器)
服务器压力低(不转发音视频)高(所有数据过服务器)
适用音视频通话聊天/股票/推送

4.3 双通道推送原理

┌─────────────────────────────────────────────────────────────────┐
│              双通道协作:阿里云 Push + WebSocket                     │
└─────────────────────────────────────────────────────────────────┘

         门口机呼叫
              ↓
         云端平台
              ↓
       ┌──────┴──────┐
       ↓             ↓
   阿里云 Push    公司后端
   (系统级通知)    (WebSocket 推送)
       ↓             ↓
   ① 杀进程也能收   ② 仅 App 进程存活时收
   ③ 触发通知横幅   ③ 触发 onMessage 回调
       ↓             ↓
       └──────┬──────┘
              ↓
      App 收到 + 跳来电页

关键设计:通知只是"敲门",把 App 拉回前台。真正的跳转靠 App 启动后 WebSocket 重连 + 主动查询。

4.4 为什么 2 秒轮询

海康 SDK 的 getDeviceStatus同步拉取接口,没有持久化的"状态变化"事件订阅。

所以setInterval 自己实现轮询:

  • 每 2 秒调一次 getRecordTalkDevicesStatus(id)
  • 检查 connected(是否被他人接听)、endTime(是否已结束)
  • 检测到变化 → 自动退出

为什么不担心性能问题? 2 秒间隔足够用户感知不到,且服务端只是简单查询。

4.5 通话状态机

通话有 3 个状态、4 个转换路径,用状态机图一图讲清楚:

初始化

门口机按呼叫

用户点接听

用户拒接 / 60s 超时

别人先接听

用户挂断

App 进后台

对方挂断

离开

离开

NoCall

Calling

Answering

状态码
iOS: 2 / ring
Android: ring

状态码
iOS: 3 / onCall
Android: onCall


五、实现架构

接听

拒接

挂断

开锁

门口机按呼叫键

云端平台
海康/思悦

阿里云 Push
系统通知栏

WebSocket
自家后端

App 收到通知
杀进程也能送达

App 在前台
onMessage 回调

用户点通知
冷启动 App

WebSocket 重连
checkCallStatus

立即跳转
checkCallStatus

导航到来电页

响铃界面
setInterval 轮询

用户操作

answer
+ startVisualTalk

reject

hangUp

doorOpen
远程开门

通话结束


六、核心实现:来电入口

6.1 checkCallStatus 核心代码

// src/pages/Home/index.js
const checkCallStatus = useCallback(async (userInfo) => {
  // ① 前置校验
  if (!userInfo?.phoneNumber) return;
  if (global.callStatus === true) return;        // 锁1:已在通话页
  if (global._isNavigatingToCall) return;       // 锁2:正在跳转

  try {
    // ② 拉设备状态
    const res = await getUserTalkDevicesStatus(userInfo.phoneNumber);
    const data = JSON.parse(res);

    // ③ 找正在响铃的设备
    const callingDevice = data.find(item => item.callState === 1);
    if (!callingDevice) {
      if (IS_ANDROID) HikVideoTalkActivityModule.cancelAllNotification();
      return;
    }

    // ④ 根据平台选目标
    const navigateInfo = resolveCallNavigation(callingDevice, userInfo);
    if (!navigateInfo) return;

    // ⑤ 二次校验(防重复)
    if (global.callStatus || global._isNavigatingToCall) return;

    // ⑥ 上锁 + 跳转
    global._isNavigatingToCall = true;
    setTimeout(() => {
      try {
        navigation.navigate(navigateInfo.screenName, navigateInfo.params);
      } finally {
        global._isNavigatingToCall = false;
      }
    }, 100);  // ← 100ms 让 navigator 准备
  } catch (e) {
    console.error('checkCallStatus failed:', e);
  }
}, [navigation]);

6.2 三个核心设计

设计 1:双重锁防重复跳转
global.callStatus = true;        // 通话页挂载时设置
global._isNavigatingToCall = true; // 跳转前设置
// 100ms 后跳转
setTimeout(() => {
  navigation.navigate(...);
  global._isNavigatingToCall = false;
}, 100);

为什么 100ms? 给 navigator 准备时间,给锁一个"观察窗口"防止并发。

设计 2:2 秒轮询检测通话状态
// HikCloudTalkPlayer.js componentDidMount
this.currCloudTalkStatus = setInterval(async () => {
  const data = JSON.parse(await getRecordTalkDevicesStatus(device.id));
  
  // 情况 1:被别人接听
  if (data.connected === 1 && data.employeeNo !== user.employeeNo) {
    Toast.show('该通话已被他人接听');
    this.leaveTalkPage();
  }
  // 情况 2:已结束
  else if (data.endTime) {
    Toast.show('该通话已结束');
    this.leaveTalkPage();
  }
}, 2000);
设计 3:AppState 监听防后台占用
// HikCloudTalkPlayer.js _handleAppStateChange
_handleAppStateChange = async (nextAppState) => {
  // 进后台 → 主动挂断
  if (curAppState === 'active' && nextAppState.match(/inactive|background/)) {
    const status = await this.getCloudTalkDeviceStatus();
    if ((IS_IOS && status === 2) || (IS_ANDROID && status === 'ring')) {
      this.rejectCalling();          // 响铃态 → 拒接
    } else if ((IS_IOS && status === 3) || (IS_ANDROID && status === 'onCall')) {
      this.hangUpCalling();          // 通话态 → 挂断
    }
  }
};

这段代码做了什么?

状态iOSAndroid检测到进后台时作用
响铃中(未接)2‘ring’rejectCalling()主动拒接,避免响铃挂后台
通话中3‘onCall’hangUpCalling()主动挂断,释放摄像头/麦克风

为什么要做?

iOS 在后台长时间占用摄像头,系统会强杀 App 释放硬件。这会导致:

  • 通话突然中断,对方黑屏
  • 门口机那边还不知道,30s 后才超时挂断
  • 业主体验极差

所以主动挂断 + 释放资源,让门口机立即收到 hangUp 信令。

iOS 还是 Android 都不行?

  • iOS:摄像头后台占用 → 系统强杀
  • Android:厂商杀进程很激进(华为 EMUI、小米 MIUI 等 5 分钟杀进程)

项目没做微信那种"后台悬浮通话",原因

  • iOS 需要 UIBackgroundModes: voip + PushKit 注册 VoIP Push(项目里只有 remote-notification
  • Android 需要前台服务 FOREGROUND_SERVICE 权限 + 启动前台服务
  • 业务场景不需要:云对讲是"看一眼就开门",默认 30s 超时
  • 改造工作量大,当前主动挂断是保守但可靠的方案

如果想改造支持后台通话(工作量 1-2 周):

平台改造
iOS1. Info.plistUIBackgroundModes: voip, audio
2. AppDelegate.m 注册 PKPushRegistry(PushKit)
3. 申请 VoIP Push 证书
4. 接听页加 PiP(画中画)支持
Android1. AndroidManifest.xmlFOREGROUND_SERVICE 权限
2. 写一个 ForegroundService 服务
3. 接听时 startForegroundService 带通知
4. 接听页加 PiP 支持

七、完整调用流程

7.1 来电通知流程(双通道并行)

门口机按呼叫键
   ↓
云端平台
   ├─→ 阿里云 Push(系统通知栏)   ← 即使杀进程也送达
   └─→ WebSocket 推 {type:1, ...}  ← App 在前台就实时
   ↓
App 收到:
   ├─ WebSocket onMessage → checkCallStatus → 跳转来电页
   └─ 通知点击 → 冷启动 App → WebSocket 重连 → 主动查询 → 跳转

7.2 通话中状态变化检测(2 秒轮询)

通话页挂载
   ↓
setInterval(2000ms) → getRecordTalkDevicesStatus
   ↓
   ├─ connected=1 且 employeeNo != 当前用户 → 被他人接听 → 自动退出
   ├─ endTime 存在 → 通话已结束 → 自动退出
   └─ 正常 → 继续轮询

7.3 接听/拒接/挂断/开锁

用户点"接听"按钮
   ↓
HikVideoTalkActivityModule.answer(serial, verifyCode, blockNo, floor, unitNo, roomNo)
   ↓
海康 SDK answer() + startVisualTalk()
   ↓
音视频流开始(RTCView 渲染)
   ↓
用户点"开锁"
   ↓
Api.doorOpen({deviceSerial, employeeNo, buildingId})  ← 后端开锁接口
   ↓
用户点"挂断"
   ↓
HikVideoTalkActivityModule.hangUp(serial, blockNo, ...)
   ↓
云端信令 + 本地 stopVisualTalk
   ↓
clearInterval(轮询) + navigation.goBack()

7.4 语音 vs 视频:两个供应商的本质差异

很多读者以为"WebRTC 就必须视频",其实 WebRTC 只是传输框架,音/视频可选。两个供应商对音视频的实现差异很大。

7.4.1 思悦(WebRTC)的音视频控制
// src/pages/CloudTalk/SiruisCloudCall.js
const stream = await mediaDevices.getUserMedia({
  video: false,           // ← 关键:业主端不发本地视频!
  audio: isAudioEnabled ? {
    echoCancellation: true,    // 回声消除
    noiseSuppression: true,    // 噪音抑制
  } : false,
});
维度思悦业主端思悦门口端
本地视频❌ 不发送-
本地音频✅ 发送(带回声消除)-
接收视频✅ 显示门口画面(RTCView 渲染 remoteStream-
接收音频✅ 听门口的声音-

思悦的设计哲学:业主只看门口画面,业主不露脸

  • 业主端video: false → 摄像头不开
  • RTCView渲染remoteStream → 业主能看到门口
7.4.2 海康(私有协议)的音视频控制
// iOS HikVideoTalkActivityModule.m
[CloudOpenSDK startVisualTalk:serial channelNo:1 verifyCode:verifyCode
                   completion:^(BOOL succeed, NSError *error) {
  // 启动视频通话
}];
维度海康业主端海康门口端
本地视频✅ 发送(门口机能看到业主)-
本地音频✅ 发送-
接收视频✅ 显示门口画面-
接收音频✅ 听门口的声音-

海康的设计哲学:双向视频(互相能看到对方)。

  • 调用 startVisualTalk → 视频 + 音频双向
7.4.3 两个方案对比
维度海康(闭源)思悦(WebRTC)
业主端摄像头✅ 开(默认)❌ 不开(video: false
业主本地视频流门口机看到业主画面
音频能力✅ 双向✅ 双向
业主显示门口画面
能否纯音频通话✅ 海康 SDK 支持 startVoiceTalk(项目未实现)✅ 默认就是"业主纯音频 + 门口视频"
7.4.4 能否完全关闭摄像头?
场景海康思悦
业主端关闭摄像头改用 startVoiceTalk(需改造)已经是这样了(默认 video: false
纯语音通话✅ 支持✅ 默认就是纯语音(业主端)
门口端没有摄像头❌ 大多门口机只有摄像头❌ 大多门口机只有摄像头
双向纯语音需海康云端配置需思悦云端配置

结论

  • 思悦:业主端默认纯音频(不露脸),门口端仍是视频(看门口)
  • 海康:默认双向视频,要改成"业主纯音频"需改海康 SDK 调用
  • 双方都可改成纯语音通话,工作量不大(1-2 天)

八、4 个关键技术参数

参数来源用途
doorDeviceSerial后端 API 返回(/home_user/{phone}萤石设备序列号,海康 SDK 用
deviceCode(verifyCode)后端 API 返回萤石设备验证码(6 位大写字母),海康 SDK 用
accessToken后端 API 返回(getCloudTalkUserInfo萤石 SDK 鉴权
pid/token/room(思悦)WebSocket type=3 消息携带思悦 WebRTC 信令的前置参数

WebSocket 消息 type

type含义
0被异地登录
1海康门口机呼叫
2室内机 app 呼叫门禁
3思悦门口机呼叫

九、其他项目想做这个功能怎么落地

9.1 技术选型建议

场景推荐方案
几百用户海康 SDK + 阿里云 Push + 阿里云 WebSocket
几万用户自研 WebRTC + 独立信令服务(WebSocket)+ CDN
跨国延迟敏感WebRTC P2P(不经过国内服务器)
已有 IM 系统直接复用 IM 的 WebSocket 做信令

9.2 协议设计

WebSocket 消息格式

{
  "type": 1,
  "deviceSerial": "D55138380",
  "deviceCode": "RGQSDA",
  "buildingId": 123,
  "employeeNo": "liuhao",
  "timestamp": 1692345678901
}

WebRTC 信令格式(SDP/ICE)

{
  "type": "offer",         // offer/answer/candidate
  "sdp": "v=0...",         // SDP 内容
  "candidate": "...",      // ICE 候选(可选)
  "from": "user_a",
  "to": "user_b"
}

9.3 实施步骤

第 1 步:选型
  - 简单方案:海康 SDK(闭源但稳)
  - 高定制方案:WebRTC + 自研信令服务(推荐)
  - ⚠️ 注意:来电通知通道**不是 MQTT**,是 WebSocket(自家后端)+ 阿里云 Push

第 2 步:服务端开发
  - 门口机呼叫 → 后端推送
  - WebSocket 服务(前台实时推送)
  - 思悦 WebRTC 信令服务(单独 WebSocket)
  - 阿里云 Push / APNs / 厂商通道配置

第 3 步:客户端推送
  - 阿里云 Push 集成
  - WebSocket 长连接
  - 自动重连(3 秒)

第 4 步:接听页开发
  - RTCView 渲染视频流
  - 接听/拒接/挂断/开锁
  - AppState 监听防后台
  - 2 秒轮询状态

第 5 步:双重锁防重复
  - global.callStatus
  - global._isNavigatingToCall
  - setTimeout(100ms) 包 navigate

第 6 步:双通道兜底
  - WebSocket 在前台实时推送
  - 通知点击后冷启动 + WebSocket 重连
  - 重连后立即查询 callState === 1

第 7 步:测试
  - 杀进程点通知能否跳页
  - 多人同时接听能否正确退出
  - 后台运行能否挂断

9.4 注意事项

注意点说明
WebRTC 需要 STUN/TURNNAT 穿透必须,自己搭 coturn 或用 Twilio
SDP 交换是异步的严格按 offer → answer → ICE 顺序
ICE 候选要缓冲SDP 没处理完就收到 ICE 候选,要 buffer
通话状态要用轮询兜底SDK 没事件订阅就自己轮询
后台必须主动挂断iOS 长时间占用摄像头会被强杀
状态码跨端不一致iOS 数字 1/2/3,Android 字符串 ‘idle’/‘ring’/‘onCall’
通话超时机制默认 30s,门口机会自动挂断
不要在 WebSocket 里发大数据音视频流走 P2P/WebRTC,不走 WebSocket

十、本项目特殊点(避免踩坑)

解决方案
重复跳转双重锁 + setTimeout(100ms)
iOS 后台被强杀AppState 监听主动挂断
状态码跨端不一致工具函数统一封装
被他人接听还在响铃2 秒轮询检测 employeeNo
杀进程收不到阿里云 Push + 厂商通道兜底
手势返回通话没断componentWillUnmount 清理资源
思悦 WebRTC 没信令用 WebSocket 单独做信令通道

十一、面试问答速记

Q1:为什么用 WebRTC 不用 WebSocket 做云对讲?

  • WebRTC 自带编解码(Opus/H.264)
  • 内置 STUN/TURN,NAT 穿透
  • P2P 传输,延迟低(50-200ms)
  • 服务器压力小(不转发音视频流)

Q2:双通道推送怎么协作?

  • 阿里云 Push(杀进程兜底)+ WebSocket(前台实时)
  • 通知对云对讲只是"敲门",把 App 拉回前台
  • 真正跳转靠 WebSocket 重连 + 主动查 callState

Q3:怎么避免重复跳转?

  • 双重锁:global.callStatus + global._isNavigatingToCall
  • setTimeout(100ms) 包 navigate,让 navigator 准备

Q4:杀进程后用户怎么收到呼叫?

  • 阿里云 Push 通过 APNs/厂商通道送达
  • 用户点通知 → 冷启动 App → WebSocket 重连 → 主动查 → 跳转

Q5:海康和思悦怎么共存?

  • 后端按房屋返回 videoCallPlatform 字段
  • 前端根据字段路由到 HikCloudTalkPlayerSiruisCloudCall
  • 共用一个 WebSocket 通知通道

十二、一句话总结

云对讲 = 双通道推送(Push + WebSocket)+ 海康 SDK/WebRTC 双方案 + 双重锁防重复跳转 + 2 秒轮询兜底 + AppState 防后台占用

十三、系统分层架构

整个项目的部署架构 4 层组成:

业主家里

第三方云服务

公司后端 ECS

客户端 App

HTTP

WebSocket

MQTT

原生调用

推送

上传文件

局域网

视频流

RN 业务层

withDevice HOC

NB MQTT SDK

Spring Boot API
9090

WebSocket
newhomewss

自建 MQTT Broker

阿里云 Push

海康萤石云

阿里云 OSS

路由器

智能家居主机

灯/空调/窗帘


系列文章:① 云对讲模块(本文) ② 智能家居模块

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。