Flutter 音视频通话集成实战:WebSocket 做信令,WebRTC 传音视频,附详细事件时序图

Flutter 音视频通话集成实战:WebSocket 做信令,WebRTC 传音视频,附详细事件时序图

适合读者:

想把聊天和音视频通话结合起来,但不想一开始就陷入太多底层细节的 Flutter 初学者。

演示

视频太大了,放不上去,所以用了两个截图


先看整体思路

这个项目里的简单聊天音视频集成,核心只有两句话:

  • WebSocket 做信令
  • WebRTC 传音视频

再直白一点:

  • WebSocket 负责告诉对方"现在该做什么"
  • WebRTC 负责真正把声音和画面传过去

所以这里不是二选一,而是配合使用。


为什么不用 WebSocket 直接传图片或视频帧

一句话结论:

技术上可以,工程上不适合。

原因很简单:

  • WebSocket 适合传文本、JSON、状态、信令
  • 视频通话是持续不断的音频帧和视频帧传输
  • 真正的实时媒体传输,更适合交给 WebRTC

所以在这个项目里:

  • WebSocket 不传真正的音视频内容
  • WebSocket 只传 callInvite / callAccept / webrtcOffer / webrtcAnswer / webrtcIceCandidate

先看事件流程图

这次不按左右时序展开,直接按事件从上往下看。你只需要顺着往下读,就能知道每一步是谁发事件、谁收事件、业务层调用了什么方法、RTC 又做了什么动作。

  1. 主叫发起通话

发出事件: callInvite

发送方方法: startOutgoingCall(roomId)

内部发送: _dispatch(callInvite)
2. 被叫收到来电

接收方方法: _handleSocketMessage() -> _onCalleeReceivedInvite()

结果: 进入 incoming 状态,等待用户接听
3. 被叫点击接听

发送方方法: acceptIncomingCall()

内部方法: _ensureRenderersReady() / _ensureLocalMedia() / _ensurePeerConnection()

RTC 方法: getUserMedia() / createPeerConnection()

最后发送事件: callAccept
4. 主叫收到 callAccept

接收方方法: _handleSocketMessage() -> _onCallerAcceptedByPeer()

内部方法: _ensureRenderersReady() / _ensureLocalMedia() / _ensurePeerConnection() / _createAndSendOffer()

RTC 方法: getUserMedia() / createPeerConnection() / createOffer() / setLocalDescription(offer)

最后发送事件: webrtcOffer
5. 被叫收到 webrtcOffer

接收方方法: _handleSocketMessage() -> _onCalleeReceivedOffer()

RTC 方法: setRemoteDescription(offer) -> createAnswer() -> setLocalDescription(answer)

最后发送事件: webrtcAnswer
6. 主叫收到 webrtcAnswer

接收方方法: _handleSocketMessage() -> _onCallerReceivedAnswer()

RTC 方法: setRemoteDescription(answer)

结果: SDP 协商完成
7. 双方持续触发 candidate 交换

发送方触发: peerConnection.onIceCandidate

发送事件: webrtcIceCandidate

接收方方法: _onRemoteIceCandidate()

RTC 方法: addCandidate(...)
8. 任意一方收到远端媒体流

触发回调: peerConnection.onTrack

界面处理: remoteRenderer.srcObject = event.streams.first

结果: 页面开始显示对方画面

看这张图时,只记住 3 句话

  1. 先用 callInvitecallAccept 把"要不要接通话"说清楚
  2. 再用 webrtcOfferwebrtcAnswer 把"媒体怎么连"协商清楚
  3. 最后靠 webrtcIceCandidateonTrack 让真实音视频跑起来

当前项目里的 WebSocket 事件,分别代表什么

这些事件定义在:

  • lib/base/model/socket/RTCVideoCallModels.dart

callInvite

  • 含义:主叫发起视频通话邀请
  • 发送方调用的方法:startOutgoingCall(...)
  • 接收方收到后调用:_handleSocketMessage() -> _onCalleeReceivedInvite(...)
  • 作用:让被叫进入来电状态

callAccept

  • 含义:被叫同意接听
  • 发送方调用的方法:acceptIncomingCall()
  • 接收方收到后调用:_handleSocketMessage() -> _onCallerAcceptedByPeer(...)
  • 作用:让主叫开始创建 Offer

callReject

  • 含义:被叫拒绝来电
  • 发送方调用的方法:rejectIncomingCall()
  • 接收方收到后调用:_handleSharedSignal(...)
  • 作用:结束本轮呼叫

callCancel

  • 含义:主叫在对方接听前取消通话
  • 发送方调用的方法:cancelOutgoingCall()
  • 接收方收到后调用:_handleSharedSignal(...)
  • 作用:让被叫停止等待

callHangup

  • 含义:任意一方主动挂断
  • 发送方调用的方法:hangup()
  • 接收方收到后调用:_handleSharedSignal(...)
  • 作用:结束当前通话

