写点什么

如何用 Flutter 开发一个直播应用

用户头像
声网Agora
关注
发布于: 16 分钟前
如何用 Flutter开发一个直播应用

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



用户头像

声网Agora

关注

还未添加个人签名 2021.02.05 加入

声网 Agora 是实时互动 API 平台行业开创者,实时互动技术服务覆盖全球 200 多个国家和地区。开发者只需简单调用 API,即可在应用内构建多种实时音视频互动场景。

评论

发布
暂无评论
如何用 Flutter开发一个直播应用