【flutter】flutter网易云信令 + im + 声网rtm从0实现通话视频文字聊天的踩坑

接了一个国外的项目,项目采用网易云im + 网易云信令+声网rtm遇到的一些问题
这个项目只对接口,给的工期是两周,延了工期,问题还是比较多的

  1. 需要全局监听rtm信息,收到监听内容,引起视频通话
  2. 网易云给的文档太烂,所有的类型推策只能文档一点点推
  3. 声网的rtm配置网易云的信令,坑太多,比如声网接收的字段是number,网易云给的字段是string等一系列报错问题
  4. im普通的对接,体验太差,采用倒叙分页解决此问题
  5. im的上传图片上传过程无显示,需要做上传图片的百分比显示

解决 im普通的对接,体验太差,采用倒叙分页解决此问题和图片上传百分比显示

js 复制代码
//im
 NIMMessageListOption option = NIMMessageListOption(
        conversationId: widget.conversationId ?? '',
        direction: NIMQueryDirection.desc, //倒叙
        limit: limit,
        anchorMessage: _anchorMessage,
        // endTime: endTime,
      );
js 复制代码
//图片
// 采用模拟发送数据,根据im提供的NimCore.instance.messageService.sendMessage ,得到是否成功,来显示状态
  Future<void> _pickImage() async {
    try {
      _logI('Picking image from gallery');
      final XFile? pickedFile = await _imagePicker.pickImage(
        source: ImageSource.gallery,
        imageQuality: 80,
      );

      if (pickedFile != null) {
        // u83b7u53d6u6587u4ef6u4fe1u606f
        final File imageFile = File(pickedFile.path);
        final String fileName = pickedFile.name;

        // u83b7u53d6u56feu7247u5c3au5bf8
        final decodedImage =
            await decodeImageFromList(imageFile.readAsBytesSync());
        final int width = decodedImage.width;
        final int height = decodedImage.height;

        // u521bu5efau4e34u65f6u6d88u606f
        final tempMessage = UnifiedMessage.createTempImage(pickedFile.path);

        setState(() {
          _messages.insert(0, tempMessage);
        });

        _scrollToBottom();

        // u5f00u59cbu4e0au4f20
        _sendImageMessage(
            tempMessage, pickedFile.path, fileName, width, height);
      }
    } catch (e) {
      _logI('Error picking image: $e');
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Failed to pick image: $e')),
      );
    }
  }

全局监听rtm信息回调

建立 call_manager.dart,单页面引入

js 复制代码
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:nim_core_v2/nim_core.dart';
import 'package:yunxin_alog/yunxin_alog.dart';
import '../screens/video_call_screen.dart';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import '../utils/event_bus.dart';
import '../utils/toast_util.dart';

var listener;

class CallManager {
  static final CallManager _instance = CallManager._internal();
  factory CallManager() => _instance;
  CallManager._internal();
  RtcEngine? _engine; // 添加 engine 变量

  final AudioPlayer _audioPlayer = AudioPlayer();
  Timer? _ringtoneTimer;
  BuildContext? _lastContext;
  bool _isShowingCallDialog = false;

  void initialize(BuildContext context) {
    _lastContext = context;
    _setupSignallingListeners();
  }

  void updateContext(BuildContext context) {
    _lastContext = context;
  }

  // 添加设置 engine 的方法
  void setEngine(RtcEngine engine) {
    _engine = engine;
  }

  // 修改现有的代码
  void _handleCallHangup(event) {
    print('关闭信令频道房间成功${event.channelInfo!.channelId} 目前一样');
    NimCore.instance.signallingService
        .closeRoom(event.channelInfo!.channelId!, true, null)
        .then((result) async {
      _isShowingCallDialog = false;
      EventBusUtil()
          .eventBus
          .fire(VideoCallEvent(VideoCallEvent.LEAVE_CHANNEL));
      if (result.isSuccess) {
        if (_engine != null) {
          await _engine!.leaveChannel();
          await _engine!.release();
        }
        // Success handling
      } else {
        // Error handling
      }
    });
  }

  @override
  void dispose() {
    print('dispose');
    if (listener != null) {
      listener.cancel();
      listener = null;
    }
  }

