如何基於 Flutter 快速實現一個視訊通話應用

聲網Agora發表於2021-11-17

今天,我們將會一起開發一個包含 RTE (實時互動)場景的 Flutter 應用。

專案介紹

靠自研開發包含實時互動功能的應用非常繁瑣,你要解決維護伺服器、負載均衡等難題,同時還要保證穩定的低延遲。

那麼,如何才能在較短的時間內,將實時互動功能新增到 Flutter 應用中?你可以通過聲網Agora SDK 來進行開發。在本教程中,我將帶大家瞭解如何使用 Agora Flutter SDK 訂閱多個頻道的過程。(多頻道是什麼樣場景呢?我們稍後舉些例子。)

開發環境

為什麼要加入多個頻道?

在進入正式開發之前,我們先看看為什麼有人或者說實時互動場景需要訂閱多個頻道。

加入多個頻道的主要原因是可以同時跟蹤多個群組的實時互動活動,或者同時與各個群組互動。各種使用場景包括線上的分組討論室、多會議場景、等待室、活動會議等。

專案設定

我們先建立一個 Flutter 專案。開啟你的終端,找到你的開發資料夾,然後輸入以下內容。

flutter create agora_multi_channel_demo

找到 pubspec.yaml,並在該檔案中新增以下依賴項。

dependencies:
  flutter:
    sdk: flutter


  cupertino_icons: ^1.0.0
  agora_rtc_engine: ^3.2.1
  permission_handler: ^5.1.0+2

在新增包的時候要注意這邊的縮排,否則可能會出現錯誤。

在你的專案資料夾中,執行以下命令來安裝所有的依賴項:

flutter pub get

一旦我們有了所有的依賴項,就可以建立檔案結構了。找到 lib 資料夾,建立一個像這樣的檔案目錄結構:

圖片

建立登入頁面

登入頁面只需讀取使用者想要加入的兩個頻道即可。在本教程中,我們只保留兩個頻道,但如果你想的話也可以加入更多的頻道:

import 'package:agora_multichannel_video/pages/lobby_page.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

class LoginPage extends StatefulWidget {
  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final rteChannelNameController = TextEditingController();
  final rtcChannelNameController = TextEditingController();
  bool _validateError = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        title: Text('Agora Multi-Channel Demo'),
        elevation: 0,
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          clipBehavior: Clip.antiAliasWithSaveLayer,
          physics: BouncingScrollPhysics(),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.12,
              ),
              Center(
                child: Image(
                  image: NetworkImage(
                      'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png'),
                  height: MediaQuery.of(context).size.height * 0.17,
                ),
              ),
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.1,
              ),
              Container(
                width: MediaQuery.of(context).size.width * 0.8,
                child: TextFormField(
                  controller: rteChannelNameController,
                  decoration: InputDecoration(
                    labelText: 'Broadcast channel Name',
                    labelStyle: TextStyle(color: Colors.black54),
                    errorText:
                        _validateError ? 'Channel name is mandatory' : null,
                    border: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    enabledBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.black, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                  ),
                ),
              ),
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.03,
              ),
              Container(
                width: MediaQuery.of(context).size.width * 0.8,
                child: TextFormField(
                  controller: rtcChannelNameController,
                  decoration: InputDecoration(
                    labelText: 'RTC channel Name',
                    labelStyle: TextStyle(color: Colors.black54),
                    errorText:
                        _validateError ? 'RTC Channel name is mandatory' : null,
                    border: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    enabledBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.black, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                  ),
                ),
              ),
              SizedBox(height: MediaQuery.of(context).size.height * 0.05),
              Container(
                width: MediaQuery.of(context).size.width * 0.35,
                child: MaterialButton(
                  onPressed: onJoin,
                  color: Colors.blueAccent,
                  child: Padding(
                    padding: EdgeInsets.symmetric(
                        horizontal: MediaQuery.of(context).size.width * 0.01,
                        vertical: MediaQuery.of(context).size.height * 0.02),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: <Widget>[
                        Text(
                          'Join',
                          style: TextStyle(
                              color: Colors.white, fontWeight: FontWeight.bold),
                        ),
                        Icon(
                          Icons.arrow_forward,
                          color: Colors.white,
                        ),
                      ],
                    ),
                  ),
                ),
              )
            ],
          ),
        ),
      ),
    );
  }

  Future<void> onJoin() async {
    setState(() {
      rteChannelNameController.text.isEmpty &&
              rtcChannelNameController.text.isEmpty
          ? _validateError = true
          : _validateError = false;
    });

    await _handleCameraAndMic(Permission.camera);
    await _handleCameraAndMic(Permission.microphone);

    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => LobbyPage(
          rtcChannelName: rtcChannelNameController.text,
          rteChannelName: rteChannelNameController.text,
        ),
      ),
    );
  }

  Future<void> _handleCameraAndMic(Permission permission) async {
    final status = await permission.request();
    print(status);
  }
}

