如何基於 React Native 快速實現一個視頻通話應用

語言: CN / TW / HK

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

項目介紹

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

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

開發環境

  • 網頁訪問 Agora.io,註冊一個Agora開發者賬户。
  • 下載 Flutter SDK: http://docs.agora.io/cn/All/downloads
  • 已安裝 VS Code 或 Android Studio
  • 對 Flutter 開發的基本瞭解

為什麼要加入多個頻道?

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

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

項目設置

我們先創建一個 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(
                      'http://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 的身份驗證的更多信息,請參考聲網官方文檔: http://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: http://github.com/Meherdeep/agora-flutter-multi-channel

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