基於聲網 Flutter SDK 實現互動直播

2023-03-17 15:00:16

前言

互動直播是實現很多熱門場景的基礎,例如直播帶貨、秀場直播,還有類似抖音的直播 PK等。本文是由聲網社群的開發者「小猿」撰寫的Flutter基礎教學系列中的第二篇,他將帶著大家用一個小時,利用聲網 Flutter SDK 實現視訊直播、發評論、送禮物等基礎功能。


開發一個跨平臺的的直播的功能需要多久?如果直播還需要支援各種互動效果呢?

我給出的答案是不到一個小時,在 Flutter + 聲網 SDK 的加持下,你可以在一個小時之內就完成一個互動直播的雛形。

聲網作為最早支援 Flutter 平臺的 SDK 廠商之一, 其 RTC SDK 實現主要來自於封裝好的 C/C++ 等 native 程式碼,而這些程式碼會被打包為對應平臺的動態連結庫,最後通過 Dart 的 FFI(ffigen) 進行封裝呼叫,減少了 Flutter 和原生平臺互動時在 Channel 上的效能開銷。

開始之前

接下來讓我們進入正題,既然選擇了 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檔案新增NSCameraUsageDescriptionNSCameraUsageDescription的許可權宣告,或者在 Xcode 的 Info 欄目新增Privacy - Microphone Usage DescriptionPrivacy - Camera Usage Description

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

使用聲網 SDK

獲取許可權

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

@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 進行初始化。

注意這裡需要在請求完許可權之後再初始化引擎。

import 'package:agora_rtc_engine/agora_rtc_engine.dart';

late final RtcEngine _engine;


Future<void> _initEngine() async {
   _engine = createAgoraRtcEngine();
  await _engine.initialize(const RtcEngineContext(
    appId: appId,
  ));
  ···
}

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

  • onError :判斷錯誤型別和錯誤資訊
  • onJoinChannelSuccess:加入頻道成功
  • onUserJoined:有使用者加入了頻道
  • onUserOffline:有使用者離開了頻道
  • onLeaveChannel:離開頻道
  • onStreamMessage: 用於接受遠端使用者傳送的訊息
    Future<void> _initEngine() async {
        ···
       _engine.registerEventHandler(RtcEngineEventHandler(
        onError: (ErrorCodeType err, String msg) {},
        onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
          setState(() {
            isJoined = true;
          });
        },
        onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
          remoteUid.add(rUid);
          setState(() {});
        },
        onUserOffline:
            (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
          setState(() {
            remoteUid.removeWhere((element) => element == rUid);
          });
        },
        onLeaveChannel: (RtcConnection connection, RtcStats stats) {
          setState(() {
            isJoined = false;
            remoteUid.clear();
          });
        },
        onStreamMessage: (RtcConnection connection, int remoteUid, int streamId,
            Uint8List data, int length, int sentTs) {
       
        }));

使用者可以根據上面的回撥來判斷 UI 狀態,比如當前使用者時候處於頻道內顯示對方的頭像和資料,提示使用者進入直播間,接收觀眾傳送的訊息等。

接下來因為我們的需求是「互動直播」,所以就會有觀眾和主播的概念,所以如下程式碼所示:

  • 首先需要呼叫enableVideo 開啟視訊模組支援,可以看到視訊畫面
  • 同時我們還可以對視訊編碼進行一些簡單設定,比如通過
    VideoEncoderConfiguration 設定解析度是影格率
  • 根據進入使用者的不同,我們假設type為"Create"是主播, "Join"是觀眾
  • 那麼初始化時,主播需要通過通過startPreview開啟預覽
  • 觀眾需要通過enableLocalAudio(false); 和enableLocalVideo(false);關閉原生的音視訊效果

Future<void> _initEngine() async {
    ···
    _engine.enableVideo();
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(
        dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
      ),
    );  
    /// 自己直播才需要預覽
    if (widget.type == "Create") {
      await _engine.startPreview();
    }

    if (widget.type != "Create") {
      _engine.enableLocalAudio(false);
      _engine.enableLocalVideo(false);
    }

關於 setVideoEncoderConfiguration 的更多引數設定支援如下所示:

接下來需要初始化一個 VideoViewController,根據角色的不同:

  • 主播可以通過VideoViewController直接構建控制器,因為畫面是通過主播本地發出的流
  • 觀眾需要通過VideoViewController.remote構建,因為觀眾需要獲取的是主播的資訊流,區別在於多了connection 引數需要寫入channelId,同時VideoCanvas需要寫入主播的uid 才能獲取到畫面
