本文是由声网社区的开发者“小猿”撰写的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) 进行封装调用。
这样做的好处在于:
如果说这样做有什么坏处,那大概就是 SDK 的底层开发和维护成本会剧增,不过从用户角度来看,这无异是一个绝佳的选择。
接下来让我们进入正题,既然选择了 Flutter + 声网的实现路线,那么在开始之前肯定有一些需要准备的前置条件,首先是为了满足声网 RTC SDK 的使用条件,必须是:
从目前 Flutter 和 Dart 版本来看,上面这个要求并不算高,然后就是你需要注册一个声网开发者账号,从而获取后续配置所需的 App ID 和 Token 等配置参数。
如果对后续配置“门清”,可以忽略跳过。
首先可以在声网控制台的项目管理页面上点击「创建项目」,然后在弹出框选输入项目名称,之后选择「视频通话」场景和「安全模式(APP ID + Token)」 即可完成项目创建。
根据法规,创建项目需要实名认证,这个必不可少;另外使用场景不必太过纠结,项目创建之后也是可以根据需要自己修改。
成功创建项目之后,在项目列表点击项目「配置」,进入项目详情页面之后,会看到基本信息栏目有个 App ID 的字段,点击如下图所示图标,即可获取项目的 App ID。
App ID 也算是敏感信息之一,所以尽量妥善保存,避免泄密。
为提高项目的安全性,声网推荐了使用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。
NSCameraUsageDescription
*****
NSMicrophoneUsageDescription
*****
在正式调用声网 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 个:
///是否加入聊天
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 配置 :
Future _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(
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 方法加入对应频道,以下的参数都是必须的,其中:
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),
),
);
}),
);
同样的道理,通过前面的 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 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 ,那么就可以简单实现画面切换,比如「点击用户画面实现大小切换」这样的需求。
如下代码所示,简单调整后逻辑为:
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 简单实现。
从上面可以看到,其实跑完基础流程很简单,回顾一下前面的内容,总结下来就是:
当然,声网 SDK 在多人视频通话领域还拥有各类丰富的底层接口,例如虚拟背景、美颜、空间音效、音频混合等等,这些我们后面在进阶内容里讲到,更多 API 效果可以查阅 Flutter RTC API 获取。
最后做个内容拓展,这部分和实际开发可能没有太大关系,纯粹是一些技术补充。
如果使用过 Flutter 开发过视频类相关项目的应该知道,Flutter 里可以使用外界纹理和PlatfromView两种方式实现画面接入,而由此对应的是 AgoraVideoView 在使用 VideoViewController 时,是有 useFlutterTexture 和 useAndroidSurfaceView 两个可选参数。
这里我们不讨论它们之间的优劣和差异,只是让大家可以更直观理解声网 SDK 在不同平台渲染时的差异,作为拓展知识点补充。
首先我们看 useFlutterTexture,从源码中我们可以看到:
而针对 useAndroidSurfaceView 参数,从源码中可以看到,它目前只对 android 平台生效,但是如果你去看原生平台的 java 源码实现,可以看到其实不管是 AgoraTextureView 配置还是 AgoraSurfaceView 配置,最终 Android 平台上还是使用 TextureView 渲染,所以这个参数目前来看不会有实际的作用。
最后,就像前面说的 , 声网 SDK 是通过 Dart FFI 调用底层动态库进行支持,而这些调用目前看是通过AgoraRtcWrapper进行,比如通过 libAgoraRtcWrapper.so 再去调用 lib-rtc-sdk.so ,如果对于这一块感兴趣的,可以继续深入探索一下。