在成功提交頻道名稱時,會觸發 PermissionHandler(),這是一個來自外部包(permission_handler)的類,我們將使用這個類來獲取使用者在呼叫過程中的攝像頭和麥克風的許可權。

現在,在我們開始開發我們的可以連線多個頻道的大廳之前,在 utils.dart 資料夾下的 utils.dart 中單獨保留 App ID。

const appID = '<---Enter your App ID here--->';

建立大廳

如果你瞭解過多人通話或互動直播,你會發現,我們在這裡要寫的大部分程式碼是相似的。這兩種情況下的主要區別是,之前我們是依靠一個頻道來連線一個群組。但是現在一個人可以同時加入多個頻道。

在一個單頻道視訊通話中,我們看到了如何建立一個 RtcEngine 類的例項並加入一個頻道。在這裡我們也是以同樣的過程開始的,如下:

_engine = await RtcEngine.create(appID);
await _engine.enableVideo();
await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.rteChannelName, null, 0);

注意:該專案是作為開發環境下的參考,不推薦用於生產環境。建議在生產環境中執行的所有 RTE App 都使用Token鑑權。關於 Agora 平臺中基於 Token 的身份驗證的更多資訊,請參考聲網官方文件:https://docs.agora.io/cn/

我們看到,在建立一個RtcEngine例項後,需要將Channel Profile設定為Live Streaming,並根據使用者輸入加入所需的頻道。

_addAgoraEventHandlers() 函式處理了我們在這個專案中需要的所有主要回撥。在示例中,我只是想在有他們的 uid 的 RTE 頻道中建立一個使用者列表。

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, reason) {
        setState(() {
          final info = 'userOffline: $uid , reason: $reason';
          _infoStrings.add(info);
          _users.remove(uid);
        });
      },
    ));
  }

uid 的列表是動態維護的,因為每次使用者加入或離開頻道時它都會更新。

這就設定了我們的主頻道或大廳,在這裡可以顯示主播直播,現在訂閱其他頻道需要一個 RtcChannel 的例項,只有這樣你才能加入第二個頻道。

_channel = await RtcChannel.create(widget.rtcChannelName);
_addRtcChannelEventHandlers();
await _engine.setClientRole(ClientRole.Broadcaster);
await _channel.joinChannel(null, null, 0, ChannelMediaOptions(true, true));
await _channel.publish();

RtcChannel 是用頻道名來初始化的,所以我們用使用者給的其他輸入來處理這個問題。一旦它被初始化,我們呼叫 ChannelMediaOptions() 類的加入頻道函式,這個類尋找兩個引數:autoSubscribeAudio 和autoSubscribeVideo。由於它期望的是一個布林值,你可以根據你的要求傳遞 ture 或 false。

對於 RtcChannel,我們看到了類似的事件處理程式,不過我們將為該特定頻道中的使用者建立另一個使用者列表。

void _addRtcChannelEventHandlers() {
    _channel.setEventHandler(RtcChannelEventHandler(
      error: (code) {
        setState(() {
          _infoStrings.add('Rtc Channel onError: $code');
        });
      },
      joinChannelSuccess: (channel, uid, elapsed) {
        setState(() {
          final info = 'Rtc Channel onJoinChannel: $channel, uid: $uid';
          _infoStrings.add(info);
        });
      },
      leaveChannel: (stats) {
        setState(() {
          _infoStrings.add('Rtc Channel onLeaveChannel');
          _users2.clear();
        });
      },
      userJoined: (uid, elapsed) {
        setState(() {
          final info = 'Rtc Channel userJoined: $uid';
          _infoStrings.add(info);
          _users2.add(uid);
        });
      },
      userOffline: (uid, reason) {
        setState(() {
          final info = 'Rtc Channel userOffline: $uid , reason: $reason';
          _infoStrings.add(info);
          _users2.remove(uid);
        });
      },
    ));
  }

_users2 列表中包含了使用 RtcChannel 類建立的頻道中所有人的 ID。

有了這個,你就可以在你的應用程式中新增多個頻道。接下來,讓我們看看我們如何建立 Widget,以便這些視訊可以顯示在我們的螢幕上。

我們首先新增 RtcEngine 的檢視。在這個例子中,我將使用一個佔據螢幕最大空間的網格檢視。