late VideoViewController rtcController; 
Future<void> _initEngine() async {
   ···
   rtcController = widget.type == "Create"
       ? VideoViewController(
           rtcEngine: _engine,
           canvas: const VideoCanvas(uid: 0),
         )
       : VideoViewController.remote(
           rtcEngine: _engine,
           connection: const RtcConnection(channelId: cid),
           canvas: VideoCanvas(uid: widget.remoteUid),
         );
   setState(() {
     _isReadyPreview = true;
   });

最後呼叫 joinChannel加入直播間就可以了,其中這些引數都是必須的:

  • token 就是前面臨時生成的Token
  • channelId 就是前面的渠道名
  • uid 就是當前使用者的id ,這些id 都是我們自己定義的
  • channelProfile根據角色我們可以選擇不同的類別,比如主播因為是發起者,可以選擇channelProfileLiveBroadcasting ;而觀眾選channelProfileCommunication
  • clientRoleType選擇clientRoleBroadcaster
Future<void> _initEngine() async {
   ···
   await _joinChannel();
}
Future<void> _joinChannel() async {
  await _engine.joinChannel(
    token: token,
    channelId: cid,
    uid: widget.uid,
    options: ChannelMediaOptions(
      channelProfile: widget.type == "Create"
          ? ChannelProfileType.channelProfileLiveBroadcasting
          : ChannelProfileType.channelProfileCommunication,
      clientRoleType: ClientRoleType.clientRoleBroadcaster,
      // clientRoleType: widget.type == "Create"
      //     ? ClientRoleType.clientRoleBroadcaster
      //     : ClientRoleType.clientRoleAudience,
    ),
  );
  

之前我以為觀眾可以選擇 clientRoleAudience 角色,但是後續發現如果使用者是通過 clientRoleAudience 加入可以直播間,onUserJoined 等回撥不會被觸發,這會影響到我們後續的開發,所以最後還是選擇了 clientRoleBroadcaster

渲染畫面

接下來就是渲染畫面,如下程式碼所示,在 UI 上加入 AgoraVideoView控制元件,並把上面初始化成功的RtcEngineVideoViewController設定到 AgoraVideoView,就可以完成畫面預覽。

Stack(
  children: [
    AgoraVideoView(
      controller: rtcController,
    ),
    Align(
      alignment: const Alignment(-.95, -.95),
      child: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          children: List.of(remoteUid.map(
            (e) => Container(
              width: 40,
              height: 40,
              decoration: const BoxDecoration(
                  shape: BoxShape.circle, color: Colors.blueAccent),
              alignment: Alignment.center,
              child: Text(
                e.toString(),
                style: const TextStyle(
                    fontSize: 10, color: Colors.white),
              ),
            ),
          )),
        ),
      ),
    ),

這裡還在頁面頂部增加了一個 SingleChildScrollView ,把直播間裡的觀眾 id 繪製出來,展示當前有多少觀眾線上。

接著我們只需要在做一些簡單的設定,就可以完成一個簡單直播 Demo 了,如下圖所示,在主頁我們提供 Create 和 Join 兩種角色進行選擇,並且模擬使用者的 uid 來進入直播間:

  • 主播只需要輸入自己的 uid 即可開播
  • 觀眾需要輸入自己的 uid 的同時,也輸入主播的 uid ,這樣才能獲取到主播的畫面

接著我們只需要通過 Navigator.push 開啟頁面,就可以看到主播(左)成功開播後,觀眾(右)進入直播間的畫面效果了,這時候如果你看下方截圖,可能會發現觀眾和主播的畫面是映象相反的。

如果想要主播和觀眾看到的畫面是一致的話,可以在前面初始化程式碼的 VideoEncoderConfiguration 裡設定 mirrorModevideoMirrorModeEnabled,就可以讓主播畫面和觀眾一致。

  await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(
        dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
        bitrate: 0,
        mirrorMode: VideoMirrorModeType.videoMirrorModeEnabled,
      ),
    );

這裡 mirrorMode 設定不需要區分角色,因為 mirrorMode 引數只會隻影響遠端使用者看到的視訊效果。

上面動圖左下角還有一個觀眾進入直播間時的提示效果,這是根據 onUserJoined 回撥實現,在收到使用者進入直播間後,將 id 寫入陣列,並通過PageView進行輪循展示後移除。

互動開發

前面我們已經完成了直播的簡單 Demo 效果,接下來就是實現「互動」的思路了。

前面我們初始化時註冊了一個 onStreamMessage 的回撥,可以用於主播和觀眾之間的訊息互動,那麼接下來主要通過兩個「互動」效果來展示如果利用聲網 SDK 實現互動的能力。

首先是「訊息互動」:

  • 我們需要通過 SDK 的createDataStream 方法得到一個streamId
  • 然後把要傳送的文字內容轉為Uint8List
  • 最後利用sendStreamMessage 就可以結合streamId 就可以將內容傳送到直播間
streamId = await _engine.createDataStream(
    const DataStreamConfig(syncWithAudio: false, ordered: false));

final data = Uint8List.fromList(
                          utf8.encode(messageController.text));

await _engine.sendStreamMessage(
                        streamId: streamId, data: data, length: data.length);

onStreamMessage 裡我們可以通過utf8.decode(data) 得到使用者傳送的文字內容,結合收到的使用者 id ,根據內容,我們就可以得到如下圖所示的互動訊息列表。

onStreamMessage: (RtcConnection connection, int remoteUid, int streamId,
    Uint8List data, int length, int sentTs) {
  var message = utf8.decode(data);
  doMessage(remoteUid, message);
}));

