基於聲網 Flutter SDK 實現多人視頻通話

語言: CN / TW / HK

前言

本文是由聲網社區的開發者“小猿”撰寫的Flutter基礎教程系列中的第一篇。本文除了講述實現多人視頻通話的過程,還有一些 Flutter 開發方面的知識點。該系列將基於聲網 Fluttter SDK 實現視頻通話、互動直播,並嘗試虛擬背景等更多功能的實現。


如果你有一個實現 “多人視頻通話” 的場景需求,你會選擇從零實現還是接第三方 SDK?如果在這個場景上你還需要支持跨平台,你會選擇怎麼樣的技術路線?

我的答案是:Flutter + 聲網 SDK,這個組合可以完美解決跨平台和多人視頻通話的所有痛點,因為:

  • Flutter 天然支持手機端和 PC 端的跨平台能力,並擁有不錯的性能表現

  • 聲網的 Flutter RTC SDK 同樣支持 Android、iOS、MacOS 和 Windows 等平台,同時也是難得針對 Flutter 進行了全平台支持和優化的音視頻 SDK

在開始之前,有必要提前簡單介紹一下聲網的 RTC SDK 相關實現,這也是我選擇聲網的原因。

聲網屬於是國內最早一批做 Flutter SDK 全平台支持的廠家,聲網的 Flutter SDK 之所以能在 Flutter 上最早保持多平台的支持,原因在於聲網並不是使用常規的 Flutter Channel 去實現平台音視頻能力:

聲網的 RTC SDK 的邏輯實現都來自於封裝好的 C/C++ 等 native 代碼,而這些代碼會被打包為對應平台的動態鏈接庫,例如.dll、.so 、.dylib ,最後通過 Dart 的 FFI(ffigen) 進行封裝調用

這樣做的好處在於:

  • Dart 可以和 native SDK 直接通信,減少了 Flutter 和原生平台交互時在 Channel 上的性能開銷;
  • C/C++ 相關實現在獲得更好性能支持的同時,也不需要過度依賴原生平台的 API ,可以得到更靈活和安全的 API 支持。

如果説這樣做有什麼壞處,那大概就是 SDK 的底層開發和維護成本會劇增,不過從用户角度來看,這無異是一個絕佳的選擇。

開發之前

接下來讓我們進入正題,既然選擇了 Flutter + 聲網的實現路線,那麼在開始之前肯定有一些需要準備的前置條件,首先是為了滿足聲網 RTC SDK 的使用條件,必須是:

  • Flutter 2.0 或更高版本
  • Dart 2.14.0 或更高版本

從目前 Flutter 和 Dart 版本來看,上面這個要求並不算高,然後就是你需要註冊一個聲網開發者賬號,從而獲取後續配置所需的 App ID 和 Token 等配置參數。

如果對後續配置“門清”,可以忽略跳過。

創建項目

首先可以在聲網控制枱的項目管理頁面上點擊「創建項目」,然後在彈出框選輸入項目名稱,之後選擇「視頻通話」場景和「安全模式(APP ID + Token)」 即可完成項目創建。

圖片

根據法規,創建項目需要實名認證,這個必不可少;另外使用場景不必太過糾結,項目創建之後也是可以根據需要自己修改。

獲取 App ID

成功創建項目之後,在項目列表點擊項目「配置」,進入項目詳情頁面之後,會看到基本信息欄目有個 App ID 的字段,點擊如下圖所示圖標,即可獲取項目的 App ID。 圖片

圖片

App ID 也算是敏感信息之一,所以儘量妥善保存,避免泄密。

獲取 Token

為提高項目的安全性,聲網推薦了使用Token對加入頻道的用户進行鑑權,在生產環境中,一般為保障安全,是需要用户通過自己的服務器去簽發 Token,而如果是測試需要,可以在項目詳情頁面的“臨時 token 生成器”獲取臨時 Token:

在頻道名輸出一個臨時頻道,比如 Test2 ,然後點擊生成臨時 token 按鍵,即可獲取一個臨時 Token,有效期為 24 小時。

圖片

這裏得到的 Token 和頻道名就可以直接用於後續的測試,如果是用在生產環境上,建議還是在服務端簽發 Token ,簽發 Token 除了 App ID 還會用到 App 證書,獲取 App 證書同樣可以在項目詳情的應用配置上獲取。 圖片

更多服務端簽發 Token 可見 token server 文檔

開始開發

通過前面的配置,我們現在擁有了 App ID、 頻道名和一個有效的臨時 Token ,接下里就是在 Flutter 項目裏引入聲網的 RTC SDK :agora_rtc_engine

