平台能力与原生互通篇(4/6):音视频房间实战:摄像头、麦克风、渲染视图

4 阅读6分钟

音视频房间实战:摄像头、麦克风、渲染视图

适用场景:Flutter 语聊房/视频房/连麦 PK
技术关键词:RTC 权限管理 前后台切换 渲染优化 弱网重连

1. 问题背景:业务场景 + 现象

做音视频房间时,最容易出现的不是「进不了房」,而是能进房但体验不稳定

  • 首次进入房间,摄像头黑屏 1~3 秒;
  • 远端用户能听见我说话,但看不到画面(或反过来);
  • 切到后台再回来,视频卡住但 UI 还显示「已连麦」;
  • 房间里 6~9 宫格时,CPU 飙升、掉帧、手机发热;
  • Android 某些机型麦克风权限弹窗后,RTC 引擎状态和页面状态不一致。

根因通常不是某一行代码,而是权限、引擎生命周期、渲染视图、状态同步四件事没打通。


2. 原因分析:核心原理 + 排查过程

核心原理(务必统一认知)

  1. 引擎状态 ≠ UI 状态
    UI 上的「开麦/开摄像头」只是意图,最终是否生效要看 RTC 回调(publish success / remote subscribed)。
  2. 渲染视图依赖流生命周期
    远端流是异步到达的,先创建容器再绑定流,避免「先绑后建」导致空画面。
  3. 权限是前置条件,不是异常分支
    麦克风/摄像头权限应该进入主流程状态机,而不是 try-catch 里临时处理。
  4. 前后台切换会打断采集/编码链路
    特别是 iOS 音频会话与 Android 焦点抢占,需要监听生命周期做恢复动作。

排查顺序(实战推荐)

  • 第一步看权限日志(是否 granted)
  • 第二步看本地发布回调(是否 publish 成功)
  • 第三步看远端订阅回调(是否订阅成功)
  • 第四步看渲染层绑定(uid -> view 是否对应)
  • 第五步看弱网/重连事件(是否自动恢复)

3. 解决方案:方案对比 + 最终选择

方案对比

方案 A:页面里直接调 RTC SDK

  • 优点:写得快。
  • 缺点:页面膨胀、无法复用、难测,生命周期容易漏。

方案 B:Controller + 页面订阅(推荐)

  • 优点:把「设备控制、房间连接、渲染位管理」从 UI 抽离;可测试、可复用。
  • 缺点:前期要定义好状态模型。

最终落地结构

presentation/
  room_page.dart             // 只负责展示与交互
application/
  room_rtc_controller.dart   // 房间状态机、动作编排
domain/
  room_media_state.dart      // 纯状态定义
infrastructure/
  rtc_gateway.dart           // SDK 适配层(屏蔽第三方差异)

关键原则:

  • 页面只触发意图:joinRoom() / toggleMic() / toggleCamera()
  • 控制器只维护状态:idle -> joining -> joined -> reconnecting
  • SDK 细节全放 RtcGateway,未来切供应商不炸业务层

4. 关键代码:最小必要代码片段(详细示例)

下面代码用「接口 + 控制器 + 页面」展示完整链路,你可按现有项目替换成 Riverpod/Bloc/GetX 风格。

4.1 状态模型(可序列化、可追踪)

enum RoomConnState { idle, joining, joined, reconnecting, failed, left }

class RtcSeatUser {
  final String uid;
  final bool hasVideo;
  final bool hasAudio;
  const RtcSeatUser({
    required this.uid,
    required this.hasVideo,
    required this.hasAudio,
  });

  RtcSeatUser copyWith({bool? hasVideo, bool? hasAudio}) => RtcSeatUser(
        uid: uid,
        hasVideo: hasVideo ?? this.hasVideo,
        hasAudio: hasAudio ?? this.hasAudio,
      );
}

class RoomMediaState {
  final RoomConnState connState;
  final bool micOn;
  final bool cameraOn;
  final bool localPreviewReady;
  final List<RtcSeatUser> remoteUsers;
  final String? errorMsg;