List<Widget> _getRenderViews() {
    final List<StatefulWidget> list = [];
    list.add(RtcLocalView.SurfaceView());
    return list;
  }

  Widget _videoView(view) {
    return Expanded(child: Container(child: view));
  }

  Widget _expandedVideoRow(List<Widget> views) {
    final wrappedViews = views.map<Widget>(_videoView).toList();
    return Expanded(
      child: Row(
        children: wrappedViews,
      ),
    );
  }

  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();
  }

對於 RtcChannel,我將使用一個位於螢幕底部的可滾動的 ListView。這樣一來,使用者可以通過滾動列表來檢視所有出現在頻道中的使用者。

List<Widget> _getRenderRtcChannelViews() {
    final List<StatefulWidget> list = [];
    _users2.forEach(
      (int uid) => list.add(
        RtcRemoteView.SurfaceView(
          uid: uid,
          channelId: widget.rtcChannelName,
          renderMode: VideoRenderMode.FILL,
        ),
      ),
    );
    return list;
  }

  Widget _viewRtcRows() {
    final views = _getRenderRtcChannelViews();
    if (views.length > 0) {
      print("NUMBER OF VIEWS : ${views.length}");
      return ListView.builder(
        scrollDirection: Axis.horizontal,
        itemCount: views.length,
        itemBuilder: (BuildContext context, int index) {
          return Align(
            alignment: Alignment.bottomCenter,
            child: Container(
              height: 200,
              width: MediaQuery.of(context).size.width * 0.25,
              child: _videoView(views[index])),
          );
        },
      );
    } else {
      return Align(
        alignment: Alignment.bottomCenter,
        child: Container(),
      );
    }
  }

在呼叫中,你的應用程式的風格或對齊使用者視訊的方式完全由你決定。需要尋找的關鍵元素或小元件是 _getRenderViews() 和 _getRenderRtcChannelViews(),它們返回一個使用者視訊列表。使用這個列表,你可以按照你的選擇來定位你的使用者和他們的視訊,類似於 _viewRows() 和 _viewRtcRows() 小元件。

使用這些小元件,我們可以將它們新增到我們的支架上。在這裡,我將使用一個堆疊將_viewRows() 放在 _viewRtcRows 之 上。

Widg et build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Lobby'),
      ),
      body: Stack(
        children: <Widget>[
          _viewRows(),
          _viewRtcRows(),
          _panel()
        ],
      ),
    );
  }

我已經在我們的堆疊中新增了另一個名為 _panel 的小元件,我們使用這個小元件來顯示我們頻道上發生的所有事件。

Widget _panel() {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 48),
      alignment: Alignment.topLeft,
      child: FractionallySizedBox(
        heightFactor: 0.5,
        child: Container(
          padding: const EdgeInsets.symmetric(vertical: 48),
          child: ListView.builder(
            reverse: true,
            itemCount: _infoStrings.length,
            itemBuilder: (BuildContext context, int index) {
              if (_infoStrings.isEmpty) {
                return null;
              }
              return Padding(
                padding: const EdgeInsets.symmetric(
                  vertical: 3,
                  horizontal: 10,
                ),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Flexible(
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                          vertical: 2,
                          horizontal: 5,
                        ),
                        decoration: BoxDecoration(
                          color: Colors.yellowAccent,
                          borderRadius: BorderRadius.circular(5),
                        ),
                        child: Text(
                          _infoStrings[index],
                          style: TextStyle(color: Colors.blueGrey),
                        ),
                      ),
                    )
                  ],
                ),
              );
            },
          ),
        ),
      ),
    );
  }

這樣一來,使用者就可以新增兩個頻道並且同時檢視。但是讓我們思考一個例子,在這個例子中,你需要加入兩個以上的頻道實時互動。在這種情況下,你可以用一個獨特的頻道名稱簡單地建立更多的 RtcChannel 類的例項。使用同一個例項,你就可以加入多個頻道。

最後,你需要建立一個 dispose() 方法,來清除兩個頻道的使用者列表,併為我們訂閱的所有頻道呼叫 leaveChannel() 方法。

@override
   void dispose() {
    // clear users
    _users.clear();
    _users2.clear();
    // leave channel 
    _engine.leaveChannel();
    _engine.destroy();
    _channel.unpublish();
    _channel .leaveChannel();
    _channel.destroy();
    super.dispose();
  }

測試

當應用完成開發後,通過它你可以使用聲網Agora SDK 加入多個頻道,你可以執行應用並在裝置上測試。在你的終端中導航到專案目錄,並執行這個命令。

flutter run

結論

通過能夠同時加入多個頻道的聲網Agora Flutter SDK,你已經實現了你自己的直播 App。

獲取本文 Demo:https://github.com/Meherdeep/agora-flutter-multi-channel

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

圖片

相關文章