本文是由声网社区的开发者“小猿”撰写的Flutter基础教程系列中的第一篇。本文除了讲述实现多人视频通话的过程,还有一些 Flutter 开发方面的知识点。该系列将基于声网 Fluttter SDK 实现视频通话、互动直播,并尝试虚拟背景等更多功能的实现。
Flutter 天然支持手机端和 PC 端的跨平台能力,并拥有不错的性能表现
声网的 Flutter RTC SDK 同样支持 Android、iOS、MacOS 和 Windows 等平台,同时也是难得针对 Flutter 进行了全平台支持和优化的音视频 SDK
在开始之前,有必要提前简单介绍一下声网的 RTC SDK 相关实现,这也是我选择声网的原因。
声网的 RTC SDK 的逻辑实现都来自于封装好的 C/C++ 等 native 代码,而这些代码会被打包为对应平台的动态链接库,例如
.dll、.so、.dylib,最后通过 Dart 的 FFI(ffigen) 进行封装调用。
Dart 可以和 native SDK 直接通信,减少了 Flutter 和原生平台交互时在 Channel 上的性能开销;
C/C++ 相关实现在获得更好性能支持的同时,也不需要过度依赖原生平台的 API ,可以得到更灵活和安全的 API 支持。
如果说这样做有什么坏处,那大概就是 SDK 的底层开发和维护成本会剧增,不过从用户角度来看,这无异是一个绝佳的选择。
PART 01
开发之前
Flutter 2.0 或更高版本
Dart 2.14.0 或更高版本
可在这个地址注册声网开发者账号:sso2.agora.io/cn/v4/signup/with-sms
如果对后续配置“门清”,可以忽略跳过。
创建项目
创建项目

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

App ID 也算是敏感信息之一,所以尽量妥善保存,避免泄密。
获取 Token
创建项目
为提高项目的安全性,声网推荐了使用Token对加入频道的用户进行鉴权,在生产环境中,一般为保障安全,是需要用户通过自己的服务器去签发 Token,而如果是测试需要,可以在项目详情页面的“临时 token 生成器”获取临时 Token:
在频道名输出一个临时频道,比如 Test2 ,然后点击生成临时 token 按键,即可获取一个临时 Token,有效期为 24 小时。


