likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 19

开发一个跨平台的的直播的功能需要多久?如果直播还需要支持各种互动效果呢?

我给出的答案是不到一个小时,在 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 端得到复用,这对于有音视频需求的中小型团队来说无疑是最优组合之一。