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

创建主页面

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

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _username = TextEditingController();
  final _channelName = TextEditingController();
  bool _isBroadcaster = false;
  String check = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        resizeToAvoidBottomInset: true,
        body: Center(
          child: SingleChildScrollView(
            physics: NeverScrollableScrollPhysics(),
            child: Stack(
              children: <Widget>[
                Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Padding(
                        padding: const EdgeInsets.all(30.0),
                        child: Image.network(
                          'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png',
                          scale: 1.5,
                        ),
                      ),
                      Container(
                        width: MediaQuery.of(context).size.width * 0.85,
                        height: MediaQuery.of(context).size.height * 0.2,
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: <Widget>[
                            TextFormField(
                              controller: _username,
                              decoration: InputDecoration(
                                border: OutlineInputBorder(
                                  borderRadius: BorderRadius.circular(20),
                                  borderSide: BorderSide(color: Colors.grey),
                                ),
                                hintText: 'Username',
                              ),
                            ),
                            TextFormField(
                              controller: _channelName,
                              decoration: InputDecoration(
                                border: OutlineInputBorder(
                                  borderRadius: BorderRadius.circular(20),
                                  borderSide: BorderSide(color: Colors.grey),
                                ),
                                hintText: 'Channel Name',
                              ),
                            ),
                          ],
                        ),
                      ),
                      Container(
                        width: MediaQuery.of(context).size.width * 0.65,
                        padding: EdgeInsets.symmetric(vertical: 10),
                        child: SwitchListTile(
                            title: _isBroadcaster
                                ? Text('Broadcaster')
                                : Text('Audience'),
                            value: _isBroadcaster,
                            activeColor: Color.fromRGBO(45, 156, 215, 1),
                            secondary: _isBroadcaster
                                ? Icon(
                                    Icons.account_circle,
                                    color: Color.fromRGBO(45, 156, 215, 1),
                                  )
                                : Icon(Icons.account_circle),
                            onChanged: (value) {
                              setState(() {
                                _isBroadcaster = value;
                                print(_isBroadcaster);
                              });
                            }),
                      ),
                      Padding(
                        padding: const EdgeInsets.symmetric(vertical: 25),
                        child: Container(
                          width: MediaQuery.of(context).size.width * 0.85,
                          decoration: BoxDecoration(
                              color: Colors.blue,
                              borderRadius: BorderRadius.circular(20)),
                          child: MaterialButton(
                            onPressed: onJoin,
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: <Widget>[
                                Text(
                                  'Join ',
                                  style: TextStyle(
                                      color: Colors.white,
                                      letterSpacing: 1,
                                      fontWeight: FontWeight.bold,
                                      fontSize: 20),
                                ),
                                Icon(
                                  Icons.arrow_forward,
                                  color: Colors.white,
                                )
                              ],
                            ),
                          ),
                        ),
                      ),
                      Text(
                        check,
                        style: TextStyle(color: Colors.red),
                      )
                    ],
                  ),
                ),
              ],
            ),
          ),
        ));
  }
}

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

每当按下加入按钮,它就会调用 ‘onJoin’ 函数,该函数首先获得用户在通话过程中访问其摄像头和麦克风的权限。一旦用户授予这些权限,我们就进入下一个页面,
‘broadcast_page.dart’

Future<void> onJoin() async {
    if (_username.text.isEmpty || _channelName.text.isEmpty) {
      setState(() {
        check = 'Username and Channel Name are required fields';
      });
    } else {
      setState(() {
        check = '';
      });
      await _handleCameraAndMic(Permission.camera);
      await _handleCameraAndMic(Permission.microphone);
      Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => BroadcastPage(
            userName: _username.text,
            channelName: _channelName.text,
            isBroadcaster: _isBroadcaster,
          ),
        ),
      );
    }
  }

为了要求用户访问摄像头和麦克风,我们使用一个名为permission_handler的包。这里我声明了一个名为 _handleCameraAndMic(), 的函数,我将在 onJoin() 函数中引用它 。

Future<void> onJoin() async {
    if (_username.text.isEmpty || _channelName.text.isEmpty) {
      setState(() {
        check = 'Username and Channel Name are required fields';
      });
    } else {
      setState(() {
        check = '';
      });
      await _handleCameraAndMic(Permission.camera);
      await _handleCameraAndMic(Permission.microphone);
      Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => BroadcastPage(
            userName: _username.text,
            channelName: _channelName.text,
            isBroadcaster: _isBroadcaster,
          ),
        ),
      );
    }
  }

建立我们的流媒体页面

默认情况下,观众的摄像头是禁用的,麦克风也是静音的,但主播有完全的权限。所以我们在构建界面的时候,会使用客户端角色来进行相应的样式设计。

每当用户选择观众角色时,我们希望这个页面被调用,在这里他们可以查看主播的流,并可以选择使用聊天选项与主播互动。

但当用户选择主播角色时,可以看到该频道中其他主播的信息流,并可以选择与频道中在场的所有人(主播和观众)进行互动。

牢记这些,下面我们开始创建界面。

class BroadcastPage extends StatefulWidget {
  final String channelName;
  final String userName;
  final bool isBroadcaster;

  const BroadcastPage({Key key, this.channelName, this.userName, this.isBroadcaster}) : super(key: key);

  @override
  _BroadcastPageState createState() => _BroadcastPageState();
}