更多服务端签发 Token 可见 token server 文档 : https://docs.agora.io/cn/video-legacy/token_server?platform=Android
PART 02
开始开发
项目配置
创建项目
首先在Flutter项目的pubspec.yaml文件中添加以下依赖,其中 agora_rtc_engine 这里引入的是 6.1.0 版本 。
其实 permission_handler并不是必须的,只是因为「视频通话」项目必不可少需要申请到「麦克风」和「相机」权限,所以这里推荐使用permission_handler来完成权限的动态申请。
dependencies:flutter:sdk: flutteragora_rtc_engine: ^6.1.0permission_handler: ^10.2.0
AndroidManifest.xml文件上添加 uses-permission,因为 SDK 的 AndroidManifest.xml 已经添加过所需的权限。
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 提前获取所需的麦克风和摄像头权限。
@overridevoid 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;@overridevoid initState() {super.initState();///请求完成权限后,初始化引擎,更新初始化成功状态initStatus = _requestPermissionIfNeed().then((value) async {await _initEngine();return true;}).whenComplete(() => setState(() {}));}Future<void> _initEngine() async {//创建 RtcEngine_engine = createAgoraRtcEngine();// 初始化 RtcEngineawait _engine.initialize(RtcEngineContext(appId: appId,));···}
registerEventHandler注册一系列回调方法,在 RtcEngineEventHandler 里有很多回调通知,而一般情况下我们比如常用到的会是下面这 5 个:
-
onError :判断错误类型和错误信息 -
onJoinChannelSuccess:加入频道成功 -
onUserJoined:有用户加入了频道 -
onUserOffline:有用户离开了频道 -
onLeaveChannel:离开频道
///是否加入聊天bool isJoined = false;/// 记录加入的用户idSet<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:配置视频的分辨率尺寸,默认是 640x360 -
frameRate:配置视频的帧率,默认是 15 fps
Future<void> _initEngine() async {···// 打开视频模块支持await _engine.enableVideo();// 配置视频编码器,编码视频的尺寸(像素),帧率await _engine.setVideoEncoderConfiguration(const VideoEncoderConfiguration(dimensions: VideoDimensions(width: 640, height: 360),frameRate: 15,),);await _engine.startPreview();}
|
|
描述 |
dimensions |
视频编码的分辨率(px)默认值为 640 × 360 |
codecType |
视频编码类型,比如 1 标准 VP8;2 标准 H.264;3:标准 H.265 |
frameRate |
视频编码的帧率(fps),默认值为 15 |
bitrate |
视频编码码率,单位为 Kbps |
minBitrate |
最低编码码率,单位为 Kbps |
orientationMode |
视频编码的方向模式,例如:0(默认)方向一致;1固定横屏;2固定竖屏 |
degradationPreference |
带宽受限时,视频编码降级偏好,例如:为 0(默认)时带宽受限时,视频编码时优先降低视频帧率,维持分辨率不变;为 1 时带宽受限时,视频编码时优先降低视频分辨率,维持视频帧率不变;为 2 时带宽受限时,视频编码时同时降低视频帧率和视频分辨率 |
mirrorMode |
发送编码视频时是否开启镜像模式,只影响远端用户看到的视频画面,默认关闭 |
advanceOptions |
高级选项,比如视频编码器偏好,视频编码的压缩偏好等 |
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);@overridecreateState() => _VideoChatPageState();}class _VideoChatPageState extends State<VideoChatPage> {late final RtcEngine _engine;///初始化状态late final Future<bool?> initStatus;///是否加入聊天bool isJoined = false;记录加入的用户idremoteUid = {};@overridevoid initState() {super.initState();initStatus = _requestPermissionIfNeed().then((value) async {await _initEngine();return true;=> setState(() {}));}_requestPermissionIfNeed() async {await [Permission.microphone, Permission.camera].request();}_initEngine() async {RtcEngine_engine = createAgoraRtcEngine();初始化 RtcEngineawait _engine.initialize(RtcEngineContext(appId: appId,));_engine.registerEventHandler(RtcEngineEventHandler(遇到错误onError: (ErrorCodeType err, String msg) {err: $err, msg: $msg');},onJoinChannelSuccess: (RtcConnection connection, int elapsed) {加入频道成功{isJoined = true;});},onUserJoined: (RtcConnection connection, int rUid, int elapsed) {有用户加入{remoteUid.add(rUid);});},onUserOffline:connection, int rUid, UserOfflineReasonType reason) {有用户离线{=> element == rUid);});},onLeaveChannel: (RtcConnection connection, RtcStats stats) {离开频道{isJoined = false;remoteUid.clear();});},));打开视频模块支持await _engine.enableVideo();配置视频编码器,编码视频的尺寸(像素),帧率await _engine.setVideoEncoderConfiguration(const VideoEncoderConfiguration(dimensions: VideoDimensions(width: 640, height: 360),frameRate: 15,),);await _engine.startPreview();}@overrideWidget 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(=> 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);@overridecreateState() => _VideoChatPageState();}class _VideoChatPageState extends State<VideoChatPage> {late final RtcEngine _engine;///初始化状态late final Future<bool?> initStatus;controllerlate VideoViewController currentController;///是否加入聊天bool isJoined = false;记录加入的用户idVideoViewController> remoteControllers = {};@overridevoid initState() {super.initState();initStatus = _requestPermissionIfNeed().then((value) async {await _initEngine();currentControllercurrentController = VideoViewController(rtcEngine: _engine,canvas: const VideoCanvas(uid: 0),);return true;=> setState(() {}));}_requestPermissionIfNeed() async {await [Permission.microphone, Permission.camera].request();}_initEngine() async {RtcEngine_engine = createAgoraRtcEngine();初始化 RtcEngineawait _engine.initialize(RtcEngineContext(appId: appId,));_engine.registerEventHandler(RtcEngineEventHandler(遇到错误onError: (ErrorCodeType err, String msg) {err: $err, msg: $msg');},onJoinChannelSuccess: (RtcConnection connection, int elapsed) {加入频道成功{isJoined = true;});},onUserJoined: (RtcConnection connection, int rUid, int elapsed) {有用户加入{= VideoViewController.remote(rtcEngine: _engine,canvas: VideoCanvas(uid: rUid),connection: RtcConnection(channelId: channel),);});},onUserOffline:connection, int rUid, UserOfflineReasonType reason) {有用户离线{remoteControllers.remove(rUid);});},onLeaveChannel: (RtcConnection connection, RtcStats stats) {离开频道{isJoined = false;remoteControllers.clear();});},));打开视频模块支持await _engine.enableVideo();配置视频编码器,编码视频的尺寸(像素),帧率await _engine.setVideoEncoderConfiguration(const VideoEncoderConfiguration(dimensions: VideoDimensions(width: 640, height: 360),frameRate: 15,),);await _engine.startPreview();}@overrideWidget 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(=> InkWell(onTap: () {{= 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 配置显示本地和远程用户画面
PART 03
额外拓展
如果使用过 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 渲染,所以这个参数目前来看不会有实际的作用。
AgoraRtcWrapper进行,比如通过 libAgoraRtcWrapper.so 再去调用 lib-rtc-sdk.so ,如果对于这一块感兴趣的,可以继续深入探索一下。



