关于flutter:基于声网-Flutter-SDK-实现多人视频通话

35次阅读

共计 13644 个字符,预计需要花费 35 分钟才能阅读完成。

前言

本文是由声网社区的开发者“小猿”撰写的 Flutter 基础教程系列中的第一篇。本文除了讲述实现多人视频通话的过程,还有一些 Flutter 开发方面的知识点。该系列将基于声网 Fluttter SDK 实现视频通话、互动直播,并尝试虚构背景等更多功能的实现。


如果你有一个实现“多人视频通话”的场景需要,你会抉择从零实现还是接第三方 SDK?如果在这个场景上你还须要反对跨平台,你会抉择怎么样的技术路线?

我的答案是:Flutter + 声网 SDK,这个组合能够完满解决跨平台和多人视频通话的所有痛点,因为:

  • Flutter 人造反对手机端和 PC 端的跨平台能力,并领有不错的性能体现
  • 声网的 Flutter RTC SDK 同样反对 Android、iOS、MacOS 和 Windows 等平台,同时也是难得针对 Flutter 进行了全平台反对和优化的音视频 SDK

在开始之前,有必要提前简略介绍一下声网的 RTC SDK 相干实现,这也是我抉择声网的起因。

声网属于是国内最早一批做 Flutter SDK 全平台反对的厂家,声网的 Flutter SDK 之所以能在 Flutter 上最早放弃多平台的反对,起因在于声网并不是应用惯例的 Flutter Channel 去实现平台音视频能力:

声网的 RTC SDK 的逻辑实现都来自于封装好的 C/C++ 等 native 代码,而 这些代码会被打包为对应平台的动态链接库,例如.dll、.so、.dylib,最初通过 Dart 的 FFI(ffigen) 进行封装调用

这样做的益处在于:

  • Dart 能够和 native SDK 间接通信,缩小了 Flutter 和原生平台交互时在 Channel 上的性能开销;
  • C/C++ 相干实现在取得更好性能反对的同时,也不须要适度依赖原生平台的 API,能够失去更灵便和平安的 API 反对。

如果说这样做有什么害处,那大略就是 SDK 的底层开发和保护老本会剧增,不过从用户角度来看,这无异是一个绝佳的抉择。

开发之前

接下来让咱们进入正题,既然抉择了 Flutter + 声网的实现路线,那么在开始之前必定有一些须要筹备的前置条件,首先是为了满足声网 RTC SDK  的应用条件,必须是:

  • Flutter 2.0 或更高版本
  • Dart 2.14.0 或更高版本

从目前 Flutter 和 Dart 版本来看,下面这个要求并不算高,而后就是你须要注册一个声网开发者账号,从而获取后续配置所需的 App ID 和 Token 等配置参数。

如果对后续配置“门清”,能够疏忽跳过。

创立我的项目

首先能够在声网控制台的项目管理页面上点击「创立我的项目」,而后在弹出框选输出项目名称,之后抉择「视频通话」场景和「平安模式(APP ID + Token)」即可实现我的项目创立。

依据法规,创立我的项目须要实名认证,这个必不可少;另外应用场景不用太过纠结,我的项目创立之后也是能够依据须要本人批改。

获取 App ID

胜利创立我的项目之后,在我的项目列表点击我的项目「配置」,进入我的项目详情页面之后,会看到根本信息栏目有个 App ID 的字段,点击如下图所示图标,即可获取我的项目的 App ID。

App ID 也算是敏感信息之一,所以尽量妥善保留,防止泄密。

获取 Token

为进步我的项目的安全性,声网举荐了应用 Token 对退出频道的用户进行鉴权,在生产环境中,个别为保障平安,是须要用户通过本人的服务器去签发 Token,而如果是测试须要,能够在我的项目详情页面的“长期 token 生成器”获取长期 Token:

在频道名输入一个长期频道,比方 Test2,而后点击生成长期 token 按键,即可获取一个长期 Token,有效期为 24 小时。

这里失去的 Token 和频道名就能够间接用于后续的测试,如果是用在生产环境上,倡议还是在服务端签发 Token,签发 Token 除了 App ID 还会用到 App 证书,获取 App 证书同样能够在我的项目详情的利用配置上获取。

更多服务端签发 Token 可见 token server 文档

开始开发

通过后面的配置,咱们当初领有了  App ID、频道名和一个无效的长期 Token,接下里就是在 Flutter 我的项目里引入声网的 RTC SDK:agora_rtc_engine。