class _BroadcastPageState extends State<BroadcastPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  RtcEngine _engine;
  bool muted = false;

  @override
  void dispose() {
    // clear users
    _users.clear();
    // destroy sdk and leave channel
    _engine.destroy();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    // initialize agora sdk
    initialize();
  }

  Future<void> initialize() async {
    
    
  }
  
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Stack(
          children: <Widget>[
            _viewRows(),
            _toolbar(),
          ],
        ),
      ),
    );
  }
}

在这里,我创建了一个名为BroadcastPage的有状态小部件,它的构造函数包括了频道名称、用户名和isBroadcaster(布尔值)的值。

在我们的BroadcastPage类中,我们声明一个Agora提供的RtcEngine类的对象。为了初始化这个对象,我们创建一个initState()方法,在这个方法中我们调用了初始化函数。

initialize()函数不仅初始化Agora SDK,它也是我将调用的其他主要函数的函数,如 _initAgoraRtcEngine() , _addAgoraEventHandlers() , 和 joinChannel()

Future<void> initialize() async {
    print('Client Role: ${widget.isBroadcaster}');
    if (appId.isEmpty) {
      setState(() {
        _infoStrings.add(
          'APP_ID missing, please provide your APP_ID in settings.dart',
        );
        _infoStrings.add('Agora Engine is not starting');
      });
      return;
    }
    await _initAgoraRtcEngine();
    _addAgoraEventHandlers();
    await _engine.joinChannel(null, widget.channelName, null, 0);
  }

现在让我们来了解一下我们的 initialize() 函数中调用的这三个函数的意义。

  • _initAgoraRtcEngine() 用于创建Agora SDK的实例。使用你从Agora开发者后台得到的项目App ID来初始化它。在这里面,我们使用 enableVideo() 函数来启用视频模块。为了将频道配置文件从视频通话(默认值)改为直播,我们调用 setChannelProfile() 方法,然后设置用户角色。

      Future<void> _initAgoraRtcEngine() async {
          _engine = await RtcEngine.create(appId);
          await _engine.enableVideo();
          await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
          if (widget.isBroadcaster) {
            await _engine.setClientRole(ClientRole.Broadcaster);
          } else {
            await _engine.setClientRole(ClientRole.Audience);
          }
      }
    
  • _addAgoraEventHandlers() 是一个处理所有主要回调函数的函数。我们从 setEventHandler() 开始,它监听engine事件并接收相应RtcEngine的统计数据。

一些重要的回调包括

  • joinChannelSuccess() 在本地用户加入指定频道时被触发。它返回频道名,用户的uid,以及本地用户加入频道所需的时间(以毫秒为单位)。

  • leaveChannel()joinChannelSuccess() 相反,因为它是在用户离开频道时触发的。每当用户离开频道时,它就会返回调用的统计信息。这些统计包括延迟、CPU使用率、持续时间等。

  • userJoined() 是一个当远程用户加入一个特定频道时被触发的方法。一个成功的回调会返回远程用户的id和经过的时间。

  • userOffline()userJoined() 相反,因为它发生在用户离开频道的时候。一个成功的回调会返回uid和离线的原因,包括掉线、退出等。

  • firstRemoteVideoFrame() 是一个当远程视频的第一个视频帧被渲染时被调用的方法,它可以帮助你返回uid、宽度、高度和经过的时间。

      void _addAgoraEventHandlers() {
          _engine.setEventHandler(RtcEngineEventHandler(error: (code) {
            setState(() {
              final info = 'onError: $code';
              _infoStrings.add(info);
            });
          }, joinChannelSuccess: (channel, uid, elapsed) {
            setState(() {
              final info = 'onJoinChannel: $channel, uid: $uid';
              _infoStrings.add(info);
            });
          }, leaveChannel: (stats) {
            setState(() {
              _infoStrings.add('onLeaveChannel');
              _users.clear();
            });
          }, userJoined: (uid, elapsed) {
            setState(() {
              final info = 'userJoined: $uid';
              _infoStrings.add(info);
              _users.add(uid);
            });
          }, userOffline: (uid, elapsed) {
            setState(() {
              final info = 'userOffline: $uid';
              _infoStrings.add(info);
              _users.remove(uid);
            });
          },
         ));
      `  }`
    
  • joinChannel() 一个频道在视频通话中就是一个房间。一个 joinChannel() 函数可以帮助用户订阅一个特定的频道。这可以使用我们的RtcEngine对象来声明:

await _engine.joinChannel(token, "channel-name", "Optional Info", uid);

注意:此项目是 开发环境,仅供参考 请勿直接 用于生产环境。建议在生产环境中运行的所有RTE App都使用 Token鉴权 。关于Agora平台中基于 Token鉴权 的更多信息,请参考 本指南

以上总结了制作这个实时互动视频直播所需的所有功能和方法。现在我们可以制作我们的组件了,它将负责我们应用的完整用户界面。

在我的构建方法中,我声明了两个小部件( _viewRows()_toolbar() ,它们负责显示主播的网格,以及一个由断开、静音、切换摄像头和消息按钮组成的工具栏。

我们从 _viewRows() 开始。为此,我们需要知道主播和他们的uid来显示他们的视频。我们需要一个带有他们uid的本地和远程用户的通用列表。为了实现这一点,我们创建一个名为 _getRendererViews() 的小组件,其中我们使用了 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、技术帮助

推荐阅读
作者信息
Sophia
TA 暂未填写个人简介
文章
10
相关专栏
SDK 教程
55 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和 Agora 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。