基於聲網 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
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...