开发一个跨渠道的的直播的功用需求多久?假如直播还需求支撑各种互动作用呢?

我给出的答案是不到一个小时,在 Flutter + 声网 SDK 的加持下,你能够在一个小时之内就完结一个互动直播的雏形。

前言

之所以挑选 Flutter ,是由于 Flutter 支撑 Android、iOS、Windows 和 MacOS 等渠道,从开发效率和开发本钱上比较契合中小团队的效益,而声网的 RTC SDK 相同支撑 Flutter 上的移动端和桌面端,所以 Flutter + 声网无疑是咱们完结「互动直播」需求的最优解。

声网作为最早支撑 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)」 即可完结项目创立。

基于声网 Flutter SDK 实现互动直播

依据法规,创立项目需求实名认证,这个必不可少,别的运用场景不用太过纠结,项目创立之后也是能够依据需求自己修正。

获取 App ID

在项目列表点击创立好的项目装备,进入项目详情页面之后,会看到基本信息栏目有个 App ID 的字段,点击如下图所示图标,即可获取项目的 App ID。

基于声网 Flutter SDK 实现互动直播

基于声网 Flutter SDK 实现互动直播

App ID 也算是灵敏信息之一,所以尽量妥善保存,防止泄密。

获取 Token

为进步项目的安全性,声网引荐了运用 Token 对参加频道的用户进行鉴权,在生产环境中,一般为保证安全,是需求用户经过自己的服务器去签发 Token,而假如是测验需求,能够在项目详情页面的「暂时 token 生成器」获取暂时 Token:

在频道名输入一个暂时频道,比方 Test2 ,然后点击生成暂时 token 按键,即可获取一个暂时 Token,有用期为 24 小时。

基于声网 Flutter SDK 实现互动直播

这儿得到的 Token 和频道名就能够直接用于后续的测验,假如是用在生产环境上,建议还是在服务端签发 Token ,签发 Token 除了 App ID 还会用到 App 证书,获取 App 证书相同能够在项目详情的运用装备上获取。

基于声网 Flutter SDK 实现互动直播

更多服务端签发 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>

基于声网 Flutter SDK 实现互动直播

运用声网 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 的更多参数装备支撑如下所示:

参数 描绘
dimensions 视频编码的分辨率(px)默许值为 640 360
codecType 视频编码类型,比方 1 规范 VP8;2 规范 H.264;3:规范 H.265
frameRate 视频编码的帧率(fps),默许值为 15
bitrate 视频编码码率,单位为 Kbps
minBitrate 最低编码码率,单位为 Kbps
orientationMode 视频编码的方向形式,例如: 0(默许)方向共同;1固定横屏;2固定竖屏
degradationPreference 带宽受限时,视频编码降级偏好,例如:为 0(默许)时带宽受限时,视频编码时优先下降视频帧率,保持分辨率不变;为 1 时带宽受限时,视频编码时优先下降视频分辨率,保持视频帧率不变;为 2 时带宽受限时,视频编码时一起下降视频帧率和视频分辨率
mirrorMode 发送编码视频时是否敞开镜像形式,只影响远端用户看到的视频画面,默许封闭
advanceOptions 高级选项,比方视频编码器偏好,视频编码的压缩偏好等

接下来需求初始化一个 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

基于声网 Flutter SDK 实现互动直播
基于声网 Flutter SDK 实现互动直播

烘托画面

接下来便是烘托画面,如下代码所示,在 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 了,如下图所示,在主页咱们提供 CreateJoin 两种人物进行挑选,并且模拟用户的 uid 来进入直播间:

  • 主播只需求输入自己的 uid 即可开播
  • 观众需求输入自己的 uid 的一起,也输入主播的 uid ,这样才能获取到主播的画面
基于声网 Flutter SDK 实现互动直播
基于声网 Flutter SDK 实现互动直播

接着咱们只需求经过 Navigator.push 翻开页面,就能够看到主播(左)成功开播后,观众(右)进入直播间的画面作用了,这时分假如你看下方截图,可能会发现观众和主播的画面是镜像相反的。

基于声网 Flutter SDK 实现互动直播
基于声网 Flutter SDK 实现互动直播

假如想要主播和观众看到的画面是共同的话,能够在前面初始化代码的 VideoEncoderConfiguration 里装备 mirrorModevideoMirrorModeEnabled ,就能够让主播画面和观众共同。

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

这儿 mirrorMode 装备不需求区分人物,由于 mirrorMode 参数只会只影响长途用户看到的视频作用。

基于声网 Flutter SDK 实现互动直播
基于声网 Flutter SDK 实现互动直播

上面动图左下角还有一个观众进入直播间时的提示作用,这是依据 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 ,后面对应的是用户发送的文本内容

基于声网 Flutter SDK 实现互动直播
基于声网 Flutter SDK 实现互动直播

那么咱们再进阶一下,收到用户一些「特别格局音讯」之后,咱们能够展现动画作用而不是文本内容,例如:

在收到 [***] 格局的音讯时弹出一个动画,相似粉丝送礼。

完结这个作用咱们能够引进第三方 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);
  }
}

基于声网 Flutter SDK 实现互动直播

尽管代码并不十分严谨,可是他展现了假如运用声网 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 端得到复用,这关于有音视频需求的中小型团队来说无疑是最优组合之一。