我的项目配置

首先在 Flutter 我的项目的 pubspec.yaml 文件中增加以下依赖,其中 agora_rtc_engine 这里引入的是 6.1.0 版本。

其实 permission_handler 并不是必须的,只是因为「视频通话」我的项目必不可少须要申请到「麦克风」和「相机」权限,所以这里举荐应用 permission_handler 来实现权限的动静申请。

dependencies:
  flutter:
    sdk: flutter

  agora_rtc_engine: ^6.1.0
  permission_handler: ^10.2.0

这里须要留神的是,Android 平台不须要特意在主工程的 AndroidManifest.xml 文件上增加 uses-permission,因为 SDK 的 AndroidManifest.xml 曾经增加过所需的权限。

iOS 和 macOS 能够间接在 Info.plist 文件增加加 NSCameraUsageDescription 和 NSCameraUsageDescription 的权限申明,或者在 Xcode 的 Info 栏目增加 Privacy – Microphone Usage Description 和 Privacy – Camera Usage Description。

 <key>NSCameraUsageDescription</key>
 <string>*****</string>
 <key>NSMicrophoneUsageDescription</key>
 <string>*****</string>

应用声网 SDK 

获取权限

在正式调用声网 SDK 的 API 之前,首先咱们须要申请权限,如下代码所示,能够应用 permission_handler 的 request 提前获取所需的麦克风和摄像头权限。

@override
void initState() {super.initState();

  _requestPermissionIfNeed();}

Future<void> _requestPermissionIfNeed() async {await [Permission.microphone, Permission.camera].request();}

初始化引擎

接下来开始配置 RTC 引擎,如下代码所示,通过 import 对应的 dart 文件之后,就能够通过 SDK 自带的 createAgoraRtcEngine 办法疾速创立引擎,而后通过 initialize 办法就能够初始化 RTC 引擎了,能够看到这里会用到后面创立我的项目时失去的 App ID 进行初始化。

留神这里须要在申请完权限之后再初始化引擎,并更新初始化胜利状态 initStatus,因为没胜利初始化之前不能应用 RtcEngine。

import 'package:agora_rtc_engine/agora_rtc_engine.dart';

late final RtcEngine _engine;

/// 初始化状态
late final Future<bool?> initStatus;

@override
void initState() {super.initState();
  /// 申请实现权限后,初始化引擎,更新初始化胜利状态
  initStatus = _requestPermissionIfNeed().then((value) async {await _initEngine();
    return true;
  }).whenComplete(() => setState(() {}));
}


Future<void> _initEngine() async {
  // 创立 RtcEngine
  _engine = createAgoraRtcEngine();
  // 初始化 RtcEngine
  await _engine.initialize(RtcEngineContext(appId: appId,));
  ···
}

接着咱们须要通过 registerEventHandler 注册一系列回调办法,在 RtcEngineEventHandler 里有很多回调告诉,而个别状况下咱们比方罕用到的会是上面这 5 个:

  • onError:判断谬误类型和错误信息
  • onJoinChannelSuccess:退出频道胜利
  • onUserJoined:有用户退出了频道
  • onUserOffline:有用户来到了频道
  • onLeaveChannel:来到频道

/// 是否退出聊天
bool isJoined = false;
/// 记录退出的用户 id
Set<int> remoteUid = {};

Future<void> _initEngine() async {
   ···
   _engine.registerEventHandler(RtcEngineEventHandler(
      // 遇到谬误
      onError: (ErrorCodeType err, String msg) {print('[onError] err: $err, msg: $msg');
      },
      onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 退出频道胜利
        setState(() {isJoined = true;});
      },
      onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
        // 有用户退出
        setState(() {remoteUid.add(rUid);
        });
      },
      onUserOffline:
          (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
        // 有用户离线
        setState(() {remoteUid.removeWhere((element) => element == rUid);
        });
      },
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // 来到频道
        setState(() {
          isJoined = false;
          remoteUid.clear();});
      },
    ));
}

用户能够依据下面的回调来判断 UI 状态,比方以后用户处于频道内时显示对方的头像和数据,其余用户退出和来到频道时更新以后 UI 等。

接下来因为咱们的需要是「多人视频通话」,所以还须要调用 enableVideo 关上视频模块反对,同时咱们还能够对视频编码进行一些简略配置,比方通过 VideoEncoderConfiguration 配置:

  • dimensions:配置视频的分辨率尺寸,默认是 640×360
  • frameRate:配置视频的帧率,默认是 15 fps Future<void> _initEngine() async {
  Future<void> _initEngine() async {

    ···
    // 关上视频模块反对
    await _engine.enableVideo();
    // 配置视频编码器,编码视频的尺寸(像素),帧率
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );

    await _engine.startPreview();}

