草庐IT

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

声网 2023-03-28 原文

前言

互动直播是实现很多热门场景的基础,例如直播带货、秀场直播,还有类似抖音的直播 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 端得到复用,这对于有音视频需求的中小型团队来说无疑是最优组合之一。

有关基于声网 Flutter SDK 实现互动直播的更多相关文章

  1. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  2. 叮咚买菜基于 Apache Doris 统一 OLAP 引擎的应用实践 - 2

    导读:随着叮咚买菜业务的发展,不同的业务场景对数据分析提出了不同的需求,他们希望引入一款实时OLAP数据库,构建一个灵活的多维实时查询和分析的平台,统一数据的接入和查询方案,解决各业务线对数据高效实时查询和精细化运营的需求。经过调研选型,最终引入ApacheDoris作为最终的OLAP分析引擎,Doris作为核心的OLAP引擎支持复杂地分析操作、提供多维的数据视图,在叮咚买菜数十个业务场景中广泛应用。作者|叮咚买菜资深数据工程师韩青叮咚买菜创立于2017年5月,是一家专注美好食物的创业公司。叮咚买菜专注吃的事业,为满足更多人“想吃什么”而努力,通过美好食材的供应、美好滋味的开发以及美食品牌的孵

  3. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  4. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  5. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

  6. kvm虚拟机安装centos7基于ubuntu20.04系统 - 2

    需求:要创建虚拟机,就需要给他提供一个虚拟的磁盘,我们就在/opt目录下创建一个10G大小的raw格式的虚拟磁盘CentOS-7-x86_64.raw命令格式:qemu-imgcreate-f磁盘格式磁盘名称磁盘大小qemu-imgcreate-f磁盘格式-o?1.创建磁盘qemu-imgcreate-fraw/opt/CentOS-7-x86_64.raw10G执行效果#ls/opt/CentOS-7-x86_64.raw2.安装虚拟机使用virt-install命令,基于我们提供的系统镜像和虚拟磁盘来创建一个虚拟机,另外在创建虚拟机之前,提前打开vnc客户端,在创建虚拟机的时候,通过vnc

  7. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  8. ruby - Arrays Sets 和 SortedSets 在 Ruby 中是如何实现的 - 2

    通常,数组被实现为内存块,集合被实现为HashMap,有序集合被实现为跳跃列表。在Ruby中也是如此吗?我正在尝试从性能和内存占用方面评估Ruby中不同容器的使用情况 最佳答案 数组是Ruby核心库的一部分。每个Ruby实现都有自己的数组实现。Ruby语言规范只规定了Ruby数组的行为,并没有规定任何特定的实现策略。它甚至没有指定任何会强制或至少建议特定实现策略的性能约束。然而,大多数Rubyist对数组的性能特征有一些期望,这会迫使不符合它们的实现变得默默无闻,因为实际上没有人会使用它:插入、前置或追加以及删除元素的最坏情况步骤复

  9. ruby - "public/protected/private"方法是如何实现的,我该如何模拟它? - 2

    在ruby中,你可以这样做:classThingpublicdeff1puts"f1"endprivatedeff2puts"f2"endpublicdeff3puts"f3"endprivatedeff4puts"f4"endend现在f1和f3是公共(public)的,f2和f4是私有(private)的。内部发生了什么,允许您调用一个类方法,然后更改方法定义?我怎样才能实现相同的功能(表面上是创建我自己的java之类的注释)例如...classThingfundeff1puts"hey"endnotfundeff2puts"hey"endendfun和notfun将更改以下函数定

  10. ruby-on-rails - (Ruby,Rails) 基于角色的身份验证和用户管理...? - 2

    我正在寻找用于Rails的优质管理插件。似乎大多数现有的插件/gem(例如“restful_authentication”、“acts_as_authenticated”)都围绕着self注册等展开。但是,我正在寻找一种功能齐全的基于管理/管理角色的解决方案——但不是简单地附加到另一个非基于角色的解决方案。如果我找不到,我想我会自己动手......只是不想重新发明轮子。 最佳答案 RyanBates最近做了两个关于授权的railscast(注意身份验证和授权之间的区别;身份验证检查用户是否如她所说的那样,授权检查用户是否有权访问资源

随机推荐