如何用 Flutter開發一個直播應用

語言: CN / TW / HK

昨天,我在參加在線瑜伽課程時,才意識到我的日常活動中使用了這麼多的視頻直播 App--從商務會議到瑜伽課程,還有即興演奏和電影之夜。對於大多數居家隔離的人來説,視頻直播是接近世界的最好方式。海量用户的觀看和直播,也讓“完美的流媒體 App”成為了新的市場訴求。

在這篇文章中,我將引導你使用聲網Agora Flutter SDK 開發自己的直播 App。你可以按照自己的需求來定製你的應用界面,同時還能夠保持最高的視頻質量和幾乎感受不到的延遲。

開發環境

如果你是 Flutter 的新手,那麼請訪問 Flutter 官網安裝 Flutter。

  • 在http://pub.dev/搜索“Agora”,下載聲網Agora Flutter SDK v3.2.1
  • 在http://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(
                          'http://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鑑權的更多信息,請參考聲網文檔中心: http://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: http://github.com/Meherdeep/Interactive-Broadcasting

獲取更多教程、Demo、技術幫助,請點擊「閲讀原文」訪問聲網開發者社區。