更多参数配置反对如下所示:

最初调用 startPreview 开启画面预览性能,接下来只须要把初始化好的 Engine 配置到 AgoraVideoView 控件就能够实现渲染。

渲染画面

接下来就是渲染画面,如下代码所示,在 UI 上退出 AgoraVideoView 控件,并把下面初始化胜利_engine,通过 VideoViewController 配置到 AgoraVideoView,就能够实现本地视图的预览。

依据后面的 initStatus 状态,在_engine 初始化胜利后才加载 AgoraVideoView。

Scaffold(appBar: AppBar(),
  body: FutureBuilder<bool?>(
      future: initStatus,
      builder: (context, snap) {if (snap.data != true) {
          return Center(
            child: new Text(
              "初始化 ing",
              style: TextStyle(fontSize: 30),
            ),
          );
        }
        return AgoraVideoView(
          controller: VideoViewController(
            rtcEngine: _engine,
            canvas: const VideoCanvas(uid: 0),
          ),
        );
      }),
);

这里还有另外一个参数 VideoCanvas,其中的 uid 是用来标记用户 id 的,这里因为是本地用户,这里临时用 0 示意。

如果须要退出频道,能够调用 joinChannel 办法退出对应频道,以下的参数都是必须的,其中:

  • token 就是后面长期生成的 Token
  • channelId 就是后面的渠道名
  • uid 和下面一样逻辑
  • channelProfile 抉择 channelProfileLiveBroadcasting,因为咱们须要的是多人通话。
  • clientRoleType 抉择 clientRoleBroadcaster,因为咱们须要多人通话,所以咱们须要进来的用户能够交换发送内容。
Scaffold(appBar: AppBar(),
  body: FutureBuilder<bool?>(
      future: initStatus,
      builder: (context, snap) {if (snap.data != true) {
          return Center(
            child: new Text(
              "初始化 ing",
              style: TextStyle(fontSize: 30),
            ),
          );
        }
        return AgoraVideoView(
          controller: VideoViewController(
            rtcEngine: _engine,
            canvas: const VideoCanvas(uid: 0),
          ),
        );
      }),
);

同样的情理,通过后面的 RtcEngineEventHandler,咱们能够获取到退出频道用户的 uid(rUid),所以还是 AgoraVideoView,然而咱们应用 VideoViewController.remote 依据 uid 和频道 id 去创立 controller,配合 SingleChildScrollView 在顶部显示一排能够左右滑动的用户小窗成果。

用 Stack 嵌套层级。

Scaffold(appBar: AppBar(),
  body: Stack(
    children: [
      AgoraVideoView(·····),
      Align(
        alignment: Alignment.topLeft,
        child: SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: Row(
            children: List.of(remoteUid.map((e) =>
                  SizedBox(
                    width: 120,
                    height: 120,
                    child: AgoraVideoView(
                      controller: VideoViewController.remote(
                        rtcEngine: _engine,
                        canvas: VideoCanvas(uid: e),
                        connection: RtcConnection(channelId: channel),
                      ),
                    ),
                  ),
            )),
          ),
        ),
      )
    ],
  ),
);

这里的 remoteUid 就是一个保留退出到 channel 的 uid 的 Set 对象。

最终运行成果如下图所示,引擎加载胜利之后,点击 FloatingActionButton 退出,能够看到挪动端和 PC 端都能够失常通信交互,并且不论是通话质量还是画面晦涩度都相当优良,能够感触到声网 SDK 的完成度还是相当之高的。

红色是我本人加上的打码。

在应用该例子测试了 12 人同时在线通话成果,根本和微信视频会议没有差异,以下是残缺代码:


class VideoChatPage extends StatefulWidget {const VideoChatPage({Key? key}) : super(key: key);

  @override
  State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
  late final RtcEngine _engine;

  /// 初始化状态
  late final Future<bool?> initStatus;

  /// 是否退出聊天
  bool isJoined = false;

  /// 记录退出的用户 id
  Set<int> remoteUid = {};

  @override
  void initState() {super.initState();
    initStatus = _requestPermissionIfNeed().then((value) async {await _initEngine();
      return true;
    }).whenComplete(() => setState(() {}));
  }