  // 添加一个方法来检查并清理监听器
  void cleanup() {
    print('cleanup');
    if (listener != null) {
      listener.cancel();
      listener = null;
    }
    _ringtoneTimer?.cancel();
    _ringtoneTimer = null;
    _audioPlayer.stop();
    _isShowingCallDialog = false;
  }

// NIMSignallingEventTypeUnknown	0	未知
// NIMSignallingEventTypeClose	1	关闭信令频道房间
// NIMSignallingEventTypeJoin	2	加入信令频道房间
// NIMSignallingEventTypeInvite	3	邀请加入信令频道房间
// NIMSignallingEventTypeCancelInvite	4	取消邀请加入信令频道房间
// NIMSignallingEventTypeReject	5	拒绝入房的邀请
// NIMSignallingEventTypeAccept	6	接受入房的邀请
// NIMSignallingEventTypeLeave	7	离开信令频道房间
// NIMSignallingEventTypeControl	8	自定义控制命令
  void _setupSignallingListeners() {
    // Listen for online events (when the app is active)
    listener = NimCore.instance.signallingService.onOnlineEvent
        .listen((NIMSignallingEvent event) {
      print("事件监听开始${event.toJson()}");
      _handleSignallingEvent(event);
    });

    // Listen for offline events (when the app is in background)
    NimCore.instance.signallingService.onOfflineEvent.listen((event) {
      // Handle offline events
      print('Offline event: $event');
    });

    // Listen for multi-client events
    NimCore.instance.signallingService.onMultiClientEvent.listen((event) {
      // Handle multi-client events
      print('Multi-client event: $event');
    });

    Alog.i(tag: 'CallManager', content: 'Signalling listeners setup complete');
  }

  void _handleSignallingEvent(NIMSignallingEvent event) {
    if (event.channelInfo != null &&
        event.eventType ==
            NIMSignallingEventType.NIMSignallingEventTypeInvite) {
      // 3
      _showIncomingCallDialog(event.channelInfo, event.requestId ?? '');
    }
    if (event.channelInfo != null &&
        event.eventType == NIMSignallingEventType.NIMSignallingEventTypeClose) {
      //1
      _handleCallHangup(event);
    }
    if (event.channelInfo != null &&
        event.eventType == NIMSignallingEventType.NIMSignallingEventTypeJoin) {
      EventBusUtil().eventBus.fire(VideoCallEvent(VideoCallEvent.USER_JOINED));
    }
    if (event.channelInfo != null &&
        event.eventType ==
            NIMSignallingEventType.NIMSignallingEventTypeReject) {
      ToastUtil.showDanger('user reject');
      cleanup();
    }
  }

  Future<void> _playRingtone() async {
    try {
      await _audioPlayer.play(AssetSource('sounds/incoming_call.mp3'));

      // Loop the ringtone
      _ringtoneTimer =
          Timer.periodic(const Duration(seconds: 3), (timer) async {
        await _audioPlayer.play(AssetSource('sounds/incoming_call.mp3'));
      });
    } catch (e) {
      Alog.e(tag: 'CallManager', content: 'Error playing ringtone: $e');
    }
  }

  void _stopRingtone() {
    _ringtoneTimer?.cancel();
    _ringtoneTimer = null;
    _audioPlayer.stop();
  }

