乐趣区

关于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 等配置参数。

可拜访这里注册声网账号:https://sso2.agora.io/cn/v4/signup/with-sms
如果对于配置“门清”,能够疏忽跳过这部分间接看下一章节。

创立我的项目

首先能够在声网控制台的项目管理页面上点击创立我的项目,而后在弹出框选输出项目名称,之后抉择「互动直播」场景和「平安模式(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 文件增加 NSCameraUsageDescription 和 NSCameraUsageDescription 的权限申明,或者在 Xcode 的 Info 栏目增加 Privacy – Microphone Usage Description 和 Privacy – Camera Usage Description。

<key>NSCameraUsageDescription</key>
  <string>*****</string>
  <key>NSMicrophoneUsageDescription</key>
  <string>*****</string>

应用声网 SDK

获取权限

在正式调用声网 SDK 的 API 之前,首先咱们须要申请权限,如下代码所示,能够应用 permission_handler 的 request 提前获取所需的麦克风和摄像头权限。

@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 控件,并把下面初始化胜利的 RtcEngine 和 VideoViewController 配置到 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 里配置 mirrorMode 为 videoMirrorModeEnabled,就能够让主播画面和观众统一。

 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 退出直播间
  • 通过 AgoraVideoView 和 VideoViewController 用户画面
  • 通过 engine 创立和发送 stream 音讯
  • 从申请账号到开发 Demo,利用声网的 SDK 开发一个「互动直播」从需要到实现大略只过了一个小时,尽管上述实现的性能和成果还很毛糙,然而主体流程很快能够跑通了。

同时在 Flutter 的加持下,代码能够在挪动端和 PC 端失去复用,这对于有音视频需要的中小型团队来说无疑是最优组合之一。


欢送开发者们也尝试体验声网 SDK,实现实时音视频互动场景。现注册声网账号下载 SDK,可取得每月收费 10000 分钟应用额度。如在开发过程中遇到疑难,可在声网开发者社区与官网工程师交换。

退出移动版