callBusy

  • 含义:对方当前忙线中
  • 发送方调用的方法:_onCalleeReceivedInvite(...) 内部在忙线时发送
  • 接收方收到后调用:_handleSharedSignal(...)
  • 作用:告诉主叫本轮通话无法继续

callTimeout

  • 含义:当前呼叫超时
  • 发送方:通常由服务端或会话控制逻辑产生
  • 接收方收到后调用:_handleSharedSignal(...)
  • 作用:结束本轮呼叫

callState

  • 含义:状态同步消息
  • 发送方:通常由服务端发出
  • 接收方收到后调用:_handleSharedSignal(...)
  • 作用:更新 sessionId / roomId / 状态文案

webrtcOffer

  • 含义:主叫创建出来的 Offer SDP
  • 发送方调用的方法:_createAndSendOffer()
  • 接收方收到后调用:_handleSocketMessage() -> _onCalleeReceivedOffer(...)
  • 作用:让被叫开始 setRemoteDescription(offer) 并生成 Answer

webrtcAnswer

  • 含义:被叫返回的 Answer SDP
  • 发送方调用的方法:_onCalleeReceivedOffer(...)
  • 接收方收到后调用:_handleSocketMessage() -> _onCallerReceivedAnswer(...)
  • 作用:让主叫完成 SDP 协商

webrtcIceCandidate

  • 含义:一条网络路径信息
  • 发送方调用的方法:peerConnection.onIceCandidate
  • 接收方收到后调用:_onRemoteIceCandidate(...)
  • 作用:把 candidate 加入连接,帮助双方真正连通

当前项目里的 RTC 方法,分别做什么

这些方法主要集中在:

  • lib/pages/ChatVideoPage/RTCVideoCallSessionStore.dart

startOutgoingCall({required String roomId})

  • 作用:主叫开始通话流程
  • 内部关键动作:发送 callInvite
  • 下一步:等待 callAccept

核心代码:

dart 复制代码
emit(
  state.copyWith(
    phase: RTCVideoCallPhase.outgoing,
    roomId: targetRoomId,
    clearPeerToken: true,
    statusMessage: '正在呼叫 room $targetRoomId 中的联系人...',
  ),
);
_dispatch(RTCVideoCallMessageType.callInvite, <String, dynamic>{
  'roomId': targetRoomId,
  'mediaType': 'video',
});

acceptIncomingCall()

  • 作用:被叫接听来电
  • 内部关键动作:
    • _ensureRenderersReady()
    • _ensureLocalMedia()
    • _ensurePeerConnection()
    • 发送 callAccept

核心代码:

dart 复制代码
await _ensureRenderersReady();
await _ensureLocalMedia();
await _ensurePeerConnection();
emit(state.copyWith(
  phase: RTCVideoCallPhase.connecting,
  statusMessage: '正在建立连接...',
));
_dispatch(RTCVideoCallMessageType.callAccept, <String, dynamic>{
  'sessionId': state.sessionId,
});

_ensureLocalMedia()

  • 作用:获取本地音视频流
  • 核心 RTC 方法:getUserMedia(...)

核心代码:

dart 复制代码
final MediaStream stream = await navigator.mediaDevices.getUserMedia(<String, dynamic>{
  'audio': true,
  'video': <String, dynamic>{
    'facingMode': 'user',
    'width': <String, dynamic>{'ideal': 1280},
    'height': <String, dynamic>{'ideal': 720},
  },
});
_localStream = stream;
localRenderer.srcObject = stream;

_ensurePeerConnection()

  • 作用:创建 RTCPeerConnection
  • 核心 RTC 方法:createPeerConnection(...)

核心代码:

dart 复制代码
final RTCPeerConnection peerConnection = await createPeerConnection(<String, dynamic>{
  'iceServers': <Map<String, dynamic>>[
    <String, dynamic>{'urls': 'stun:stun.l.google.com:19302'},
  ],
});

addTrack(...)

  • 作用:把本地音视频轨加入 RTCPeerConnection

核心代码:

dart 复制代码
for (final MediaStreamTrack track in stream.getTracks()) {
  await peerConnection.addTrack(track, stream);
}

_createAndSendOffer()

  • 作用:主叫创建 Offer 并发送
  • 核心 RTC 方法:
    • createOffer()
    • setLocalDescription(offer)

核心代码:

dart 复制代码
final RTCSessionDescription offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
_dispatch(RTCVideoCallMessageType.webrtcOffer, <String, dynamic>{
  'sessionId': state.sessionId,
  'to': state.peerToken,
  'sdp': offer.sdp,
});

_onCalleeReceivedOffer(...)

  • 作用:被叫处理 Offer 并返回 Answer
  • 核心 RTC 方法:
    • setRemoteDescription(offer)
    • createAnswer()
    • setLocalDescription(answer)

核心代码:

dart 复制代码
await _peerConnection!.setRemoteDescription(
  RTCSessionDescription(envelope.sdp!, 'offer'),
);
final RTCSessionDescription answer = await _peerConnection!.createAnswer();
await _peerConnection!.setLocalDescription(answer);
_dispatch(RTCVideoCallMessageType.webrtcAnswer, <String, dynamic>{
  'sessionId': envelope.sessionId ?? state.sessionId,
  'to': envelope.from,
  'sdp': answer.sdp,
});

_onCallerReceivedAnswer(...)

  • 作用:主叫处理远端 Answer
  • 核心 RTC 方法:setRemoteDescription(answer)

核心代码:

dart 复制代码
await _peerConnection!.setRemoteDescription(
  RTCSessionDescription(envelope.sdp!, 'answer'),
);
emit(state.copyWith(
  phase: RTCVideoCallPhase.connected,
  statusMessage: '视频通话已建立',
));

peerConnection.onIceCandidate

  • 作用:本地收集到 candidate 时触发
  • 附带动作:通过 WebSocket 发 webrtcIceCandidate

核心代码:

dart 复制代码
peerConnection.onIceCandidate = (RTCIceCandidate candidate) {
  if (candidate.candidate == null || candidate.candidate!.isEmpty) {
    return;
  }
  _dispatch(RTCVideoCallMessageType.webrtcIceCandidate, <String, dynamic>{
    'sessionId': state.sessionId,
    'to': state.peerToken,
    'candidate': candidate.candidate,
    'sdpMid': candidate.sdpMid,
    'sdpMLineIndex': candidate.sdpMLineIndex,
  });
};

_onRemoteIceCandidate(...)

  • 作用:把远端 candidate 加进本地连接
  • 核心 RTC 方法:addCandidate(...)

核心代码:

dart 复制代码
await _peerConnection!.addCandidate(
  RTCIceCandidate(
    envelope.candidate,
    envelope.sdpMid,
    envelope.sdpMLineIndex,
  ),
);

peerConnection.onTrack

  • 作用:远端媒体流真正到达时触发
  • 附带动作:把远端流绑定给 remoteRenderer

核心代码:

dart 复制代码
peerConnection.onTrack = (RTCTrackEvent event) {
  if (event.streams.isEmpty) {
    return;
  }
  remoteRenderer.srcObject = event.streams.first;
  emit(state.copyWith(
    phase: RTCVideoCallPhase.connected,
    statusMessage: '视频通话已建立',
  ));
};

这一句最关键:

onTrack 触发,通常就意味着你真的能看到对方画面了。


几个关键参数名,顺手记住

roomId

  • 当前通话属于哪个聊天房间

sessionId

  • 当前这一轮通话会话的唯一标识

sdp

  • Offer / Answer 的核心内容

candidate

  • 一条网络路径信息

sdpMid

  • candidate 属于哪条媒体描述

sdpMLineIndex

  • candidate 对应 SDP 中哪一段媒体信息

最后再记一句

如果你只记一句话,那就记这句:

先用 WebSocket 把"怎么连"说清楚,再让 WebRTC 去真正传音视频。

这就是一个简单聊天音视频集成的核心思路。

参考资料

1. 当前项目直接依赖

2. 协议概念补充

说明:

本文的 Flutter 插件接入、接口调用、项目落地方式,优先以 flutter_webrtcweb_socket_channel 为依据;
Offer / Answer / ICE Candidate / PeerConnection 这些协议层概念,补充参考 WebRTC 官方文档即可。

作者 : 911hzh
邮箱 : 911hzh@gmail.com
需要demo:点个关注,私信

相关推荐
里欧跑得慢16 小时前
15. Web可访问性最佳实践:让每个用户都能平等访问
前端·css·flutter·web
m0_7263658317 小时前
Ai漫剧系统 几分钟,让AI 把一篇小说变成了一部漫剧成片:从剧本到视频的全流程系统实现
人工智能·语言模型·ai作画·音视频
Lanren的编程日记19 小时前
Flutter 鸿蒙应用数据版本管理实战:版本记录+版本回退+版本对比,实现全链路数据版本控制
flutter·华为·harmonyos
非凡ghost21 小时前
可拓浏览器:给手机浏览器装上“外挂“!2W+拓展+AI搜索,玩出无限可能!
windows·智能手机·音视频·firefox
TimeAground21 小时前
WebSocket 与长连接:从协议握手到断线重连的完整实战
websocket
美狐美颜SDK开放平台1 天前
多场景美颜SDK解决方案:直播APP(iOS/安卓)开发接入详解
android·人工智能·ios·音视频·美颜sdk·第三方美颜sdk·短视频美颜sdk
2501_921649491 天前
企业定制金融数据 API:从架构设计到 Python 接入实战
大数据·开发语言·python·websocket·金融·量化
ai产品老杨1 天前
深度解析:基于国产化异构计算的 AI 视频管理平台架构——从 GB28181 接入到 NPU 边缘推流的解耦实践
人工智能·架构·音视频
watson_pillow1 天前
音视频相关基础知识储备入门-字幕
音视频