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

来源:https://www.cnblogs.com/Agora/archive/2023/03/17/17218821.html
-Advertisement-
Play Games

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


前言

互動直播是實現很多熱門場景的基礎,例如直播帶貨、秀場直播,還有類似抖音的直播 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 端得到復用,這對於有音視頻需求的中小型團隊來說無疑是最優組合之一。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 組合模式(Composite Pattern)是一種結構型設計模式,它允許將對象組合成樹形結構,並且可以像操作單個對象一樣操作整個樹形結構。 組合模式的核心思想是將對象組織成樹形結構,其中包含組合對象和葉子對象兩種類型。組合對象可以包含葉子對象或其他組合對象,從而形成一個樹形結構。 組合模式可以應用 ...
  • ChatGPT 虛擬號碼是什麼? 如何使用虛擬號註冊 ChatGPT,用來收手機驗證碼呢?先瞭解它是什麼 虛擬號碼是一種虛擬電話號碼,它可以用於接收和發送簡訊,但不會顯示真實的號碼。它可以用於保護用戶的隱私,也可以用於接收垃圾簡訊。 ChatGPT 虛擬號碼簡訊驗證碼接碼平臺 有幾個網站提供與 ht ...
  • 報錯信息 如題, cn.hutool.core.io.IORuntimeException: Not a file.... 報錯位置 FileReader reader = new FileReader(path); 初步分析 檢查下來發現,path實際對應的是一個文件夾,而不是文件。 文件來源關鍵 ...
  • 環境ThinkPHP+Redis 1.IP保存文件,文件名自定義,與後文對應 2.獲取IP信息腳本.sh文件 #!/bin/bash #variables ip_txt_path=/www/wwwroot/checkip/china_ip.txt; ip_url='http://ftp.apnic. ...
  • 需求:爬取豆瓣電影短評評論文本內容 目標:將爬取的文本存入 excel 中 爬蟲步驟: 1.拼接分頁網址,迴圈請求分頁數據,獲取HTML代碼 2.分析獲取到的HTML代碼,解析出所需要的數據,提取內容 3.存儲爬取到的數據 準備工作: 1.開發工具 pycharm 2.模塊 requests、bs4 ...
  • 類載入器 類載入的過程 類載入器的功能 將.class文件【物理文件:在硬碟中】載入到Java虛擬機的記憶體中【搬用工】。 類載入的時機情況分析: //1,當創建Fu對象的時候【Fu.class會被載入到Java虛擬機】 Fu f = new Fu(); //2,調用類的靜態方法【Fu.class會被 ...
  • 本文介紹在Anaconda環境下,安裝Python中的一個高級地理空間數據分析庫whitebox的方法。 首先,我們打開“Anaconda Prompt (Anaconda)”軟體。 隨後,將彈出如下所示的命令輸入視窗。 在上述彈出的命令輸入視窗中,輸入以下代碼: conda install -c ...
  • tar 備忘清單 IT寶庫網整理的關於 tar 常用命令的快速參考備忘單。入門,為開發人員分享快速參考備忘單。 開發速查表大綱 入門 介紹 選項 創建一個 tar 格式的壓縮文件 創建壓縮後的 tar.gz 存檔文件 生成壓縮率更高的 tar.bz2 文件 解壓縮 tar 文件 解壓縮 tar.gz ...
一周排行
    -Advertisement-
    Play Games
  • 在本篇教程中,我們學習瞭如何使用 Taurus.MVC WebMVC 框架創建一個簡單的頁面。 我們創建了一個控制器並編寫了一個用於呈現頁面的方法,然後創建了對應的視圖,並最終成功運行了應用程式。 在下一篇教程中,我們將繼續探索 Taurus.MVC WebMVC 框架的更多功能和用法。 ...
  • 一:背景 1. 講故事 很多.NET開發者在學習高級調試的時候,使用sos的命令輸出會發現這裡也看不懂那裡也看不懂,比如截圖中的這位朋友。 .NET高級調試屬於一個偏冷門的領域,國內可觀測的資料比較少,所以很多東西需要你自己去探究源代碼,然後用各種調試工具去驗證,相關源代碼如下: coreclr: ...
  • 我一直都以為c中除以2的n次方可以使用右移n位代替,然而在實際調試中發現並不都是這樣的。是在計算餘數是發現了異常 被除數:114325068 右移15計算結果:3488 除法取整計算結果:3489 右移操作計算餘數:33772 除法取整計算餘數:1005 顯然:這是不一樣的。 移位操作是一條cpu指 ...
  • 在上一篇文章中,我們介紹了ReentrantLock類的一些基本用法,今天我們重點來介紹一下ReentrantLock其它的常用方法,以便對ReentrantLock類的使用有更深入的理解。 ...
  • Excelize 是 Go 語言編寫的用於操作電子錶格辦公文檔的開源基礎庫,2024年2月26日,社區正式發佈了 2.8.1 版本,該版本包含了多項新增功能、錯誤修複和相容性提升優化。 ...
  • 雲採用框架(Cloud Adoption Framework,簡稱CAF)為企業上雲提供策略和技術的指導原則和最佳實踐,幫助企業上好雲、用好雲、管好雲,併成功實現業務目標。本雲採用框架是基於服務大量企業客戶的經驗總結,將企業雲採用分為四個階段,並詳細探討企業應在每個階段採取的業務和技術策略;同時,還 ...
  • 與TXT文本文件,PDF文件更加專業也更適合傳輸,常用於正式報告、簡歷、合同等場合。項目中如果有使用Java將TXT文本文件轉為PDF文件的需求,可以查看本文中介紹的免費實現方法。 免費Java PDF庫 本文介紹的方法需要用到Free Spire.PDF for Java,該免費庫支持多種操作、轉 ...
  • 指針和引用 當我們需要在程式中傳遞變數的地址時,可以使用指針或引用。它們都可以用來間接訪問變數,但它們之間有一些重要的區別。 指針是一個變數,它存儲另一個變數的地址。通過指針,我們可以訪問存儲在該地址中的變數。指針可以被重新分配,可以指向不同的變數,也可以為NULL。指針使用*運算符來訪問存儲在地址 ...
  • 即使再小再簡單的需求,作為研發開發完畢之後,我們可以直接上線麽?其實很多時候事故往往就是由於“不以為意”發生的。事故的發生往往也遵循“墨菲定律”,這就要求我們更要敬畏線上,再小的需求點都需要經過嚴格的測試驗證才能上線。 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 一、是什麼 許可權是對特定資源的訪問許可,所謂許可權控制,也就是確保用戶只能訪問到被分配的資源 而前端許可權歸根結底是請求的發起權,請求的發起可能有下麵兩種形式觸發 頁面載入觸發 頁面上的按鈕點擊觸發 總的來說,所有的請求發起都觸發自前端路由或 ...