使用Agora SDK开发Flutter视频互动直播App


昨天,我在参加在线瑜伽课程时,才意识到我的日常活动中使用了这么多的视频直播App–从商务会议到瑜伽课程,还有即兴演奏和电影之夜。对于大多数在家里隔离的人来说,视频直播是接近世界的最好方式。大批涌入的用户观看并开始自己的直播,这在市场上为一个 "完美 "的流媒体app创造了空白。

在这篇文章中,我将引导你使用Agora Flutter SDK构建自己的Flutter互动直播App。你可以按照自己的需求来定制你的应用界面,同时还能够保持最高的视频质量和几乎感受不到的延迟。

先决条件

如果你是Flutter的新手,那么从这里安装Flutter SDK。

项目设置

我们先创建一个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 文件夹,并创建一个像这样的文件结构。
    ROqQvaOwhZqcPN1p

创建主页面

首先,我创建了一个简单的登录表单,需要输入三个信息:用户名、频道名称和用户角色(观众或主播)。你可以根据自己的需要来定制这个界面。

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),
                      )
                    ],
                  ),
                ),
              ],
            ),
          ),
        ));
  }
}

这样就会创建一个类似于这样的用户界面:

|257xauto

每当按下加入按钮,它就会调用 ‘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() 的小组件,其中我们使用了 RtcLocalViewRtcRemoteView.

    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、技术帮助

|600xauto

推荐阅读
相关专栏
SDK 教程
164 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。