前面顯示的 id ,後面對應的是使用者傳送的文字內容

那麼我們再進階一下,收到使用者一些「特殊格式訊息」之後,我們可以展示動畫效果而不是文字內容,例如:

在收到 [ *** ] 格式的訊息時彈出一個動畫,類似粉絲送禮。

實現這個效果我們可以引入第三方 rive 動畫庫,這個庫只要通過 RiveAnimation.network 就可以實現遠端載入,這裡我們直接參照一個社群開放的免費 riv 動畫,並且在彈出後 3s 關閉動畫。

  showAnima() {
    showDialog(
        context: context,
        builder: (context) {
          return const Center(
            child: SizedBox(
              height: 300,
              width: 300,
              child: RiveAnimation.network(
                'https://public.rive.app/community/runtime-files/4037-8438-first-animation.riv',
              ),
            ),
          );
        },
        barrierColor: Colors.black12);
    Future.delayed(const Duration(seconds: 3), () {
      Navigator.of(context).pop();
    });
  }
  

最後,我們通過一個簡單的正則判斷,如果收到 [ *** ] 格式的訊息就彈出動畫,如果是其他就顯示文字內容,最終效果如下圖動圖所示。


bool isSpecialMessage(message) {
  RegExp reg = RegExp(r"[*]$");
  return reg.hasMatch(message);
}

doMessage(int id, String message) {
  if (isSpecialMessage(message) == true) {
    showAnima();
  } else {
    normalMessage(id, message);
  }
}

雖然程式碼並不十分嚴謹,但是他展示瞭如果使用聲網 SDK 實現 「互動」的效果,可以看到使用聲網 SDK 只需要簡單設定就能完成「直播」和 「互動」兩個需求場景。

完整程式碼如下所示,這裡面除了聲網 SDK 還引入了另外兩個第三方包:

  • flutter_swiper_view 實現使用者進入時的迴圈播放提示
  • rive用於上面我們展示的動畫效果
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/material.dart';
import 'package:flutter_swiper_view/flutter_swiper_view.dart';
import 'package:rive/rive.dart';

const token = "xxxxxx";
const cid = "test";
const appId = "xxxxxx";

class LivePage extends StatefulWidget {
  final int uid;
  final int? remoteUid;
  final String type;

  const LivePage(
      {required this.uid, required this.type, this.remoteUid, Key? key})
      : super(key: key);

  @override
  State<StatefulWidget> createState() => _State();
}

class _State extends State<LivePage> {
  late final RtcEngine _engine;
  bool _isReadyPreview = false;

  bool isJoined = false;
  Set<int> remoteUid = {};
  final List<String> _joinTip = [];
  List<Map<int, String>> messageList = [];

  final messageController = TextEditingController();
  final messageListController = ScrollController();
  late VideoViewController rtcController;
  late int streamId;

  final animaStream = StreamController<String>();

  @override
  void initState() {
    super.initState();
    animaStream.stream.listen((event) {
      showAnima();
    });
    _initEngine();
  }

