假如你有一个完结 “多人视频通话” 的场景需求,你会挑选从零完结仍是接第三方 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
装备 :
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(
floatingActionButton: FloatingActionButton(
onPressed: () async {
// 参加频道
_engine.joinChannel(
token: token,
channelId: channel,
uid: 0,
options: ChannelMediaOptions(
channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
clientRoleType: ClientRoleType.clientRoleBroadcaster,
),
);
},
),
);
相同的道理,经过前面的 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
两个可选参数。
这儿咱们不讨论它们之间的优劣和差异,仅仅让咱们能够更直观理解 Agora SDK 在不同渠道烘托时的差异,作为拓宽知识点弥补。
首要咱们看 useFlutterTexture
,从源码中咱们能够看到:
- 在 macOS 和 windows 版别中,Agora 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
,假如关于这一块感爱好的,能够继续深入探索一下。