  const RoomMediaState({
    required this.connState,
    required this.micOn,
    required this.cameraOn,
    required this.localPreviewReady,
    required this.remoteUsers,
    this.errorMsg,
  });

  factory RoomMediaState.initial() => const RoomMediaState(
        connState: RoomConnState.idle,
        micOn: true,
        cameraOn: false,
        localPreviewReady: false,
        remoteUsers: [],
      );

  RoomMediaState copyWith({
    RoomConnState? connState,
    bool? micOn,
    bool? cameraOn,
    bool? localPreviewReady,
    List<RtcSeatUser>? remoteUsers,
    String? errorMsg,
  }) {
    return RoomMediaState(
      connState: connState ?? this.connState,
      micOn: micOn ?? this.micOn,
      cameraOn: cameraOn ?? this.cameraOn,
      localPreviewReady: localPreviewReady ?? this.localPreviewReady,
      remoteUsers: remoteUsers ?? this.remoteUsers,
      errorMsg: errorMsg,
    );
  }
}

4.2 SDK 适配层(统一回调与动作)

abstract class RtcGateway {
  Future<void> init();
  Future<void> join({
    required String roomId,
    required String uid,
    required String token,
  });
  Future<void> leave();

  Future<void> enableMic(bool enable);
  Future<void> enableCamera(bool enable);
  Future<void> startPreview();
  Future<void> stopPreview();

  Stream<RtcEvent> get events; // SDK 回调统一成事件流
}

sealed class RtcEvent {
  const RtcEvent();
}

class LocalJoined extends RtcEvent {}
class LocalJoinFailed extends RtcEvent {
  final String message;
  const LocalJoinFailed(this.message);
}
class RemoteUserJoined extends RtcEvent {
  final String uid;
  const RemoteUserJoined(this.uid);
}
class RemoteUserLeft extends RtcEvent {
  final String uid;
  const RemoteUserLeft(this.uid);
}
class RemoteVideoStateChanged extends RtcEvent {
  final String uid;
  final bool hasVideo;
  const RemoteVideoStateChanged(this.uid, this.hasVideo);
}
class RemoteAudioStateChanged extends RtcEvent {
  final String uid;
  final bool hasAudio;
  const RemoteAudioStateChanged(this.uid, this.hasAudio);
}
class NetworkReconnecting extends RtcEvent {}
class NetworkRecovered extends RtcEvent {}

4.3 控制器(完整链路:权限 -> 加入 -> 订阅 -> 恢复)

class RoomRtcController with WidgetsBindingObserver {
  final RtcGateway rtc;
  final PermissionService permissionService;

  RoomMediaState _state = RoomMediaState.initial();
  RoomMediaState get state => _state;
  final _stateStream = StreamController<RoomMediaState>.broadcast();
  Stream<RoomMediaState> get stream => _stateStream.stream;

  StreamSubscription<RtcEvent>? _rtcSub;
  bool _disposed = false;

  RoomRtcController({
    required this.rtc,
    required this.permissionService,
  });

  Future<void> init() async {
    WidgetsBinding.instance.addObserver(this);
    await rtc.init();

    _rtcSub = rtc.events.listen(_onRtcEvent);
  }

  Future<void> joinRoom({
    required String roomId,
    required String uid,
    required String token,
  }) async {
    _emit(_state.copyWith(connState: RoomConnState.joining, errorMsg: null));

    final granted = await permissionService.request(
      camera: _state.cameraOn,
      microphone: true,
    );
    if (!granted) {
      _emit(_state.copyWith(
        connState: RoomConnState.failed,
        errorMsg: '未授予麦克风/摄像头权限',
      ));
      return;
    }

    try {
      if (_state.cameraOn) {
        await rtc.startPreview();
        _emit(_state.copyWith(localPreviewReady: true));
      }
      await rtc.enableMic(_state.micOn);
      await rtc.enableCamera(_state.cameraOn);

      await rtc.join(roomId: roomId, uid: uid, token: token);
      // 成功/失败最终由回调确认
    } catch (e) {
      _emit(_state.copyWith(
        connState: RoomConnState.failed,
        errorMsg: '加入房间失败: $e',
      ));
    }
  }

