远程监控只是冰山一角,真正复杂的 underneath 是声网 SDK 的使用。
RtcManager是这个项目里最重的类,900 多行代码,承载了视频通话、语音通话、远程监控三种场景。这篇博客拆解几个我踩过最深的坑。
一、为什么是"双引擎"?
声网的 SDK 拆成两个独立部分:
-
RTC Engine(
agora_rtc_engine):负责音视频媒体流,比如加入频道、推流、拉流、摄像头控制。 -
RTM(
agora_rtm):负责信令通道,比如发起呼叫、挂断、切换音视频、传输自定义指令(控制老人端摄像头转向、开关灯等)。
为什么不直接用 RTC 的媒体通道传信令?因为媒体通道需要"加入频道"才能用,但呼叫流程是"先协商好要不要通话,再加入频道"。所以信令必须独立于媒体流。
架构图:
┌─────────────────────────────────────────┐
│ RtcManager │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ RtcEngine │ │ RtmManager │ │
│ │ (媒体流) │ │ (信令) │ │
│ │ │ │ │ │
│ │ - join │ │ - login │ │
│ │ - publish │ │ - sendMsg │ │
│ │ - subscribe │ │ - receiveMsg │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘
RtcManager 作为统一出口,把两个引擎包装起来,对上层暴露 play()、acceptInCall()、cancelCall() 这类业务语义化方法。
二、初始化:双引擎的"握手"顺序
初始化顺序很关键,错一步就可能让某个回调永远不触发:
void init(RtcUser user) async {
if (haveInit) return;
localUser = user;
if (_engine == null) {
eventHandler = RtcEngineEventHandler(
onTokenPrivilegeWillExpire: (connection, token) {
_updateToken();
},
onJoinChannelSuccess: (connection, elapsed) {
LogUtil.i(tag, 'onJoinChannelSuccess');
if (connection.channelId != localUser.user.channel &&
connection.channelId?.contains("remote_channel") != true) {
// 频道不一样表示是呼入,标记为被叫
asCalledJoined = true;
}
eventCallback?.joinSuccess();
rtmManager.rtmLogin.remoteControllerListener?.localResult(0);
},
// ... 其他回调
);
RtcEngineContext context = RtcEngineContext(
appId: BaseConfig.rtcAppId,
);
_engine = createAgoraRtcEngineEx();
await _engine!.initialize(context);
_engine!.registerEventHandler(eventHandler);
_onInitSuccessCallBack?.call();
}
haveInit = true;
// 声网账号登录(RTM)
rtmManager.login(localUser);
// 呼叫流程的状态回调
rtmManager.setCallback((RtmCallState state, ...) {
// ...
});
}
关键点:
-
haveInit标志位:防止重复初始化。声网 SDK 重复initialize会抛异常。 -
RtcEngineEventHandler必须在registerEventHandler之前组装好:否则在 register 之后到 setEventHandler 之间的回调会丢失。 - RTM 登录放在 RTC 初始化之后:因为 RTM 收到呼叫消息后,可能需要立即用 RTC 加入频道,所以 RTC 必须先就绪。
-
asCalledJoined标志位:这是被叫场景的关键判断。主叫和被叫都会触发onJoinChannelSuccess,但被叫加入的频道是主叫指定的,跟自己的localUser.user.channel不一样。用这个差异来区分角色。
三、Token 自动续期:通话突然断开的元凶
声网的 Token 是有时效的,默认 1 小时。如果通话过程中 Token 过期,会被直接踢出频道。这个坑不踩一次根本想不到。
监听 onTokenPrivilegeWillExpire 回调,在 Token 即将过期前续期:
void _updateToken() async {
if (curChannel != null) {
await reqToken(curChannel!);
if (curToken != null) {
_engine?.renewToken(curToken!);
}
}
}
reqToken 是从自己的服务器获取新 Token:
Future<void> reqToken(String channelId) async {
try {
final respList = await Future.wait([
DeviceServiceApi.getCallToken(channelId, 1),
MeServiceApi.fetchUserInfo()
]);
if (respList[0].isSuccess == true && respList[0] is AgoraToken) {
curToken = (respList[0] as AgoraToken).token;
}
if (respList[1].isSuccess == true && respList[1] is UserModel) {
final user = (respList[1] as UserModel).user;
UserManager.instance.updateLocalUser(user);
}
} catch (_) {
curToken = null;
}
}
这里有个性能优化点:用 Future.wait 并行请求 Token 和用户信息。 通话初始化时这两个接口都要调,串行的话要等 2 倍 RTT,并行只要 1 倍。在弱网环境下,这个优化能让"点击通话→看到画面"的时间缩短几百毫秒。
四、网络质量监控:用 Toast 频控避免骚扰
声网会持续回调 onNetworkQuality,如果不加节流,弱网下用户会被 Toast 刷屏。
final LimitCheckModel _limitCheckModel = LimitCheckModel(5000);
onNetworkQuality: (connection, remoteUid, txQuality, rxQuality) {
bool isLocal = remoteUid == 0;
List<QualityType> badList = [
QualityType.qualityBad,
QualityType.qualityVbad
];
List<QualityType> failList = [
QualityType.qualityDown,
];
if (badList.contains(txQuality) || badList.contains(rxQuality)) {
if (_limitCheckModel.checkLimit()) {
return; // 5秒内已经提示过,跳过
}
ToastUtil.show(
isLocal ? AppString.localNetworkBad : AppString.remoteNetworkBad
);
} else if (failList.contains(txQuality) || failList.contains(rxQuality)) {
eventCallback?.onError(ErrorCodeType.errNetDown, isLocal ? '0' : '-1');
}
}
LimitCheckModel(5000) 是个简单的频控工具:5 秒内只允许一次提示。这个 5 秒不是随便选的——太短(比如 1 秒)用户还是会觉得吵,太长(比如 30 秒)又可能错过关键告警。5 秒是测试下来的平衡点。
remoteUid == 0 是声网约定:本地用户的 remoteUid 永远是 0。用这个来区分"是我网络差还是对方网络差",给用户更精确的提示。
五、加入频道前的状态机:防止"加入中"被中断
joinChannel 是异步的,而且内部要调 reqToken 接口。如果用户在这期间点了挂断,会出现什么?
- Token 接口返回了,但用户已经退出 → 不应该继续 joinChannel
- 已经 joinChannel 了,但用户已经退出 → 需要 leaveChannel
这就是 _callJoinStatus 状态机的用途:
enum CallJoinsStatus {
none, // 空闲
joining, // 加入中
}
Future<void> joinChannel(String channelId, ...) async {
_callJoinStatus = CallJoinsStatus.joining;
_limitCheckModel.reset();
if (!_networkConnected) {
eventCallback?.onError(ErrorCodeType.errNetDown, "network err");
return;
}
if (curToken == null) {
await reqToken(channelId); // 异步等待 Token
}
if (curToken == null) {
eventCallback?.onError(ErrorCodeType.errNetDown, "network err");
return;
}
// 关键 check:等待 Token 期间状态可能已经变了
if (_callJoinStatus != CallJoinsStatus.joining) {
return;
}
// ... 配置频道参数
// 二次 check:配置期间状态也可能变了
if (_callJoinStatus != CallJoinsStatus.joining) {
leaveChannel();
return;
}
_engine!.joinChannel(
token: token,
channelId: channelId,
uid: int.parse(localUser.user.id),
options: options
).catchError((err) {
eventCallback?.onError(ErrorCodeType.errFailed, "join fail");
});
}
两次 check,一次在 await reqToken 之后,一次在配置频道参数之后。 每一次 await 都是一个"挂起点",挂起期间外部状态可能改变,必须重新校验。
leaveChannel 里把状态重置回 none:
void leaveChannel() async {
_callJoinStatus = CallJoinsStatus.none;
curChannel = null;
curToken = null;
// ... 清理资源
}
六、视频通话 vs 远程监控:同一套引擎,不同配置
视频通话和远程监控都用 RTC Engine,但配置完全不同:
if (type == CallType.video) {
await openVideo();
_engine!.setVideoEncoderConfiguration(const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 1920, height: 1080),
frameRate: 15,
// 带宽受限时,视频编码时同时降低视频帧率和视频分辨率
degradationPreference: DegradationPreference.maintainBalanced
));
} else if (type == CallType.controller) {
// 远程监控:只需要音频通话 + 查看远端视频流
await _engine?.enableVideo();
await _engine?.enableLocalVideo(false); // 不推本地视频
await _engine?.muteLocalVideoStream(false);
await _engine?.muteAllRemoteVideoStreams(true); // 监控前先静音,等指令
await _engine?.muteAllRemoteAudioStreams(true);
options = ChannelMediaOptions(
channelProfile: channelProfile,
clientRoleType: clientRoleType
);
// 远程监控用低分辨率低帧率,省带宽
_engine!.setVideoEncoderConfiguration(const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 120, height: 120),
frameRate: 5
));
}
几个关键差异:
| 维度 | 视频通话 | 远程监控 |
|---|---|---|
| 本地视频 | 推流 | 不推流 |
| 分辨率 | 1920x1080 | 120x120 |
| 帧率 | 15fps | 5fps |
| 降级策略 | maintainBalanced | - |
视频通话追求清晰度,所以用 1080P + 帧率分辨率同时降级的策略;远程监控只看老人状态,120x120 + 5fps 完全够用,还能大幅省流量。
muteAllRemoteVideoStreams(true) 这个调用一开始很反直觉——监控不就是看视频吗?为什么先 mute?原因是老人端设备开启摄像头需要时间,在摄像头开启前订阅视频流会报错。所以先 mute,等收到老人端的"已就绪"信号再 unmute。
七、振铃管理:iOS 不可不说的事
振铃看似简单,实际是 iOS 上最大的坑。
void _playRing() async {
if (isPlayRing) return;
JpushManager.instance.clearCallNotification(); // 关闭通知里的振铃
isPlayRing = true;
await checkEnableAudio();
final filePath = await FileUtil.getAssetFileDocName(BaseConfig.ringFile);
try {
// 振铃是还原为默认音频路由
_engine?.setDefaultAudioRouteToSpeakerphone(true);
// 处理 ios 部分机型没有铃声
_engine?.setParameters("\"che.audio.keep.audiosession\":true}");
_engine?.startAudioMixing(filePath: filePath, loopback: true, cycle: -1);
} catch (e) {
LogUtil.e(tag, "e:${e.toString()}");
}
}
两个关键调用:
-
setDefaultAudioRouteToSpeakerphone(true):振铃时一定要走扬声器,否则用户听不到。但通话接通后可能要切回听筒,所以leaveChannel里要还原配置。 -
setParameters("\"che.audio.keep.audiosession\":true}"):这是声网内部参数,意思是"保持 AVAudioSession 不被释放"。iOS 上如果 audio session 被系统回收,铃声会突然消失。这个参数让声网在 AVAudioSession 上保持持有。
还有一个隐藏问题:从后台返回前台时,铃声会断。 因为 iOS 后台不允许 App 持续播放音频。所以 JpushManager 里有这么一段:
// 清除呼叫振铃消息,避免从后台返回前台时,呼叫页面的振铃没有立即响起
Future<void> clearCallNotification() async {
final list = await notificationPlugin.getActiveNotifications();
for (final notification in list) {
if (notification.id != null &&
(["240718", "124124", "156123"].contains(notification.channelId) ||
["call"].contains(notification.category))) {
notificationPlugin.cancel(notification.id!);
}
}
}
收到呼叫推送时,本地通知会用自己的铃声(系统级,能后台播放)。返回前台时,要先清掉这个通知,否则 App 内的振铃和通知的振铃会同时响,体验崩坏。
八、录音录像:MediaRecorder 的状态管理
声网提供 MediaRecorder 用于录像,startAudioRecording 用于录音。但两者互斥——不能同时录。
Future<bool> startRecordAudio(String path) async {
if (_recordType == RecordType.video) {
stopRecordVideo(); // 录像中要先停掉
} else if (_recordType == RecordType.audio) {
return false; // 已经在录音了
}
_recordType = RecordType.none;
final config = AudioRecordingConfiguration(
filePath: path,
encode: true,
fileRecordingType: AudioFileRecordingType.audioFileRecordingMixed,
recordingChannel: 2,
);
try {
await _engine?.startAudioRecording(config);
_recordType = RecordType.audio;
return true;
} catch (e) {
LogUtil.e(tag, "startRecordAudio e:$e");
return false;
}
}
用 _recordType 枚举做互斥锁,比直接用 boolean 清晰得多。 三态:none / audio / video,任何状态切换都先 check 当前态,再切换。
录像还有最大时长限制:
Future<bool> startRecordVideo(String channel, String uid, String path,
recorderObserver, {int maxDurationMs = 2 * 60 * 1000}) async {
// ...
MediaRecorderConfiguration config = MediaRecorderConfiguration(
storagePath: path,
maxDurationMs: maxDurationMs, // 默认 2 分钟
recorderInfoUpdateInterval: 1000,
);
// ...
}
为什么限制 2 分钟? 不是技术限制,是产品决策。录像文件会占用用户存储,老人端设备容量有限。2 分钟够记录关键事件,又不会让存储爆掉。
九、资源释放的顺序:每一行都有理由
leaveChannel 是整个类里最讲究的方法,每一行顺序都经过踩坑:
void leaveChannel() async {
_callJoinStatus = CallJoinsStatus.none;
curChannel = null;
curToken = null;
useSpeakerphone = false;
asCalledJoined = false;
// 1. 先关闭振铃
await _stopRing();
// 2. 关闭音频
await checkDisableAudio();
// 3. 还原声网参数
_engine?.setParameters("\"che.audio.keep.audiosession\":false}");
_engine?.disableVideo();
// 4. dispose 视图控制器
_localVideoViewController?.dispose();
_remoteVideoViewController?.dispose();
_localVideoViewController = null;
_remoteVideoViewController = null;
// 5. 离开频道
_engine?.leaveChannel(options: const LeaveChannelOptions());
// 6. 关闭录音录像
stopRecordAudio();
stopRecordVideo();
haveReceiverVideo = false;
firstReceiverVideoCallback = null;
}
为什么是这个顺序?
-
先停振铃再关音频:振铃依赖音频通道,如果先关音频,振铃的
stopAudioMixing会报错。 - 先 dispose 视图再 leaveChannel:视图控制器持有 engine 引用,先 leaveChannel 会让视图变成无效状态,dispose 时崩溃。
- 最后停录音录像:录音录像是独立的 MediaRecorder,不依赖频道,所以最后处理。
十、收获
写完这个类,我对"复杂状态管理"有了体系化的认知:
- 任何 await 之后都要重新 check 状态。异步世界里,await 等于"挂起",挂起期间外部世界已经变了。
- 回调参数要分类。声网的回调有十几个,按"加入/离开/质量/错误"分类比按时间顺序写清晰得多。
- 同一引擎的不同场景,用枚举 + 配置差异化。不要为每个场景写一套独立的初始化代码,那样维护成本爆炸。
-
iOS 音频相关的代码一定要标注原因。
che.audio.keep.audiosession这种参数不写注释,3 个月后自己都不知道为什么这么写。 - 资源释放顺序跟资源依赖关系反着来。先建立的资源后释放,后建立的资源先释放——这就是"栈式释放"。
完整类图:
RtcManager (单例)
├── RtcEngine _engine // 媒体引擎
├── RtmManager rtmManager // 信令引擎
├── CallJoinsStatus _callJoinStatus // 加入状态机
├── RecordType _recordType // 录制状态机
│
├── init() // 双引擎初始化
├── joinChannel() // 加入频道(含 Token 续期)
├── leaveChannel() // 离开频道(资源释放)
├── _updateToken() // Token 自动续期
├── _playRing() / _stopRing() // 振铃管理
├── startRecordAudio/Video() // 录制管理
└── cancelCall() / acceptInCall() // 呼叫流程
下一篇我会写 Flutter WebSocket 多连接管理,那是另一种"复杂状态"的故事。
这是声网 SDK 实战系列的第二篇。如果你也在做音视频通讯,欢迎一起交流踩坑经验。