今天,我们将会一起开发一个包含 RTE (实时互动)场景的 Flutter 应用。
项目介绍
靠自研开发包含实时互动功能的应用非常繁琐,你要解决维护服务器、负载均衡等难题,同时还要保证稳定的低延迟。
那么,如何才能在较短的时间内,将实时互动功能添加到 Flutter 应用中?你可以通过声网Agora SDK 来进行开发。在本教程中,我将带大家了解如何使用 Agora Flutter SDK 订阅多个频道的过程。(多频道是什么样场景呢?我们稍后举些例子。)
开发环境
- 网页访问 Agora.io,注册一个Agora开发者账户。
- 下载 Flutter SDK:https://docs.agora.io/cn/All/downloads
- 已安装 VS Code 或 Android Studio
- 对 Flutter 开发的基本了解
为什么要加入多个频道?
在进入正式开发之前,我们先看看为什么有人或者说实时互动场景需要订阅多个频道。
加入多个频道的主要原因是可以同时跟踪多个群组的实时互动活动,或者同时与各个群组互动。各种使用场景包括线上的分组讨论室、多会议场景、等待室、活动会议等。
项目设置
我们先创建一个 Flutter 项目。打开你的终端,找到你的开发文件夹,然后输入以下内容。
flutter create agora_multi_channel_demo
找到 pubspec.yaml,并在该文件中添加以下依赖项。
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.0
agora_rtc_engine: ^3.2.1
permission_handler: ^5.1.0+2
在添加包的时候要注意这边的缩进,否则可能会出现错误。
在你的项目文件夹中,运行以下命令来安装所有的依赖项:
flutter pub get
一旦我们有了所有的依赖项,就可以创建文件结构了。找到 lib 文件夹,创建一个像这样的文件目录结构:
创建登录页面
登录页面只需读取用户想要加入的两个频道即可。在本教程中,我们只保留两个频道,但如果你想的话也可以加入更多的频道:
import 'package:agora_multichannel_video/pages/lobby_page.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginPage extends StatefulWidget {
@override
_LoginPageState createState() => _LoginPageState();
}
class _LoginPageState extends State {
final rteChannelNameController = TextEditingController();
final rtcChannelNameController = TextEditingController();
bool _validateError = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text('Agora Multi-Channel Demo'),
elevation: 0,
),
body: SafeArea(
child: SingleChildScrollView(
clipBehavior: Clip.antiAliasWithSaveLayer,
physics: BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.12,
),
Center(
child: Image(
image: NetworkImage(
'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png'),
height: MediaQuery.of(context).size.height * 0.17,
),
),
SizedBox(
height: MediaQuery.of(context).size.height * 0.1,
),
Container(
width: MediaQuery.of(context).size.width * 0.8,
child: TextFormField(
controller: rteChannelNameController,
decoration: InputDecoration(
labelText: 'Broadcast channel Name',
labelStyle: TextStyle(color: Colors.black54),
errorText:
_validateError ? 'Channel name is mandatory' : null,
border: OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2),
borderRadius: BorderRadius.circular(20),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black, width: 2),
borderRadius: BorderRadius.circular(20),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2),
borderRadius: BorderRadius.circular(20),
),
),
),
),
SizedBox(
height: MediaQuery.of(context).size.height * 0.03,
),
Container(
width: MediaQuery.of(context).size.width * 0.8,
child: TextFormField(
controller: rtcChannelNameController,
decoration: InputDecoration(
labelText: 'RTC channel Name',
labelStyle: TextStyle(color: Colors.black54),
errorText:
_validateError ? 'RTC Channel name is mandatory' : null,
border: OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2),
borderRadius: BorderRadius.circular(20),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black, width: 2),
borderRadius: BorderRadius.circular(20),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2),
borderRadius: BorderRadius.circular(20),
),
),
),
),
SizedBox(height: MediaQuery.of(context).size.height * 0.05),
Container(
width: MediaQuery.of(context).size.width * 0.35,
child: MaterialButton(
onPressed: onJoin,
color: Colors.blueAccent,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: MediaQuery.of(context).size.width * 0.01,
vertical: MediaQuery.of(context).size.height * 0.02),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
'Join',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
Icon(
Icons.arrow_forward,
color: Colors.white,
),
],
),
),
),
)
],
),
),
),
);
}
Future onJoin() async {
setState(() {
rteChannelNameController.text.isEmpty &&
rtcChannelNameController.text.isEmpty
? _validateError = true
: _validateError = false;
});
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LobbyPage(
rtcChannelName: rtcChannelNameController.text,
rteChannelName: rteChannelNameController.text,
),
),
);
}
Future _handleCameraAndMic(Permission permission) async {
final status = await permission.request();
print(status);
}
}
在成功提交频道名称时,会触发 PermissionHandler(),这是一个来自外部包(permission_handler)的类,我们将使用这个类来获取用户在调用过程中的摄像头和麦克风的权限。
现在,在我们开始开发我们的可以连接多个频道的大厅之前,在 utils.dart 文件夹下的 utils.dart 中单独保留 App ID。
const appID = '<---Enter your App ID here--->';
创建大厅
如果你了解过多人通话或互动直播,你会发现,我们在这里要写的大部分代码是相似的。这两种情况下的主要区别是,之前我们是依靠一个频道来连接一个群组。但是现在一个人可以同时加入多个频道。
在一个单频道视频通话中,我们看到了如何创建一个 RtcEngine 类的实例并加入一个频道。在这里我们也是以同样的过程开始的,如下:
_engine = await RtcEngine.create(appID);
await _engine.enableVideo();
await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.rteChannelName, null, 0);
注意:该项目是作为开发环境下的参考,不推荐用于生产环境。建议在生产环境中运行的所有 RTE App 都使用Token鉴权。关于 Agora 平台中基于 Token 的身份验证的更多信息,请参考声网官方文档:https://docs.agora.io/cn/。
我们看到,在创建一个RtcEngine实例后,需要将Channel Profile设置为Live Streaming,并根据用户输入加入所需的频道。
_addAgoraEventHandlers() 函数处理了我们在这个项目中需要的所有主要回调。在示例中,我只是想在有他们的 uid 的 RTE 频道中创建一个用户列表。
void _addAgoraEventHandlers() {
_engine.setEventHandler(RtcEngineEventHandler(
error: (code) {
setState(() {
final info = 'onError: $code';
_infoStrings.add(info);
});
},
joinChannelSuccess: (channel, uid, elapsed) {
setState(() {
final info = 'onJoinChannel: $channel, uid: $uid';
_infoStrings.add(info);
});
},
leaveChannel: (stats) {
setState(() {
_infoStrings.add('onLeaveChannel');
_users.clear();
});
},
userJoined: (uid, elapsed) {
setState(() {
final info = 'userJoined: $uid';
_infoStrings.add(info);
_users.add(uid);
});
},
userOffline: (uid, reason) {
setState(() {
final info = 'userOffline: $uid , reason: $reason';
_infoStrings.add(info);
_users.remove(uid);
});
},
));
}
uid 的列表是动态维护的,因为每次用户加入或离开频道时它都会更新。
这就设置了我们的主频道或大厅,在这里可以显示主播直播,现在订阅其他频道需要一个 RtcChannel 的实例,只有这样你才能加入第二个频道。
_channel = await RtcChannel.create(widget.rtcChannelName);
_addRtcChannelEventHandlers();
await _engine.setClientRole(ClientRole.Broadcaster);
await _channel.joinChannel(null, null, 0, ChannelMediaOptions(true, true));
await _channel.publish();
RtcChannel 是用频道名来初始化的,所以我们用用户给的其他输入来处理这个问题。一旦它被初始化,我们调用 ChannelMediaOptions() 类的加入频道函数,这个类寻找两个参数:autoSubscribeAudio 和autoSubscribeVideo。由于它期望的是一个布尔值,你可以根据你的要求传递 ture 或 false。
对于 RtcChannel,我们看到了类似的事件处理程序,不过我们将为该特定频道中的用户创建另一个用户列表。
void _addRtcChannelEventHandlers() {
_channel.setEventHandler(RtcChannelEventHandler(
error: (code) {
setState(() {
_infoStrings.add('Rtc Channel onError: $code');
});
},
joinChannelSuccess: (channel, uid, elapsed) {
setState(() {
final info = 'Rtc Channel onJoinChannel: $channel, uid: $uid';
_infoStrings.add(info);
});
},
leaveChannel: (stats) {
setState(() {
_infoStrings.add('Rtc Channel onLeaveChannel');
_users2.clear();
});
},
userJoined: (uid, elapsed) {
setState(() {
final info = 'Rtc Channel userJoined: $uid';
_infoStrings.add(info);
_users2.add(uid);
});
},
userOffline: (uid, reason) {
setState(() {
final info = 'Rtc Channel userOffline: $uid , reason: $reason';
_infoStrings.add(info);
_users2.remove(uid);
});
},
));
}
_users2 列表中包含了使用 RtcChannel 类创建的频道中所有人的 ID。
有了这个,你就可以在你的应用程序中添加多个频道。接下来,让我们看看我们如何创建 Widget,以便这些视频可以显示在我们的屏幕上。
我们首先添加 RtcEngine 的视图。在这个例子中,我将使用一个占据屏幕最大空间的网格视图。
List _getRenderViews() {
final List list = [];
list.add(RtcLocalView.SurfaceView());
return list;
}
Widget _videoView(view) {
return Expanded(child: Container(child: view));
}
Widget _expandedVideoRow(List views) {
final wrappedViews = views.map(_videoView).toList();
return Expanded(
child: Row(
children: wrappedViews,
),
);
}
Widget _viewRows() {
final views = _getRenderViews();
switch (views.length) {
case 1:
return Container(
child: Column(
children: [_videoView(views[0])],
));
case 2:
return Container(
child: Column(
children: [
_expandedVideoRow([views[0]]),
_expandedVideoRow([views[1]])
],
));
case 3:
return Container(
child: Column(
children: [
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 3))
],
));
case 4:
return Container(
child: Column(
children: [
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 4))
],
));
default:
}
return Container();
}
对于 RtcChannel,我将使用一个位于屏幕底部的可滚动的 ListView。这样一来,用户可以通过滚动列表来查看所有出现在频道中的用户。
List _getRenderRtcChannelViews() {
final List list = [];
_users2.forEach(
(int uid) => list.add(
RtcRemoteView.SurfaceView(
uid: uid,
channelId: widget.rtcChannelName,
renderMode: VideoRenderMode.FILL,
),
),
);
return list;
}
Widget _viewRtcRows() {
final views = _getRenderRtcChannelViews();
if (views.length > 0) {
print("NUMBER OF VIEWS : ${views.length}");
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: views.length,
itemBuilder: (BuildContext context, int index) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 200,
width: MediaQuery.of(context).size.width * 0.25,
child: _videoView(views[index])),
);
},
);
} else {
return Align(
alignment: Alignment.bottomCenter,
child: Container(),
);
}
}
在调用中,你的应用程序的风格或对齐用户视频的方式完全由你决定。需要寻找的关键元素或小组件是 _getRenderViews() 和 _getRenderRtcChannelViews(),它们返回一个用户视频列表。使用这个列表,你可以按照你的选择来定位你的用户和他们的视频,类似于 _viewRows() 和 _viewRtcRows() 小组件。
使用这些小组件,我们可以将它们添加到我们的支架上。在这里,我将使用一个堆栈将_viewRows() 放在 _viewRtcRows 之 上。
Widg et build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Lobby'),
),
body: Stack(
children: [
_viewRows(),
_viewRtcRows(),
_panel()
],
),
);
}
我已经在我们的堆栈中添加了另一个名为 _panel 的小组件,我们使用这个小组件来显示我们频道上发生的所有事件。
Widget _panel() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 48),
alignment: Alignment.topLeft,
child: FractionallySizedBox(
heightFactor: 0.5,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 48),
child: ListView.builder(
reverse: true,
itemCount: _infoStrings.length,
itemBuilder: (BuildContext context, int index) {
if (_infoStrings.isEmpty) {
return null;
}
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 3,
horizontal: 10,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 5,
),
decoration: BoxDecoration(
color: Colors.yellowAccent,
borderRadius: BorderRadius.circular(5),
),
child: Text(
_infoStrings[index],
style: TextStyle(color: Colors.blueGrey),
),
),
)
],
),
);
},
),
),
),
);
}
这样一来,用户就可以添加两个频道并且同时查看。但是让我们思考一个例子,在这个例子中,你需要加入两个以上的频道实时互动。在这种情况下,你可以用一个独特的频道名称简单地创建更多的 RtcChannel 类的实例。使用同一个实例,你就可以加入多个频道。
最后,你需要创建一个 dispose() 方法,来清除两个频道的用户列表,并为我们订阅的所有频道调用 leaveChannel() 方法。
@override
void dispose() {
// clear users
_users.clear();
_users2.clear();
// leave channel
_engine.leaveChannel();
_engine.destroy();
_channel.unpublish();
_channel .leaveChannel();
_channel.destroy();
super.dispose();
}
测试
当应用完成开发后,通过它你可以使用声网Agora SDK 加入多个频道,你可以运行应用并在设备上测试。在你的终端中导航到项目目录,并运行这个命令。
flutter run
结论
通过能够同时加入多个频道的声网Agora Flutter SDK,你已经实现了你自己的直播 App。
获取本文 Demo:https://github.com/Meherdeep/agora-flutter-multi-channel
获取更多教程、Demo、技术帮助,请点击「阅读原文」访问声网开发者社区。