  @override
  void dispose() {
    super.dispose();
    animaStream.close();
    _dispose();
  }

  Future<void> _dispose() async {
    await _engine.leaveChannel();
    await _engine.release();
  }

  Future<void> _initEngine() async {
    _engine = createAgoraRtcEngine();
    await _engine.initialize(const RtcEngineContext(
      appId: appId,
    ));

    _engine.registerEventHandler(RtcEngineEventHandler(
        onError: (ErrorCodeType err, String msg) {},
        onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
          setState(() {
            isJoined = true;
          });
        },
        onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
          remoteUid.add(rUid);
          var tip = (widget.type == "Create")
              ? "$rUid 來了"
              : "${connection.localUid} 來了";
          _joinTip.add(tip);
          Future.delayed(const Duration(milliseconds: 1500), () {
            _joinTip.remove(tip);
            setState(() {});
          });
          setState(() {});
        },
        onUserOffline:
            (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
          setState(() {
            remoteUid.removeWhere((element) => element == rUid);
          });
        },
        onLeaveChannel: (RtcConnection connection, RtcStats stats) {
          setState(() {
            isJoined = false;
            remoteUid.clear();
          });
        },
        onStreamMessage: (RtcConnection connection, int remoteUid, int streamId,
            Uint8List data, int length, int sentTs) {
          var message = utf8.decode(data);
          doMessage(remoteUid, message);
        }));

    _engine.enableVideo();
    await _engine.setVideoEncoderConfiguration(
      const VideoEncoderConfiguration(
        dimensions: VideoDimensions(width: 640, height: 360),
        frameRate: 15,
        bitrate: 0,
        mirrorMode: VideoMirrorModeType.videoMirrorModeEnabled,
      ),
    );

    /// 自己直播才需要預覽
    if (widget.type == "Create") {
      await _engine.startPreview();
    }

    await _joinChannel();

    if (widget.type != "Create") {
      _engine.enableLocalAudio(false);
      _engine.enableLocalVideo(false);
    }

    rtcController = widget.type == "Create"
        ? VideoViewController(
            rtcEngine: _engine,
            canvas: const VideoCanvas(uid: 0),
          )
        : VideoViewController.remote(
            rtcEngine: _engine,
            connection: const RtcConnection(channelId: cid),
            canvas: VideoCanvas(uid: widget.remoteUid),
          );
    setState(() {
      _isReadyPreview = true;
    });
  }

  Future<void> _joinChannel() async {
    await _engine.joinChannel(
      token: token,
      channelId: cid,
      uid: widget.uid,
      options: ChannelMediaOptions(
        channelProfile: widget.type == "Create"
            ? ChannelProfileType.channelProfileLiveBroadcasting
            : ChannelProfileType.channelProfileCommunication,
        clientRoleType: ClientRoleType.clientRoleBroadcaster,
        // clientRoleType: widget.type == "Create"
        //     ? ClientRoleType.clientRoleBroadcaster
        //     : ClientRoleType.clientRoleAudience,
      ),
    );

    streamId = await _engine.createDataStream(
        const DataStreamConfig(syncWithAudio: false, ordered: false));
  }

  bool isSpecialMessage(message) {
    RegExp reg = RegExp(r"[*]$");
    return reg.hasMatch(message);
  }

  doMessage(int id, String message) {
    if (isSpecialMessage(message) == true) {
      animaStream.add(message);
    } else {
      normalMessage(id, message);
    }
  }

  normalMessage(int id, String message) {
    messageList.add({id: message});
    setState(() {});
    Future.delayed(const Duration(seconds: 1), () {
      messageListController
          .jumpTo(messageListController.position.maxScrollExtent + 2);
    });
  }

  showAnima() {
    showDialog(
        context: context,
        builder: (context) {
          return const Center(
            child: SizedBox(
              height: 300,
              width: 300,
              child: RiveAnimation.network(
                'https://public.rive.app/community/runtime-files/4037-8438-first-animation.riv',
              ),
            ),
          );
        },
        barrierColor: Colors.black12);
    Future.delayed(const Duration(seconds: 3), () {
      Navigator.of(context).pop();
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_isReadyPreview) return Container();
    return Scaffold(
      appBar: AppBar(
        title: const Text("LivePage"),
      ),
      body: Column(
        children: [
          Expanded(
            child: Stack(
              children: [
                AgoraVideoView(
                  controller: rtcController,
                ),
                Align(
                  alignment: const Alignment(-.95, -.95),
                  child: SingleChildScrollView(
                    scrollDirection: Axis.horizontal,
                    child: Row(
                      children: List.of(remoteUid.map(
                        (e) => Container(
                          width: 40,
                          height: 40,
                          decoration: const BoxDecoration(
                              shape: BoxShape.circle, color: Colors.blueAccent),
                          alignment: Alignment.center,
                          child: Text(
                            e.toString(),
                            style: const TextStyle(
                                fontSize: 10, color: Colors.white),
                          ),
                        ),
                      )),
                    ),
                  ),
                ),
                Align(
                  alignment: Alignment.bottomLeft,
                  child: Container(
                    height: 200,
                    width: 150,
                    decoration: const BoxDecoration(
                      borderRadius:
                          BorderRadius.only(topRight: Radius.circular(8)),
                      color: Colors.black12,
                    ),
                    padding: const EdgeInsets.only(left: 5, bottom: 5),
                    child: Column(
                      children: [
                        Expanded(
                          child: ListView.builder(
                            controller: messageListController,
                            itemBuilder: (context, index) {
                              var item = messageList[index];
                              return Padding(
                                padding: const EdgeInsets.symmetric(
                                    horizontal: 10, vertical: 10),
                                child: Row(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: [
                                    Text(
                                      item.keys.toList().toString(),
                                      style: const TextStyle(
                                          fontSize: 12, color: Colors.white),
                                    ),
                                    const SizedBox(
                                      width: 10,
                                    ),
                                    Expanded(
                                      child: Text(
                                        item.values.toList()[0],
                                        style: const TextStyle(
                                            fontSize: 12, color: Colors.white),
                                      ),
                                    )
                                  ],
                                ),
                              );
                            },
                            itemCount: messageList.length,
                          ),
                        ),
                        Container(
                          height: 40,
                          color: Colors.black54,
                          padding: const EdgeInsets.only(left: 10),
                          child: Swiper(
                            itemBuilder: (context, index) {
                              return Container(
                                alignment: Alignment.centerLeft,
                                child: Text(
                                  _joinTip[index],
                                  style: const TextStyle(
                                      color: Colors.white, fontSize: 14),
                                ),
                              );
                            },
                            autoplayDelay: 1000,
                            physics: const NeverScrollableScrollPhysics(),
                            itemCount: _joinTip.length,
                            autoplay: true,
                            scrollDirection: Axis.vertical,
                          ),
                        ),
                      ],
                    ),
                  ),
                )
              ],
            ),
          ),
          Container(
            height: 80,
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        isDense: true,
                      ),
                      controller: messageController,
                      keyboardType: TextInputType.number),
                ),
                TextButton(
                    onPressed: () async {
                      if (isSpecialMessage(messageController.text) != true) {
                        messageList.add({widget.uid: messageController.text});
                      }
                      final data = Uint8List.fromList(
                          utf8.encode(messageController.text));
                      await _engine.sendStreamMessage(
                          streamId: streamId, data: data, length: data.length);
                      messageController.clear();
                      setState(() {});
                      // ignore: use_build_context_synchronously
                      FocusScope.of(context).requestFocus(FocusNode());
                    },
                    child: const Text("Send"))
              ],
            ),
          ),
        ],
      ),
    );
  }
}

總結

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

  • 申請麥克風和攝像頭許可權
  • 建立和通過App ID初始化引擎
  • 註冊RtcEngineEventHandler回撥用於判斷狀態和接收互動能力
  • 根絕角色開啟和設定視訊編碼支援
  • 呼叫joinChannel加入直播間
  • 通過AgoraVideoViewVideoViewController使用者畫面
  • 通過engine建立和傳送stream訊息

從申請賬號到開發 Demo ,利用聲網的 SDK 開發一個「互動直播」從需求到實現大概只過了一個小時,雖然上述實現的功能和效果還很粗糙,但是主體流程很快可以跑通了。


歡迎開發者們也嘗試體驗聲網 SDK,實現實時音視訊互動場景。現註冊聲網賬號下載 SDK,可獲得每月免費 10000 分鐘使用額度。如在開發過程中遇到疑問,可在聲網開發者社群與官方工程師交流。

同時在 Flutter 的加持下,程式碼可以在行動端和 PC 端得到複用,這對於有音視訊需求的中小型團隊來說無疑是最優組合之一。