  Future<void> toggleMic() async {
    final next = !_state.micOn;
    await rtc.enableMic(next);
    _emit(_state.copyWith(micOn: next));
  }

  Future<void> toggleCamera() async {
    final next = !_state.cameraOn;
    await rtc.enableCamera(next);

    if (next) {
      await rtc.startPreview();
    } else {
      await rtc.stopPreview();
    }

    _emit(_state.copyWith(
      cameraOn: next,
      localPreviewReady: next,
    ));
  }

  Future<void> leaveRoom() async {
    await rtc.leave();
    _emit(RoomMediaState.initial().copyWith(connState: RoomConnState.left));
  }

  void _onRtcEvent(RtcEvent event) {
    if (event is LocalJoined) {
      _emit(_state.copyWith(connState: RoomConnState.joined));
      return;
    }
    if (event is LocalJoinFailed) {
      _emit(_state.copyWith(
        connState: RoomConnState.failed,
        errorMsg: event.message,
      ));
      return;
    }
    if (event is RemoteUserJoined) {
      final list = [..._state.remoteUsers, RtcSeatUser(uid: event.uid, hasVideo: false, hasAudio: false)];
      _emit(_state.copyWith(remoteUsers: list));
      return;
    }
    if (event is RemoteUserLeft) {
      _emit(_state.copyWith(
        remoteUsers: _state.remoteUsers.where((u) => u.uid != event.uid).toList(),
      ));
      return;
    }
    if (event is RemoteVideoStateChanged) {
      _emit(_state.copyWith(
        remoteUsers: _state.remoteUsers
            .map((u) => u.uid == event.uid ? u.copyWith(hasVideo: event.hasVideo) : u)
            .toList(),
      ));
      return;
    }
    if (event is RemoteAudioStateChanged) {
      _emit(_state.copyWith(
        remoteUsers: _state.remoteUsers
            .map((u) => u.uid == event.uid ? u.copyWith(hasAudio: event.hasAudio) : u)
            .toList(),
      ));
      return;
    }
    if (event is NetworkReconnecting) {
      _emit(_state.copyWith(connState: RoomConnState.reconnecting));
      return;
    }
    if (event is NetworkRecovered) {
      _emit(_state.copyWith(connState: RoomConnState.joined));
      return;
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState appState) {
    // 建议策略:回前台后主动做一次音视频链路校验/恢复
    if (appState == AppLifecycleState.resumed &&
        _state.connState == RoomConnState.joined) {
      rtc.enableMic(_state.micOn);
      rtc.enableCamera(_state.cameraOn);
    }
  }

  void _emit(RoomMediaState next) {
    _state = next;
    if (!_disposed) _stateStream.add(next);
  }

  Future<void> dispose() async {
    _disposed = true;
    WidgetsBinding.instance.removeObserver(this);
    await _rtcSub?.cancel();
    await _stateStream.close();
  }
}

4.4 页面渲染(宫格 + 本地预览 + 远端视频)

class RoomPage extends StatefulWidget {
  const RoomPage({super.key});

  @override
  State<RoomPage> createState() => _RoomPageState();
}

class _RoomPageState extends State<RoomPage> {
  late final RoomRtcController c;

