昨天,我在加入在线瑜伽课程时,才意识到我的日常流动中应用了这么多的视频直播 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、技术帮忙,请点击「浏览原文」拜访声网开发者社区。