基于声网 Flutter SDK 实现多人视频通话

前言

本文是由声网社区的开发者“小猿”撰写的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)」 即可完成项目创建。

基于声网 Flutter SDK 实现多人视频通话_第1张图片

根据法规,创建项目需要实名认证,这个必不可少;另外使用场景不必太过纠结,项目创建之后也是可以根据需要自己修改。

获取 App ID

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

基于声网 Flutter SDK 实现多人视频通话_第2张图片

App ID 也算是敏感信息之一,所以尽量妥善保存,避免泄密。

获取 Token

为提高项目的安全性,声网推荐了使用Token对加入频道的用户进行鉴权,在生产环境中,一般为保障安全,是需要用户通过自己的服务器去签发 Token,而如果是测试需要,可以在项目详情页面的“临时 token 生成器”获取临时 Token:

在频道名输出一个临时频道,比如 Test2 ,然后点击生成临时 token 按键,即可获取一个临时 Token,有效期为 24 小时。

基于声网 Flutter SDK 实现多人视频通话_第3张图片

这里得到的 Token 和频道名就可以直接用于后续的测试,如果是用在生产环境上,建议还是在服务端签发 Token ,签发 Token 除了 App ID 还会用到 App 证书,获取 App 证书同样可以在项目详情的应用配置上获取。
基于声网 Flutter SDK 实现多人视频通话_第4张图片

更多服务端签发 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。

 NSCameraUsageDescription
 *****
 NSMicrophoneUsageDescription
 *****

基于声网 Flutter SDK 实现多人视频通话_第5张图片

使用声网 SDK

获取权限

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

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

  _requestPermissionIfNeed();
}

Future _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 initStatus;

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


Future _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 remoteUid = {};

Future _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:配置视频的分辨率尺寸,默认是 640x360
  • frameRate:配置视频的帧率,默认是 15 fps Future _initEngine() async {
  Future _initEngine() async {

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

    await _engine.startPreview();
  }

更多参数配置支持如下所示:
基于声网 Flutter SDK 实现多人视频通话_第6张图片
最后调用 startPreview 开启画面预览功能,接下来只需要把初始化好的 Engine 配置到 AgoraVideoView 控件就可以完成渲染。

渲染画面

接下来就是渲染画面,如下代码所示,在 UI 上加入 AgoraVideoView 控件,并把上面初始化成功_engine,通过VideoViewController配置到 AgoraVideoView ,就可以完成本地视图的预览。

根据前面的initStatus状态,在_engine初始化成功后才加载 AgoraVideoView。

Scaffold(
  appBar: AppBar(),
  body: FutureBuilder(
      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(
      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),
          ),
        );
      }),
);

基于声网 Flutter SDK 实现多人视频通话_第7张图片

基于声网 Flutter SDK 实现多人视频通话_第8张图片

同样的道理,通过前面的 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 的完成度还是相当之高的。

红色是我自己加上的打码。

基于声网 Flutter SDK 实现多人视频通话_第9张图片

在使用该例子测试了 12 人同时在线通话效果,基本和微信视频会议没有差别,以下是完整代码:


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

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

class _VideoChatPageState extends State {
  late final RtcEngine _engine;

  ///初始化状态
  late final Future initStatus;

  ///是否加入聊天
  bool isJoined = false;

  /// 记录加入的用户id
  Set remoteUid = {};

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

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

  Future _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(
              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
  • 新增了currentController用于保存当前大画面下的 VideoViewController ,默认是用户自己
  • registerEventHandler 里将 uid 保存更改为 VideoViewController 的创建和保存
  • 在小窗处增加 InkWell 点击,在单击之后切换 VideoViewController 实现画面切换
class VideoChatPage extends StatefulWidget {
  const VideoChatPage({Key? key}) : super(key: key);

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

class _VideoChatPageState extends State {
  late final RtcEngine _engine;

  ///初始化状态
  late final Future initStatus;

  ///当前 controller
  late VideoViewController currentController;

  ///是否加入聊天
  bool isJoined = false;

  /// 记录加入的用户id
  Map 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 _requestPermissionIfNeed() async {
    await [Permission.microphone, Permission.camera].request();
  }

  Future _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(
              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 在不同平台渲染时的差异,作为拓展知识点补充。

基于声网 Flutter SDK 实现多人视频通话_第10张图片

首先我们看 useFlutterTexture,从源码中我们可以看到:

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

基于声网 Flutter SDK 实现多人视频通话_第11张图片

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

基于声网 Flutter SDK 实现多人视频通话_第12张图片

基于声网 Flutter SDK 实现多人视频通话_第13张图片

最后,就像前面说的 , 声网 SDK 是通过 Dart FFI 调用底层动态库进行支持,而这些调用目前看是通过AgoraRtcWrapper进行,比如通过 libAgoraRtcWrapper.so 再去调用 lib-rtc-sdk.so ,如果对于这一块感兴趣的,可以继续深入探索一下。

基于声网 Flutter SDK 实现多人视频通话_第14张图片

基于声网 Flutter SDK 实现多人视频通话_第15张图片

你可能感兴趣的:(Dev,for,Dev,flutter,音视频,android)