Flutter 音视频通话集成实战:WebSocket 做信令,WebRTC 传音视频,附详细事件时序图
适合读者:
想把聊天和音视频通话结合起来,但不想一开始就陷入太多底层细节的 Flutter 初学者。
演示
视频太大了,放不上去,所以用了两个截图

先看整体思路
这个项目里的简单聊天音视频集成,核心只有两句话:
- WebSocket 做信令
- WebRTC 传音视频
再直白一点:
- WebSocket 负责告诉对方"现在该做什么"
- WebRTC 负责真正把声音和画面传过去
所以这里不是二选一,而是配合使用。
为什么不用 WebSocket 直接传图片或视频帧
一句话结论:
技术上可以,工程上不适合。
原因很简单:
- WebSocket 适合传文本、JSON、状态、信令
- 视频通话是持续不断的音频帧和视频帧传输
- 真正的实时媒体传输,更适合交给 WebRTC
所以在这个项目里:
- WebSocket 不传真正的音视频内容
- WebSocket 只传
callInvite / callAccept / webrtcOffer / webrtcAnswer / webrtcIceCandidate
先看事件流程图
这次不按左右时序展开,直接按事件从上往下看。你只需要顺着往下读,就能知道每一步是谁发事件、谁收事件、业务层调用了什么方法、RTC 又做了什么动作。
- 主叫发起通话
发出事件: 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 句话
- 先用
callInvite和callAccept把"要不要接通话"说清楚 - 再用
webrtcOffer和webrtcAnswer把"媒体怎么连"协商清楚 - 最后靠
webrtcIceCandidate和onTrack让真实音视频跑起来
当前项目里的 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. 当前项目直接依赖
flutter_webrtc:https://pub.dev/packages/flutter_webrtcweb_socket_channel:https://pub.dev/packages/web_socket_channel
2. 协议概念补充
- WebRTC 官方概览:https://webrtc.org/getting-started/overview
说明:
本文的 Flutter 插件接入、接口调用、项目落地方式,优先以
flutter_webrtc和web_socket_channel为依据;
Offer / Answer / ICE Candidate / PeerConnection这些协议层概念,补充参考 WebRTC 官方文档即可。
作者 : 911hzh
邮箱 : 911hzh@gmail.com
需要demo:点个关注,私信