項目配置

首先在Flutter項目的pubspec.yaml文件中添加以下依賴,其中 agora_rtc_engine 這裏引入的是 6.1.0 版本。

其實 permission_handler 並不是必須的,只是因為「視頻通話」項目必不可少需要申請到「麥克風」和「相機」權限,所以這裏推薦使用 permission_handler 來完成權限的動態申請。

dependencies:
  flutter:
    sdk: flutter

  agora_rtc_engine: ^6.1.0
  permission_handler: ^10.2.0

這裏需要注意的是,Android 平台不需要特意在主工程的 AndroidManifest.xml文件上添加 uses-permission,因為 SDK 的 AndroidManifest.xml 已經添加過所需的權限。

iOS 和 macOS 可以直接在 Info.plist 文件添加加 NSCameraUsageDescription 和 NSCameraUsageDescription 的權限聲明,或者在 Xcode 的 Info 欄目添加Privacy - Microphone Usage Description和Privacy - Camera Usage Description。

 <key>NSCameraUsageDescription</key>
 <string>*****</string>
 <key>NSMicrophoneUsageDescription</key>
 <string>*****</string>

圖片

使用聲網 SDK

獲取權限

在正式調用聲網 SDK 的 API 之前,首先我們需要申請權限,如下代碼所示,可以使用 permission_handler 的 request 提前獲取所需的麥克風和攝像頭權限。

@override
void initState() {
  super.initState();

  _requestPermissionIfNeed();
}

Future<void> _requestPermissionIfNeed() async {
  await [Permission.microphone, Permission.camera].request();
}

初始化引擎

接下來開始配置 RTC 引擎,如下代碼所示,通過 import 對應的 dart 文件之後,就可以通過 SDK 自帶的 createAgoraRtcEngine 方法快速創建引擎,然後通過 initialize 方法就可以初始化 RTC 引擎了,可以看到這裏會用到前面創建項目時得到的 App ID 進行初始化。

注意這裏需要在請求完權限之後再初始化引擎,並更新初始化成功狀態 initStatus,因為沒成功初始化之前不能使用 RtcEngine。

import 'package:agora_rtc_engine/agora_rtc_engine.dart';

late final RtcEngine _engine;

///初始化狀態
late final Future<bool?> initStatus;

@override
void initState() {
  super.initState();
  ///請求完成權限後,初始化引擎,更新初始化成功狀態
  initStatus = _requestPermissionIfNeed().then((value) async {
    await _initEngine();
    return true;
  }).whenComplete(() => setState(() {}));
}


Future<void> _initEngine() async {
  //創建 RtcEngine
  _engine = createAgoraRtcEngine();
  // 初始化 RtcEngine
  await _engine.initialize(RtcEngineContext(
    appId: appId,
  ));
  ···
}

接着我們需要通過registerEventHandler註冊一系列回調方法,在 RtcEngineEventHandler 裏有很多回調通知,而一般情況下我們比如常用到的會是下面這 5 個:

  • onError :判斷錯誤類型和錯誤信息
  • onJoinChannelSuccess:加入頻道成功
  • onUserJoined:有用户加入了頻道
  • onUserOffline:有用户離開了頻道
  • onLeaveChannel:離開頻道

///是否加入聊天
bool isJoined = false;
/// 記錄加入的用户id
Set<int> remoteUid = {};

Future<void> _initEngine() async {
   ···
   _engine.registerEventHandler(RtcEngineEventHandler(
      // 遇到錯誤
      onError: (ErrorCodeType err, String msg) {
        print('[onError] err: $err, msg: $msg');
      },
      onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 加入頻道成功
        setState(() {
          isJoined = true;
        });
      },
      onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
        // 有用户加入
        setState(() {
          remoteUid.add(rUid);
        });
      },
      onUserOffline:
          (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
        // 有用户離線
        setState(() {
          remoteUid.removeWhere((element) => element == rUid);
        });
      },
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // 離開頻道
        setState(() {
          isJoined = false;
          remoteUid.clear();
        });
      },
    ));
}

用户可以根據上面的回調來判斷 UI 狀態,比如當前用户處於頻道內時顯示對方的頭像和數據,其他用户加入和離開頻道時更新當前 UI 等。

