昨天,我在参加在线瑜伽课程时,才意识到我的日常活动中使用了这么多的视频直播App–从商务会议到瑜伽课程,还有即兴演奏和电影之夜。对于大多数在家里隔离的人来说,视频直播是接近世界的最好方式。大批涌入的用户观看并开始自己的直播,这在市场上为一个 "完美 "的流媒体app创造了空白。
在这篇文章中,我将引导你使用Agora Flutter SDK构建自己的Flutter互动直播App。你可以按照自己的需求来定制你的应用界面,同时还能够保持最高的视频质量和几乎感受不到的延迟。
先决条件
如果你是Flutter的新手,那么从这里安装Flutter SDK。
- Agora Flutter SDK v3.2.1
- Agora Flutter RTM SDK v0.9.14
- VS Code或您选择的其他IDE
- Agora 开发者账户(官网注册)
项目设置
我们先创建一个Flutter项目。打开你的终端,导航到你开发用的文件夹,然后输入以下内容。
flutter create agora_live_streaming
-
导航到你的 ‘pubspec.yaml’ 文件,在该文件中,添加以下依赖项:
dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.0 permission_handler: ^5.1.0+2 agora_rtc_engine: ^3.2.1 agora_rtm: ^0.9.14
-
在添加文件压缩包的时候,要注意缩进,以免出错。
在你的项目文件夹中,运行以下命令来安装所有的依赖项。flutter pub get
-
一旦我们有了所有的依赖项,我们就可以创建文件结构了。
导航到 lib 文件夹,并创建一个像这样的文件结构。
创建主页面
首先,我创建了一个简单的登录表单,需要输入三个信息:用户名、频道名称和用户角色(观众或主播)。你可以根据自己的需要来定制这个界面。
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _username = TextEditingController();
final _channelName = TextEditingController();
bool _isBroadcaster = false;
String check = '';
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
body: Center(
child: SingleChildScrollView(
physics: NeverScrollableScrollPhysics(),
child: Stack(
children: <Widget>[
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(30.0),
child: Image.network(
'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png',
scale: 1.5,
),
),
Container(
width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.height * 0.2,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
TextFormField(
controller: _username,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: Colors.grey),
),
hintText: 'Username',
),
),
TextFormField(
controller: _channelName,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: Colors.grey),
),
hintText: 'Channel Name',
),
),
],
),
),
Container(
width: MediaQuery.of(context).size.width * 0.65,
padding: EdgeInsets.symmetric(vertical: 10),
child: SwitchListTile(
title: _isBroadcaster
? Text('Broadcaster')
: Text('Audience'),
value: _isBroadcaster,
activeColor: Color.fromRGBO(45, 156, 215, 1),
secondary: _isBroadcaster
? Icon(
Icons.account_circle,
color: Color.fromRGBO(45, 156, 215, 1),
)
: Icon(Icons.account_circle),
onChanged: (value) {
setState(() {
_isBroadcaster = value;
print(_isBroadcaster);
});
}),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 25),
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(20)),
child: MaterialButton(
onPressed: onJoin,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Join ',
style: TextStyle(
color: Colors.white,
letterSpacing: 1,
fontWeight: FontWeight.bold,
fontSize: 20),
),
Icon(
Icons.arrow_forward,
color: Colors.white,
)
],
),
),
),
),
Text(
check,
style: TextStyle(color: Colors.red),
)
],
),
),
],
),
),
));
}
}
这样就会创建一个类似于这样的用户界面:
每当按下加入按钮,它就会调用 ‘onJoin’ 函数,该函数首先获得用户在通话过程中访问其摄像头和麦克风的权限。一旦用户授予这些权限,我们就进入下一个页面,
‘broadcast_page.dart’
Future<void> onJoin() async {
if (_username.text.isEmpty || _channelName.text.isEmpty) {
setState(() {
check = 'Username and Channel Name are required fields';
});
} else {
setState(() {
check = '';
});
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BroadcastPage(
userName: _username.text,
channelName: _channelName.text,
isBroadcaster: _isBroadcaster,
),
),
);
}
}
为了要求用户访问摄像头和麦克风,我们使用一个名为permission_handler的包。这里我声明了一个名为 _handleCameraAndMic(),
的函数,我将在 onJoin()
函数中引用它 。
Future<void> onJoin() async {
if (_username.text.isEmpty || _channelName.text.isEmpty) {
setState(() {
check = 'Username and Channel Name are required fields';
});
} else {
setState(() {
check = '';
});
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BroadcastPage(
userName: _username.text,
channelName: _channelName.text,
isBroadcaster: _isBroadcaster,
),
),
);
}
}
建立我们的流媒体页面
默认情况下,观众的摄像头是禁用的,麦克风也是静音的,但主播有完全的权限。所以我们在构建界面的时候,会使用客户端角色来进行相应的样式设计。
每当用户选择观众角色时,我们希望这个页面被调用,在这里他们可以查看主播的流,并可以选择使用聊天选项与主播互动。
但当用户选择主播角色时,可以看到该频道中其他主播的信息流,并可以选择与频道中在场的所有人(主播和观众)进行互动。
牢记这些,下面我们开始创建界面。
class BroadcastPage extends StatefulWidget {
final String channelName;
final String userName;
final bool isBroadcaster;
const BroadcastPage({Key key, this.channelName, this.userName, this.isBroadcaster}) : super(key: key);
@override
_BroadcastPageState createState() => _BroadcastPageState();
}
class _BroadcastPageState extends State<BroadcastPage> {
final _users = <int>[];
final _infoStrings = <String>[];
RtcEngine _engine;
bool muted = false;
@override
void dispose() {
// clear users
_users.clear();
// destroy sdk and leave channel
_engine.destroy();
super.dispose();
}
@override
void initState() {
super.initState();
// initialize agora sdk
initialize();
}
Future<void> initialize() async {
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Stack(
children: <Widget>[
_viewRows(),
_toolbar(),
],
),
),
);
}
}
在这里,我创建了一个名为BroadcastPage的有状态小部件,它的构造函数包括了频道名称、用户名和isBroadcaster(布尔值)的值。
在我们的BroadcastPage类中,我们声明一个Agora提供的RtcEngine类的对象。为了初始化这个对象,我们创建一个initState()方法,在这个方法中我们调用了初始化函数。
initialize()函数不仅初始化Agora SDK,它也是我将调用的其他主要函数的函数,如 _initAgoraRtcEngine()
, _addAgoraEventHandlers()
, 和 joinChannel()
。
Future<void> initialize() async {
print('Client Role: ${widget.isBroadcaster}');
if (appId.isEmpty) {
setState(() {
_infoStrings.add(
'APP_ID missing, please provide your APP_ID in settings.dart',
);
_infoStrings.add('Agora Engine is not starting');
});
return;
}
await _initAgoraRtcEngine();
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.channelName, null, 0);
}
现在让我们来了解一下我们的 initialize()
函数中调用的这三个函数的意义。
-
_initAgoraRtcEngine()
用于创建Agora SDK的实例。使用你从Agora开发者后台得到的项目App ID来初始化它。在这里面,我们使用enableVideo()
函数来启用视频模块。为了将频道配置文件从视频通话(默认值)改为直播,我们调用setChannelProfile()
方法,然后设置用户角色。Future<void> _initAgoraRtcEngine() async { _engine = await RtcEngine.create(appId); await _engine.enableVideo(); await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting); if (widget.isBroadcaster) { await _engine.setClientRole(ClientRole.Broadcaster); } else { await _engine.setClientRole(ClientRole.Audience); } }
-
_addAgoraEventHandlers()
是一个处理所有主要回调函数的函数。我们从setEventHandler()
开始,它监听engine事件并接收相应RtcEngine的统计数据。
一些重要的回调包括
-
joinChannelSuccess()
在本地用户加入指定频道时被触发。它返回频道名,用户的uid,以及本地用户加入频道所需的时间(以毫秒为单位)。 -
leaveChannel()
与joinChannelSuccess()
相反,因为它是在用户离开频道时触发的。每当用户离开频道时,它就会返回调用的统计信息。这些统计包括延迟、CPU使用率、持续时间等。 -
userJoined()
是一个当远程用户加入一个特定频道时被触发的方法。一个成功的回调会返回远程用户的id和经过的时间。 -
userOffline()
与userJoined()
相反,因为它发生在用户离开频道的时候。一个成功的回调会返回uid和离线的原因,包括掉线、退出等。 -
firstRemoteVideoFrame()
是一个当远程视频的第一个视频帧被渲染时被调用的方法,它可以帮助你返回uid、宽度、高度和经过的时间。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, elapsed) { setState(() { final info = 'userOffline: $uid'; _infoStrings.add(info); _users.remove(uid); }); }, )); ` }`
-
joinChannel()
一个频道在视频通话中就是一个房间。一个joinChannel()
函数可以帮助用户订阅一个特定的频道。这可以使用我们的RtcEngine对象来声明:
await _engine.joinChannel(token, "channel-name", "Optional Info", uid);
注意:此项目是 开发环境,仅供参考 , 请勿直接 用于生产环境。建议在生产环境中运行的所有RTE App都使用 Token鉴权 。关于Agora平台中基于 Token鉴权 的更多信息,请参考 本指南 。
以上总结了制作这个实时互动视频直播所需的所有功能和方法。现在我们可以制作我们的组件了,它将负责我们应用的完整用户界面。
在我的构建方法中,我声明了两个小部件( _viewRows()
和 _toolbar()
,它们负责显示主播的网格,以及一个由断开、静音、切换摄像头和消息按钮组成的工具栏。
我们从 _viewRows()
开始。为此,我们需要知道主播和他们的uid来显示他们的视频。我们需要一个带有他们uid的本地和远程用户的通用列表。为了实现这一点,我们创建一个名为 _getRendererViews()
的小组件,其中我们使用了 RtcLocalView
和 RtcRemoteView.
。
List<Widget> _getRenderViews() {
final List<StatefulWidget> list = [];
if(widget.isBroadcaster) {
list.add(RtcLocalView.SurfaceView());
}
_users.forEach((int uid) => list.add(RtcRemoteView.SurfaceView(uid: uid)));
return list;
}
/// Video view wrapper
Widget _videoView(view) {
return Expanded(child: Container(child: view));
}
/// Video view row wrapper
Widget _expandedVideoRow(List<Widget> views) {
final wrappedViews = views.map<Widget>(_videoView).toList();
return Expanded(
child: Row(
children: wrappedViews,
),
);
}
/// Video layout wrapper
Widget _viewRows() {
final views = _getRenderViews();
switch (views.length) {
case 1:
return Container(
child: Column(
children: <Widget>[_videoView(views[0])],
));
case 2:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow([views[0]]),
_expandedVideoRow([views[1]])
],
));
case 3:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 3))
],
));
case 4:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 4))
],
));
default:
}
return Container();
}
有了它,你就可以开发一个完整的Flutter互动直播App。为了增加断开通话、静音、切换摄像头和消息等功能,我们将创建一个名为_ _toolbar()
有四个按钮的基本小组件。然后根据用户角色对这些按钮进行样式设计,这样观众只能进行聊天,而主播则可以使用所有的功能:
Widget _toolbar() {
return widget.isBroadcaster
? Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 48),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RawMaterialButton(
onPressed: _onToggleMute,
child: Icon(
muted ? Icons.mic_off : Icons.mic,
color: muted ? Colors.white : Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: muted ? Colors.blueAccent : Colors.white,
padding: const EdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: () => _onCallEnd(context),
child: Icon(
Icons.call_end,
color: Colors.white,
size: 35.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.redAccent,
padding: const EdgeInsets.all(15.0),
),
RawMaterialButton(
onPressed: _onSwitchCamera,
child: Icon(
Icons.switch_camera,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: _goToChatPage,
child: Icon(
Icons.message_rounded,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
),
],
),
)
: Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 48),
child: RawMaterialButton(
onPressed: _goToChatPage,
child: Icon(
Icons.message_rounded,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
),
);
}
让我们来看看我们声明的四个功能:
-
_onToggleMute()
可以让你的数据流静音或者取消静音。这里,我们使用muteLocalAudioStream()
方法,它采用一个布尔输入来使数据流静音或取消静音。void _onToggleMute() { setState(() { muted = !muted; }); _engine.muteLocalAudioStream(muted); }
-
_onSwitchCamera()
可以让你在前摄像头和后摄像头之间切换。在这里,我们使用switchCamera()方法,它可以帮助你实现所需的功能。void _onSwitchCamera() { _engine.switchCamera(); }
-
_onCallEnd()
断开呼叫并将用户带回主页 。
void _onCallEnd(BuildContext context) { Navigator.pop(context); }
-
_goToChatPage() 导航到聊天界面。
void _goToChatPage() { Navigator.of(context).push( MaterialPageRoute( builder: (context) => RealTimeMessaging( channelName: widget.channelName, userName: widget.userName, isBroadcaster: widget.isBroadcaster, ),) ); }
建立我们的聊天屏幕
为了扩展观众和主播之间的互动,我们添加了一个聊天页面,任何人都可以发送消息。要做到这一点,我们使用Agora Flutter RTM包,它提供了向特定同行发送消息或向频道广播消息的选项。在本教程中,我们将把消息广播到频道上。
我们首先创建一个有状态的小组件,它的构造函数拥有所有的输入值:频道名称、用户名和isBroadcaster。我们将在我们的逻辑中使用这些值,也将在我们的页面设计中使用这些值。
为了初始化我们的SDK,我们声明initState()方法,其中我声明的是_createClient(),它负责初始化。
class RealTimeMessaging extends StatefulWidget {
final String channelName;
final String userName;
final bool isBroadcaster;
const RealTimeMessaging(
{Key key, this.channelName, this.userName, this.isBroadcaster})
: super(key: key);
@override
_RealTimeMessagingState createState() => _RealTimeMessagingState();
}
class _RealTimeMessagingState extends State<RealTimeMessaging> {
bool _isLogin = false;
bool _isInChannel = false;
final _channelMessageController = TextEditingController();
final _infoStrings = <String>[];
AgoraRtmClient _client;
AgoraRtmChannel _channel;
@override
void initState() {
super.initState();
_createClient();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildInfoList(),
Container(
width: double.infinity,
alignment: Alignment.bottomCenter,
child: _buildSendChannelMessage(),
),
],
),
)),
);
}
}
在我们的_createClient()函数中,我们创建一个AgoraRtmClient对象。这个对象将被用来登录和注销一个特定的频道。
void _createClient() async {
_client = await AgoraRtmClient.createInstance(appId);
_client.onMessageReceived = (AgoraRtmMessage message, String peerId) {
_logPeer(message.text);
};
_client.onConnectionStateChanged = (int state, int reason) {
print('Connection state changed: ' +
state.toString() +
', reason: ' +
reason.toString());
if (state == 5) {
_client.logout();
print('Logout.');
setState(() {
_isLogin = false;
});
}
};
_toggleLogin();
_toggleJoinChannel();
}
在我的_createClient()函数中,我引用了另外两个函数:
-
_toggleLogin()使用AgoraRtmClient对象来登录和注销一个频道。它需要一个Token和一个用户ID作为参数。这里,我使用用户名作为用户ID。
void _toggleLogin() async { if (!_isLogin) { try { await _client.login(null, widget.userName); print('Login success: ' + widget.userName); setState(() { _isLogin = true; }); } catch (errorCode) { print('Login error: ' + errorCode.toString()); } } }
-
_toggleJoinChannel()创建了一个AgoraRtmChannel对象,并使用这个对象来订阅一个特定的频道。这个对象将被用于所有的回调,当一个成员加入,一个成员离开,或者一个用户收到消息时,回调都会被触发。
void _toggleJoinChannel() async { try { _channel = await _createChannel(widget.channelName); await _channel.join(); print('Join channel success.'); setState(() { _isInChannel = true; }); } catch (errorCode) { print('Join channel error: ' + errorCode.toString()); } }
到这里,你将拥有一个功能齐全的聊天应用。现在我们可以制作小组件了,它将负责我们应用的完整用户界面。
在我的构建中,我声明了两个小组件: _buildSendChannelMessage()
和 _buildInfoList().
_buildSendChannelMessage()
创建一个输入字段并触发一个函数来发送消息。
_buildInfoList()
对消息进行样式设计,并将它们放在唯一 的容器中。你可以根据设计需求来定制这些小组件。
这里有两个小组件:
-
_buildSendChannelMessage()
我已经声明了一个Row,它添加了一个文本输入字段和一 个按钮,这个按钮在被按下时调用_toggleSendChannelMessage
。Widget _buildSendChannelMessage() { if (!_isLogin || !_isInChannel) { return Container(); } return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ Container( width: MediaQuery.of(context).size.width * 0.75, child: TextFormField( showCursor: true, enableSuggestions: true, textCapitalization: TextCapitalization.sentences, controller: _channelMessageController, decoration: InputDecoration( hintText: 'Comment...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: BorderSide(color: Colors.grey, width: 2), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: BorderSide(color: Colors.grey, width: 2), ), ), ), ), Container( decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(40)), border: Border.all( color: Colors.blue, width: 2, )), child: IconButton( icon: Icon(Icons.send, color: Colors.blue), onPressed: _toggleSendChannelMessage, ), ) ], ); }
-
这个函数调用我们之前声明的对象使用的AgoraRtmChannel类中的sendMessage()方法。这用到一个类型为 AgoraRtmMessage的输入。
void _toggleSendChannelMessage() async { String text = _channelMessageController.text; if (text.isEmpty) { print('Please input text to send.'); return; } try { await _channel.sendMessage(AgoraRtmMessage.fromText(text)); _log(text); _channelMessageController.clear(); } catch (errorCode) { print('Send channel message error: ' + errorCode.toString()); } }
-
‘_buildInfoList()’ 将所有本地消息排列在右边,而用户收到的所有消息则在左边。然后,这个文本消息被包裹在一个容器内,并根据你的需要进行样式设计。
Widget _buildInfoList() {
return Expanded(
child: Container(
child: _infoStrings.length > 0
? ListView.builder(
reverse: true,
itemBuilder: (context, i) {
return Container(
child: ListTile(
title: Align(
alignment: _infoStrings[i].startsWith('%')
? Alignment.bottomLeft
: Alignment.bottomRight,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3),
color: Colors.grey,
child: Column(
crossAxisAlignment: _infoStrings[i].startsWith('%') ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
_infoStrings[i].startsWith('%')
? Text(
_infoStrings[i].substring(1),
maxLines: 10,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(color: Colors.black),
)
: Text(
_infoStrings[i],
maxLines: 10,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(color: Colors.black),
),
Text(
widget.userName,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 10,
),
)
],
),
),
),
),
);
},
itemCount: _infoStrings.length,
)
: Container()));
}
测试
一旦我们完成了实时直播应用的构建,我们可以在我们的设备上进行测试。在终端中找到你的项目目录,然后运行这个命令。
flutter run
结论
恭喜,你已经完成了自己的Flutter互动直播App,使用Agora Flutter SDK构建了这个应用,并通过Agora Flutter RTM SDK实现了交互。
你可以在这里获得该应用程序的完整代码。
其他资源
要了解更多关于Agora Flutter SDK和其他用例的信息,请看这里的开发者指南。
您还可以在这里查看上面讨论的功能和许多其他功能的完整文档。
获取更多文档、Demo、技术帮助
- 获取 SDK 开发文档,可访问声网文档中心。
- 如需参考各类场景 Demo,可访问下载中心获取。
- 如遇开发疑难,可访问论坛发帖提问。
- 了解更多教程、RTE 技术干货与技术活动,可访问声网开发者社区。
- 欢迎扫码关注我们。