昨天,我在加入在线瑜伽课程时,才意识到我的日常流动中应用了这么多的视频直播 App--从商务会议到瑜伽课程,还有即兴演奏和电影之夜。对于大多数居家隔离的人来说,视频直播是靠近世界的最好形式。海量用户的观看和直播,也让“完满的流媒体 App”成为了新的市场诉求。
在这篇文章中,我将疏导你应用声网Agora Flutter SDK 开发本人的直播 App。你能够依照本人的需要来定制你的利用界面,同时还可能放弃最高的视频品质和简直感触不到的提早。
开发环境
如果你是 Flutter 的老手,那么请拜访 Flutter 官网装置 Flutter。
- 在https://pub.dev/搜寻“Agora”,下载声网Agora Flutter SDK v3.2.1
- 在https://pub.dev/搜寻“Agora”,声网Agora Flutter RTM SDK v0.9.14
- VS Code 或其余 IDE
- 声网Agora 开发者账户,请拜访 Agora.io 注册
我的项目设置
咱们先创立一个 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), ) ], ), ), ], ), ), )); }}
这样就会创立一个相似于这样的用户界面:
每当按下“退出(Join)”按钮,它就会调用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 的 StatefulWidget,它的构造函数包含了频道名称、用户名和 isBroadcaster(布尔值)的值。
在咱们的 BroadcastPage 类中,咱们申明一个 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鉴权的更多信息,请参考声网文档核心:https://docs.agora.io/cn。
以上总结了制作这个实时互动视频直播所需的所有性能和办法。当初咱们能够制作咱们的组件了,它将负责咱们利用的残缺用户界面。
在我的办法中,我申明了两个小部件(_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(); }
有了它,你就能够实现一个残缺的视频通话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和一个 user 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
论断
祝贺,你曾经实现了本人的实时互动视频直播利用,应用声网Agora Flutter SDK开发了这个利用,并通过声网Agora Flutter RTM SDK实现了交互。
获取本文的 Demo:https://github.com/Meherdeep/Interactive-Broadcasting
获取更多教程、Demo、技术帮忙,请点击「浏览原文」拜访声网开发者社区。