接下來因為我們的需求是「多人視頻通話」,所以還需要調用 enableVideo 打開視頻模塊支持,同時我們還可以對視頻編碼進行一些簡單配置,比如通過 VideoEncoderConfiguration 配置 :

  • dimensions:配置視頻的分辨率尺寸,默認是 640x360
  • frameRate:配置視頻的幀率,默認是 15 fps Future<void> _initEngine() async {
  Future<void> _initEngine() async {

    ···
    // 打開視頻模塊支持
    await _engine.enableVideo();
    // 配置視頻編碼器,編碼視頻的尺寸(像素),幀率
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(
        dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );

    await _engine.startPreview();
  }

更多參數配置支持如下所示: 最後調用 startPreview 開啟畫面預覽功能,接下來只需要把初始化好的 Engine 配置到 AgoraVideoView 控件就可以完成渲染。

渲染畫面

接下來就是渲染畫面,如下代碼所示,在 UI 上加入 AgoraVideoView 控件,並把上面初始化成功_engine,通過VideoViewController配置到 AgoraVideoView ,就可以完成本地視圖的預覽。

根據前面的initStatus狀態,在_engine初始化成功後才加載 AgoraVideoView。

Scaffold(
  appBar: AppBar(),
  body: FutureBuilder<bool?>(
      future: initStatus,
      builder: (context, snap) {
        if (snap.data != true) {
          return Center(
            child: new Text(
              "初始化ing",
              style: TextStyle(fontSize: 30),
            ),
          );
        }
        return AgoraVideoView(
          controller: VideoViewController(
            rtcEngine: _engine,
            canvas: const VideoCanvas(uid: 0),
          ),
        );
      }),
);

這裏還有另外一個參數 VideoCanvas ,其中的 uid 是用來標誌用户id的,這裏因為是本地用户,這裏暫時用 0 表示 。

如果需要加入頻道,可以調用 joinChannel 方法加入對應頻道,以下的參數都是必須的,其中:

  • token 就是前面臨時生成的 Token
  • channelId 就是前面的渠道名
  • uid 和上面一樣邏輯
  • channelProfile 選擇 channelProfileLiveBroadcasting ,因為我們需要的是多人通話。
  • clientRoleType 選擇 clientRoleBroadcaster,因為我們需要多人通話,所以我們需要進來的用户可以交流發送內容。
Scaffold(
  appBar: AppBar(),
  body: FutureBuilder<bool?>(
      future: initStatus,
      builder: (context, snap) {
        if (snap.data != true) {
          return Center(
            child: new Text(
              "初始化ing",
              style: TextStyle(fontSize: 30),
            ),
          );
        }
        return AgoraVideoView(
          controller: VideoViewController(
            rtcEngine: _engine,
            canvas: const VideoCanvas(uid: 0),
          ),
        );
      }),
);

圖片

圖片

同樣的道理,通過前面的 RtcEngineEventHandler ,我們可以獲取到加入頻道用户的 uid(rUid) ,所以還是AgoraVideoView,但是我們使用 VideoViewController.remote根據 uid 和頻道id去創建 controller ,配合 SingleChildScrollView 在頂部顯示一排可以左右滑動的用户小窗效果。

用 Stack 嵌套層級。

Scaffold(
  appBar: AppBar(),
  body: Stack(
    children: [
      AgoraVideoView(
      ·····
      ),
      Align(
        alignment: Alignment.topLeft,
        child: SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: Row(
            children: List.of(remoteUid.map(
                  (e) =>
                  SizedBox(
                    width: 120,
                    height: 120,
                    child: AgoraVideoView(
                      controller: VideoViewController.remote(
                        rtcEngine: _engine,
                        canvas: VideoCanvas(uid: e),
                        connection: RtcConnection(channelId: channel),
                      ),
                    ),
                  ),
            )),
          ),
        ),
      )
    ],
  ),
);

這裏的 remoteUid 就是一個保存加入到 channel 的 uid 的 Set 對象。

最終運行效果如下圖所示,引擎加載成功之後,點擊 FloatingActionButton 加入,可以看到移動端和PC端都可以正常通信交互,並且不管是通話質量還是畫面流暢度都相當優秀,可以感受到聲網 SDK 的完成度還是相當之高的。

紅色是我自己加上的打碼。

圖片

圖片

圖片

在使用該例子測試了 12 人同時在線通話效果,基本和微信視頻會議沒有差別,以下是完整代碼:


class VideoChatPage extends StatefulWidget {
  const VideoChatPage({Key? key}) : super(key: key);

  @override
  State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
  late final RtcEngine _engine;

  ///初始化狀態
  late final Future<bool?> initStatus;

  ///是否加入聊天
  bool isJoined = false;

  /// 記錄加入的用户id
  Set<int> remoteUid = {};

