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

語言: CN / TW / HK

前言

互動直播是實現很多熱門場景的基礎,例如直播帶貨、秀場直播,還有類似抖音的直播 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 開發一個「互動直播」從需求到實現大概只過了一個小時,雖然上述實現的功能和效果還很粗糙,但是主體流程很快可以跑通了。

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


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