  @override
  void initState() {
    super.initState();
    c = RoomRtcController(
      rtc: sl<RtcGateway>(),
      permissionService: sl<PermissionService>(),
    );
    c.init().then((_) {
      c.joinRoom(roomId: '10001', uid: 'u_9527', token: 'server_token');
    });
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<RoomMediaState>(
      stream: c.stream,
      initialData: c.state,
      builder: (context, snap) {
        final s = snap.data!;
        return Scaffold(
          appBar: AppBar(title: Text('房间状态: ${s.connState.name}')),
          body: Column(
            children: [
              // 本地预览
              AspectRatio(
                aspectRatio: 16 / 9,
                child: s.localPreviewReady
                    ? LocalVideoView() // 来自具体 RTC SDK 的渲染组件
                    : const DecoratedBox(
                        decoration: BoxDecoration(color: Colors.black12),
                        child: Center(child: Text('本地预览未开启')),
                      ),
              ),
              const SizedBox(height: 8),
              // 远端宫格
              Expanded(
                child: GridView.builder(
                  itemCount: s.remoteUsers.length,
                  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 2,
                    childAspectRatio: 16 / 9,
                  ),
                  itemBuilder: (_, i) {
                    final u = s.remoteUsers[i];
                    return Stack(
                      children: [
                        Positioned.fill(
                          child: u.hasVideo
                              ? RemoteVideoView(uid: u.uid)
                              : const DecoratedBox(
                                  decoration: BoxDecoration(color: Colors.black26),
                                  child: Center(child: Text('对方未开摄像头')),
                                ),
                        ),
                        Positioned(
                          left: 8,
                          bottom: 8,
                          child: Text(
                            '${u.uid}  ${u.hasAudio ? "🎤" : "🔇"}',
                            style: const TextStyle(color: Colors.white),
                          ),
                        ),
                      ],
                    );
                  },
                ),
              ),
              _BottomBar(controller: c, state: s),
            ],
          ),
        );
      },
    );
  }

  @override
  void dispose() {
    c.leaveRoom();
    c.dispose();
    super.dispose();
  }
}

4.5 性能优化(大房间必做)

// 1) 不可见宫格暂停远端渲染(示例思路)
// 可结合 VisibilityDetector / 滑动位置判断
Future<void> onTileVisibilityChanged(String uid, bool visible) async {
  if (visible) {
    await rtc.resumeRemoteVideo(uid);
  } else {
    await rtc.pauseRemoteVideo(uid);
  }
}

// 2) 降级策略:弱网时优先保音频、降视频档位
Future<void> applyWeakNetworkPolicy() async {
  await rtc.setVideoEncoderConfig(
    width: 320,
    height: 180,
    fps: 12,
    bitrateKbps: 220,
  );
}

5. 效果验证:数据/截图/日志

建议你在项目里固定 3 类埋点,避免「感觉优化了」:

  1. 时延指标

    • join_click -> local_joined(入房耗时)
    • remote_user_joined -> first_frame_rendered(首帧耗时)
  2. 稳定性指标

    • 重连次数、重连成功率
    • 前后台切换后 10 秒内黑屏率
  3. 体验指标

    • 房间 4/9 人场景 CPU、内存、平均 FPS
    • 麦克风开关成功率(意图与实际状态一致率)

示例日志格式:

[RTC] join_start room=10001 uid=u_9527 ts=...
[RTC] local_joined cost_ms=842
[RTC] remote_first_frame uid=u_8848 cost_ms=476
[RTC] reconnecting reason=network_bad
[RTC] recovered cost_ms=1730

6. 可复用结论:通用经验 + 避坑清单

通用经验

  • 先建状态机,再接 SDKidle/joining/joined/reconnecting/failed/left 这套状态先定义好,代码会稳很多。
  • 权限前置成主流程:权限拒绝是常态,不是异常分支。
  • 渲染和连接分离:连接成功不代表画面就绪,画面就绪要看首帧回调。
  • 弱网先保音频:语聊/视频社交里,声音连续性优先于高清画质。

避坑清单

  • build 里反复创建 RTC 渲染 View,导致闪屏/内存抖动
  • 只改 UI 的麦克风图标,不等 SDK 回调确认
  • 页面 dispose 漏掉 leaveRoom,导致后台仍占用麦克风
  • 前后台切换后不做链路恢复,出现假在线状态
  • 宫格全量渲染不降级,9 人房间直接发热掉帧

结语

音视频房间是否「可运营」,核心不在“能通话”,而在异常情况下还能稳定工作
把权限、连接状态、渲染状态、生命周期恢复放到同一条可追踪链路里,你的 Flutter 房间模块才能从 Demo 变成可持续迭代的业务能力。