  Future<void> _requestPermissionIfNeed() async {await [Permission.microphone, Permission.camera].request();}

  Future<void> _initEngine() async {
    // 创立 RtcEngine
    _engine = createAgoraRtcEngine();
    // 初始化 RtcEngine
    await _engine.initialize(RtcEngineContext(appId: appId,));

    _engine.registerEventHandler(RtcEngineEventHandler(
      // 遇到谬误
      onError: (ErrorCodeType err, String msg) {print('[onError] err: $err, msg: $msg');
      },
      onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 退出频道胜利
        setState(() {isJoined = true;});
      },
      onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
        // 有用户退出
        setState(() {remoteUid.add(rUid);
        });
      },
      onUserOffline:
          (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
        // 有用户离线
        setState(() {remoteUid.removeWhere((element) => element == rUid);
        });
      },
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // 来到频道
        setState(() {
          isJoined = false;
          remoteUid.clear();});
      },
    ));

    // 关上视频模块反对
    await _engine.enableVideo();
    // 配置视频编码器,编码视频的尺寸(像素),帧率
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );

    await _engine.startPreview();}

  @override
  Widget build(BuildContext context) {
    return Scaffold(appBar: AppBar(),
      body: Stack(
        children: [
          FutureBuilder<bool?>(
              future: initStatus,
              builder: (context, snap) {if (snap.data != true) {
                  return Center(
                    child: new Text(
                      "初始化 ing",
                      style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                return AgoraVideoView(
                  controller: VideoViewController(
                    rtcEngine: _engine,
                    canvas: const VideoCanvas(uid: 0),
                  ),
                );
              }),
          Align(
            alignment: Alignment.topLeft,
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: List.of(remoteUid.map((e) => SizedBox(
                    width: 120,
                    height: 120,
                    child: AgoraVideoView(
                      controller: VideoViewController.remote(
                        rtcEngine: _engine,
                        canvas: VideoCanvas(uid: e),
                        connection: RtcConnection(channelId: channel),
                      ),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(onPressed: () async {
          // 退出频道
          _engine.joinChannel(
            token: token,
            channelId: channel,
            uid: 0,
            options: ChannelMediaOptions(
              channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
              clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
    );
  }

进阶调整

最初咱们再来个进阶调整,后面 remoteUid 保留的只是近程用户 id,如果咱们将 remoteUid 批改为 remoteControllers 用于保留 VideoViewController,那么就能够简略实现画面切换,比方「点击用户画面实现大小切换」这样的需要。

如下代码所示,简略调整后逻辑为:

  • remoteUid 从保留近程用户 id 变成了 remoteControllers 的 Map<int,VideoViewController>
  • 新增了 currentController 用于保留以后大画面下的 VideoViewController,默认是用户本人
  • registerEventHandler 里将 uid 保留更改为 VideoViewController 的创立和保留
  • 在小窗处减少 InkWell 点击,在单击之后切换 VideoViewController 实现画面切换
class VideoChatPage extends StatefulWidget {const VideoChatPage({Key? key}) : super(key: key);

  @override
  State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
  late final RtcEngine _engine;

  /// 初始化状态
  late final Future<bool?> initStatus;

  /// 以后 controller
  late VideoViewController currentController;

  /// 是否退出聊天
  bool isJoined = false;

  /// 记录退出的用户 id
  Map<int, VideoViewController> remoteControllers = {};

  @override
  void initState() {super.initState();
    initStatus = _requestPermissionIfNeed().then((value) async {await _initEngine();
      /// 构建以后用户 currentController
      currentController = VideoViewController(
        rtcEngine: _engine,
        canvas: const VideoCanvas(uid: 0),
      );
      return true;
    }).whenComplete(() => setState(() {}));
  }

  Future<void> _requestPermissionIfNeed() async {await [Permission.microphone, Permission.camera].request();}

  Future<void> _initEngine() async {
    // 创立 RtcEngine
    _engine = createAgoraRtcEngine();
    // 初始化 RtcEngine
    await _engine.initialize(RtcEngineContext(appId: appId,));

    _engine.registerEventHandler(RtcEngineEventHandler(
      // 遇到谬误
      onError: (ErrorCodeType err, String msg) {print('[onError] err: $err, msg: $msg');
      },
      onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 退出频道胜利
        setState(() {isJoined = true;});
      },
      onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
        // 有用户退出
        setState(() {remoteControllers[rUid] = VideoViewController.remote(
            rtcEngine: _engine,
            canvas: VideoCanvas(uid: rUid),
            connection: RtcConnection(channelId: channel),
          );
        });
      },
      onUserOffline:
          (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
        // 有用户离线
        setState(() {remoteControllers.remove(rUid);
        });
      },
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // 来到频道
        setState(() {
          isJoined = false;
          remoteControllers.clear();});
      },
    ));

    // 关上视频模块反对
    await _engine.enableVideo();
    // 配置视频编码器,编码视频的尺寸(像素),帧率
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );

    await _engine.startPreview();}

  @override
  Widget build(BuildContext context) {
    return Scaffold(appBar: AppBar(),
      body: Stack(
        children: [
          FutureBuilder<bool?>(
              future: initStatus,
              builder: (context, snap) {if (snap.data != true) {
                  return Center(
                    child: new Text(
                      "初始化 ing",
                      style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                return AgoraVideoView(controller: currentController,);
              }),
          Align(
            alignment: Alignment.topLeft,
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                /// 减少点击切换
                children: List.of(remoteControllers.entries.map((e) => InkWell(onTap: () {setState(() {remoteControllers[e.key] = currentController;
                        currentController = e.value;
                      });
                    },
                    child: SizedBox(
                      width: 120,
                      height: 120,
                      child: AgoraVideoView(controller: e.value,),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(onPressed: () async {
          // 退出频道
          _engine.joinChannel(
            token: token,
            channelId: channel,
            uid: 0,
            options: ChannelMediaOptions(
              channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
              clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
    );
  }
}

残缺代码如上图所示,运行后成果如下图所示,能够看到画面在点击之后能够完满切换,这里次要提供一个大体思路,如果有趣味的能够本人优化并增加切换动画成果。

另外如果你想切换前后摄像头,能够通过 _engine.switchCamera(); 等 API 简略实现。

总结

从下面能够看到,其实跑完根底流程很简略,回顾一下后面的内容,总结下来就是:

  • 申请麦克风和摄像头权限
  • 创立和通过 App ID 初始化引擎
  • 注册 RtcEngineEventHandler 回调用于判断状态
  • 关上和配置视频编码反对,并且启动预览 startPreview
  • 调用 joinChannel 退出对应频道
  • 通过 AgoraVideoView 和 VideoViewController 配置显示本地和近程用户画面

当然,声网 SDK  在多人视频通话畛域还领有各类丰盛的底层接口,例如虚构背景、美颜、空间音效、音频混合等等,这些咱们前面在进阶内容里讲到,更多 API 成果能够查阅 Flutter RTC  API 获取。

额定拓展

最初做个内容拓展,这部分和理论开发可能没有太大关系,纯正是一些技术补充。

如果应用过 Flutter 开发过视频类相干我的项目的应该晓得,Flutter 里能够应用外界纹理和 PlatfromView 两种形式实现画面接入,而由此对应的是 AgoraVideoView 在应用 VideoViewController 时,是有 useFlutterTexture 和 useAndroidSurfaceView 两个可选参数。

这里咱们不探讨它们之间的优劣和差别,只是让大家能够更直观了解声网 SDK 在不同平台渲染时的差别,作为拓展知识点补充。

首先咱们看 useFlutterTexture,从源码中咱们能够看到:

  • 在 macOS 和 windows 版本中,声网 SDK 默认只反对 Texture 这种外界纹理的实现,这次要是因为 PC 端的一些 API 限度导致。
  • Android 上并不反对配置为 Texture,只反对 PlatfromView 模式,这里应该是基于性能思考。
  • 只有 iOS 反对 Texture 模式或者 PlatfromView 的渲染模式可抉择,所以  useFlutterTexture 更多是针对 iOS 失效。

而针对 useAndroidSurfaceView 参数,从源码中能够看到,它目前只对 android 平台失效,然而如果你去看原生平台的 java 源码实现,能够看到其实不论是 AgoraTextureView 配置还是 AgoraSurfaceView 配置,最终 Android 平台上还是应用 TextureView 渲染,所以这个参数目前来看不会有理论的作用。

最初,就像后面说的,声网 SDK 是通过 Dart FFI 调用底层动静库进行反对,而这些调用目前看是通过 AgoraRtcWrapper 进行,比方通过 libAgoraRtcWrapper.so 再去调用 lib-rtc-sdk.so,如果对于这一块感兴趣的,能够持续深刻摸索一下。

正文完
 0