  void _showIncomingCallDialog(
      NIMSignallingChannelInfo? channelInfo, String requestId) {
    if (channelInfo == null || _lastContext == null || _isShowingCallDialog) {
      return;
    }
    _isShowingCallDialog = true;
    _playRingtone();

    String? channelId = channelInfo.channelId;
    String? callerName = channelInfo.creatorAccountId;

    showDialog(
      context: _lastContext!,
      barrierDismissible: false,
      builder: (context) {
        return AlertDialog(
          backgroundColor: Colors.black87,
          title: const Text(
            'Incoming Video Call',
            style: TextStyle(color: Colors.white),
            textAlign: TextAlign.center,
          ),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const CircleAvatar(
                radius: 40,
                backgroundColor: Colors.purple,
                child: Icon(Icons.person, size: 50, color: Colors.white),
              ),
              const SizedBox(height: 16),
              Text(
                callerName ?? 'Unknown Caller',
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'is calling you...',
                style: TextStyle(color: Colors.white70),
              ),
            ],
          ),
          actions: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                // Decline button
                ElevatedButton(
                  onPressed: () {
                    _stopRingtone();
                    _isShowingCallDialog = false;
                    Navigator.of(context).pop();

                    // Reject the call - using hangup instead of reject since reject isn't available
                    _rejectCall(channelInfo, requestId, context);
                  },
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.red,
                    shape: const CircleBorder(),
                    padding: const EdgeInsets.all(16),
                  ),
                  child: const Icon(Icons.call_end, color: Colors.white),
                ),

                // Accept button
                ElevatedButton(
                  onPressed: () {
                    _stopRingtone();
                    _isShowingCallDialog = false;
                    Navigator.of(context).pop();

                    // Accept the call
                    _acceptCall(channelInfo, requestId, context);
                  },
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.green,
                    shape: const CircleBorder(),
                    padding: const EdgeInsets.all(16),
                  ),
                  child: const Icon(Icons.call, color: Colors.white),
                ),
              ],
            ),
          ],
        );
      },
    ).then((_) {
      _stopRingtone();
      _isShowingCallDialog = false;
    });
  }

  Future<void> _acceptCall(NIMSignallingChannelInfo channelInfo,
      String requestId, BuildContext context) async {
    String? channelId = channelInfo.channelId;
    String? creatorAccountId = channelInfo.creatorAccountId;
    if (channelId == null || creatorAccountId == null) {
      return;
    }

    final params = NIMSignallingCallSetupParams(
      channelId: channelId,
      callerAccountId: creatorAccountId,
      requestId: requestId,
    );

    try {
      final result = await NimCore.instance.signallingService.callSetup(params);

      if (result.isSuccess) {
        // Navigate to the video call screen with incomingCall flag
        // 检查 context 是否还有效
        print("1121212${result.toMap()}");
        final setUpChanelId = result.data?.roomInfo?.channelInfo?.channelId;
        final setUpCalleeAccountId =
            result.data?.roomInfo?.channelInfo?.creatorAccountId;
        // final setUpRemoteUid = result.data?.roomInfo?.members?.first.uid ?? 0;
        // final setUpRemoteUid = result.data?.roomInfo?.members?.first.uid;
        final setUpRemoteUid =
            result.data?.roomInfo?.channelInfo?.creatorAccountId;
        if (!context.mounted) {
          Alog.e(tag: 'CallManager', content: 'Context is not mounted anymore');
          // 如果原始 context 无效,尝试使用 _lastContext
          if (_lastContext != null && _lastContext!.mounted) {
            context = _lastContext!;
          } else {
            Alog.e(
                tag: 'CallManager',
                content: 'No valid context available for navigation');
            return;
          }
        }
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => VideoCallScreen(
                calleeAccountId: channelInfo.creatorAccountId,
                isIncomingCall: true,
                setUpChanelId: setUpChanelId,
                setUpCalleeAccountId: setUpCalleeAccountId,
                setUpRemoteUid: int.tryParse(setUpRemoteUid!) ?? 0),
          ),
        );
      } else {
        Alog.e(
            tag: 'CallManager',
            content: 'Failed to setup video call: ${result.code}');
        ToastUtil.showDanger('Failed to setup video call');
      }
    } catch (e) {
      ToastUtil.showDanger('Failed to setup video call');
      Alog.e(tag: 'CallManager', content: 'Error accepting call: $e');
    }
  }

  Future<void> _rejectCall(NIMSignallingChannelInfo channelInfo,
      String requestId, BuildContext context) async {
    try {
      String? channelId = channelInfo.channelId;
      String? creatorAccountId = channelInfo.creatorAccountId;
      if (channelId == null || creatorAccountId == null) {
        return;
      }
      final params = NIMSignallingRejectInviteParams(
        channelId: channelId,
        inviterAccountId: creatorAccountId,
        requestId: requestId,
      );

      // Close the room since direct reject isn't available
      final result =
          await NimCore.instance.signallingService.rejectInvite(params);

      if (result.isSuccess) {
        Alog.i(tag: 'CallManager', content: 'Call rejected successfully');
      } else {
        Alog.e(
            tag: 'CallManager',
            content: 'Failed to reject call: ${result.code}');
      }
    } catch (e) {
      Alog.e(tag: 'CallManager', content: 'Error rejecting call: $e');
    }
  }
}

待续