  @override
  void initState() {
    super.initState();
    initStatus = _requestPermissionIfNeed().then((value) async {
      await _initEngine();
      return true;
    }).whenComplete(() => setState(() {}));
  }

  Future<void> _requestPermissionIfNeed() async {
    await [Permission.microphone, Permission.camera].request();
  }

  Future<void> _initEngine() async {
    //創建 RtcEngine
    _engine = createAgoraRtcEngine();
    // 初始化 RtcEngine
    await _engine.initialize(RtcEngineContext(
      appId: appId,
    ));

    _engine.registerEventHandler(RtcEngineEventHandler(
      // 遇到錯誤
      onError: (ErrorCodeType err, String msg) {
        print('[onError] err: $err, msg: $msg');
      },
      onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 加入頻道成功
        setState(() {
          isJoined = true;
        });
      },
      onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
        // 有用户加入
        setState(() {
          remoteUid.add(rUid);
        });
      },
      onUserOffline:
          (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
        // 有用户離線
        setState(() {
          remoteUid.removeWhere((element) => element == rUid);
        });
      },
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // 離開頻道
        setState(() {
          isJoined = false;
          remoteUid.clear();
        });
      },
    ));

    // 打開視頻模塊支持
    await _engine.enableVideo();
    // 配置視頻編碼器,編碼視頻的尺寸(像素),幀率
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(
        dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );

    await _engine.startPreview();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Stack(
        children: [
          FutureBuilder<bool?>(
              future: initStatus,
              builder: (context, snap) {
                if (snap.data != true) {
                  return Center(
                    child: new Text(
                      "初始化ing",
                      style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                return AgoraVideoView(
                  controller: VideoViewController(
                    rtcEngine: _engine,
                    canvas: const VideoCanvas(uid: 0),
                  ),
                );
              }),
          Align(
            alignment: Alignment.topLeft,
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: List.of(remoteUid.map(
                      (e) => SizedBox(
                    width: 120,
                    height: 120,
                    child: AgoraVideoView(
                      controller: VideoViewController.remote(
                        rtcEngine: _engine,
                        canvas: VideoCanvas(uid: e),
                        connection: RtcConnection(channelId: channel),
                      ),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          // 加入頻道
          _engine.joinChannel(
            token: token,
            channelId: channel,
            uid: 0,
            options: ChannelMediaOptions(
              channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
              clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
    );
  }

進階調整

最後我們再來個進階調整,前面 remoteUid 保存的只是遠程用户 id ,如果我們將 remoteUid 修改為 remoteControllers 用於保存 VideoViewController ,那麼就可以簡單實現畫面切換,比如「點擊用户畫面實現大小切換」這樣的需求。

如下代碼所示,簡單調整後邏輯為:

  • remoteUid 從保存遠程用户 id 變成了 remoteControllers 的 Map<int,VideoViewController>
  • 新增了currentController用於保存當前大畫面下的 VideoViewController ,默認是用户自己
  • registerEventHandler 裏將 uid 保存更改為 VideoViewController 的創建和保存
  • 在小窗處增加 InkWell 點擊,在單擊之後切換 VideoViewController 實現畫面切換
class VideoChatPage extends StatefulWidget {
  const VideoChatPage({Key? key}) : super(key: key);

  @override
  State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
  late final RtcEngine _engine;

  ///初始化狀態
  late final Future<bool?> initStatus;

  ///當前 controller
  late VideoViewController currentController;

  ///是否加入聊天
  bool isJoined = false;

  /// 記錄加入的用户id
  Map<int, VideoViewController> remoteControllers = {};

  @override
  void initState() {
    super.initState();
    initStatus = _requestPermissionIfNeed().then((value) async {
      await _initEngine();
      ///構建當前用户 currentController
      currentController = VideoViewController(
        rtcEngine: _engine,
        canvas: const VideoCanvas(uid: 0),
      );
      return true;
    }).whenComplete(() => setState(() {}));
  }

  Future<void> _requestPermissionIfNeed() async {
    await [Permission.microphone, Permission.camera].request();
  }

  Future<void> _initEngine() async {
    //創建 RtcEngine
    _engine = createAgoraRtcEngine();
    // 初始化 RtcEngine
    await _engine.initialize(RtcEngineContext(
      appId: appId,
    ));

    _engine.registerEventHandler(RtcEngineEventHandler(
      // 遇到錯誤
      onError: (ErrorCodeType err, String msg) {
        print('[onError] err: $err, msg: $msg');
      },
      onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 加入頻道成功
        setState(() {
          isJoined = true;
        });
      },
      onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
        // 有用户加入
        setState(() {
          remoteControllers[rUid] = VideoViewController.remote(
            rtcEngine: _engine,
            canvas: VideoCanvas(uid: rUid),
            connection: RtcConnection(channelId: channel),
          );
        });
      },
      onUserOffline:
          (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
        // 有用户離線
        setState(() {
          remoteControllers.remove(rUid);
        });
      },
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // 離開頻道
        setState(() {
          isJoined = false;
          remoteControllers.clear();
        });
      },
    ));

    // 打開視頻模塊支持
    await _engine.enableVideo();
    // 配置視頻編碼器,編碼視頻的尺寸(像素),幀率
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(
        dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );

    await _engine.startPreview();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Stack(
        children: [
          FutureBuilder<bool?>(
              future: initStatus,
              builder: (context, snap) {
                if (snap.data != true) {
                  return Center(
                    child: new Text(
                      "初始化ing",
                      style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                return AgoraVideoView(
                  controller: currentController,
                );
              }),
          Align(
            alignment: Alignment.topLeft,
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                ///增加點擊切換
                children: List.of(remoteControllers.entries.map(
                  (e) => InkWell(
                    onTap: () {
                      setState(() {
                        remoteControllers[e.key] = currentController;
                        currentController = e.value;
                      });
                    },
                    child: SizedBox(
                      width: 120,
                      height: 120,
                      child: AgoraVideoView(
                        controller: e.value,
                      ),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          // 加入頻道
          _engine.joinChannel(
            token: token,
            channelId: channel,
            uid: 0,
            options: ChannelMediaOptions(
              channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
              clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
    );
  }
}

完整代碼如上圖所示,運行後效果如下圖所示,可以看到畫面在點擊之後可以完美切換,這裏主要提供一個大體思路,如果有興趣的可以自己優化並添加切換動畫效果。

圖片

另外如果你想切換前後攝像頭,可以通過 _engine.switchCamera(); 等 API 簡單實現。

總結

從上面可以看到,其實跑完基礎流程很簡單,回顧一下前面的內容,總結下來就是:

  • 申請麥克風和攝像頭權限
  • 創建和通過 App ID 初始化引擎
  • 註冊 RtcEngineEventHandler 回調用於判斷狀態
  • 打開和配置視頻編碼支持,並且啟動預覽 startPreview
  • 調用 joinChannel 加入對應頻道
  • 通過 AgoraVideoView 和 VideoViewController 配置顯示本地和遠程用户畫面

當然,聲網 SDK 在多人視頻通話領域還擁有各類豐富的底層接口,例如虛擬背景、美顏、空間音效、音頻混合等等,這些我們後面在進階內容裏講到,更多 API 效果可以查閲 Flutter RTC API 獲取

額外拓展

最後做個內容拓展,這部分和實際開發可能沒有太大關係,純粹是一些技術補充。

如果使用過 Flutter 開發過視頻類相關項目的應該知道,Flutter 裏可以使用外界紋理和PlatfromView兩種方式實現畫面接入,而由此對應的是 AgoraVideoView 在使用 VideoViewController 時,是有 useFlutterTexture 和 useAndroidSurfaceView 兩個可選參數。

這裏我們不討論它們之間的優劣和差異,只是讓大家可以更直觀理解聲網 SDK 在不同平台渲染時的差異,作為拓展知識點補充。

圖片

首先我們看 useFlutterTexture,從源碼中我們可以看到:

  • 在 macOS 和 windows 版本中,聲網 SDK 默認只支持 Texture 這種外界紋理的實現,這主要是因為 PC 端的一些 API 限制導致。
  • Android 上並不支持配置為 Texture ,只支持 PlatfromView 模式,這裏應該是基於性能考慮。
  • 只有 iOS 支持 Texture 模式或者 PlatfromView 的渲染模式可選擇,所以 useFlutterTexture 更多是針對 iOS 生效。

圖片

而針對 useAndroidSurfaceView 參數,從源碼中可以看到,它目前只對 android 平台生效,但是如果你去看原生平台的 java 源碼實現,可以看到其實不管是 AgoraTextureView 配置還是 AgoraSurfaceView 配置,最終 Android 平台上還是使用 TextureView 渲染,所以這個參數目前來看不會有實際的作用。

圖片

圖片

最後,就像前面説的 , 聲網 SDK 是通過 Dart FFI 調用底層動態庫進行支持,而這些調用目前看是通過AgoraRtcWrapper進行,比如通過 libAgoraRtcWrapper.so 再去調用 lib-rtc-sdk.so ,如果對於這一塊感興趣的,可以繼續深入探索一下。

圖片

圖片