声网 RTC + RTM 双引擎实战:在 Flutter 里搭一套稳定的音视频通讯架构

远程监控只是冰山一角,真正复杂的 underneath 是声网 SDK 的使用。RtcManager 是这个项目里最重的类,900 多行代码,承载了视频通话、语音通话、远程监控三种场景。这篇博客拆解几个我踩过最深的坑。

一、为什么是"双引擎"?

声网的 SDK 拆成两个独立部分:

  • RTC Engineagora_rtc_engine):负责音视频媒体流,比如加入频道、推流、拉流、摄像头控制。
  • RTMagora_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, ...) {
    // ...
  });
}

关键点:

  1. haveInit 标志位:防止重复初始化。声网 SDK 重复 initialize 会抛异常。
  2. RtcEngineEventHandler 必须在 registerEventHandler 之前组装好:否则在 register 之后到 setEventHandler 之间的回调会丢失。
  3. RTM 登录放在 RTC 初始化之后:因为 RTM 收到呼叫消息后,可能需要立即用 RTC 加入频道,所以 RTC 必须先就绪。
  4. 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()}");
  }
}

两个关键调用:

  1. setDefaultAudioRouteToSpeakerphone(true):振铃时一定要走扬声器,否则用户听不到。但通话接通后可能要切回听筒,所以 leaveChannel 里要还原配置。
  2. 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,不依赖频道,所以最后处理。

十、收获

写完这个类,我对"复杂状态管理"有了体系化的认知:

  1. 任何 await 之后都要重新 check 状态。异步世界里,await 等于"挂起",挂起期间外部世界已经变了。
  2. 回调参数要分类。声网的回调有十几个,按"加入/离开/质量/错误"分类比按时间顺序写清晰得多。
  3. 同一引擎的不同场景,用枚举 + 配置差异化。不要为每个场景写一套独立的初始化代码,那样维护成本爆炸。
  4. iOS 音频相关的代码一定要标注原因che.audio.keep.audiosession 这种参数不写注释,3 个月后自己都不知道为什么这么写。
  5. 资源释放顺序跟资源依赖关系反着来。先建立的资源后释放,后建立的资源先释放——这就是"栈式释放"。

完整类图:

RtcManager (单例)
├── RtcEngine _engine          // 媒体引擎
├── RtmManager rtmManager      // 信令引擎
├── CallJoinsStatus _callJoinStatus  // 加入状态机
├── RecordType _recordType     // 录制状态机
│
├── init()                     // 双引擎初始化
├── joinChannel()              // 加入频道(含 Token 续期)
├── leaveChannel()             // 离开频道(资源释放)
├── _updateToken()             // Token 自动续期
├── _playRing() / _stopRing()  // 振铃管理
├── startRecordAudio/Video()   // 录制管理
└── cancelCall() / acceptInCall() // 呼叫流程

下一篇我会写 Flutter WebSocket 多连接管理,那是另一种"复杂状态"的故事。


这是声网 SDK 实战系列的第二篇。如果你也在做音视频通讯,欢迎一起交流踩坑经验。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容