写点什么

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

作者:声网
  • 2023-03-17
    北京
  • 本文字数:12933 字

    阅读完需:约 42 分钟

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

互动直播是实现很多热门场景的基础,例如直播带货、秀场直播,还有类似抖音的直播 PK 等。本文是由声网社区的开发者“小猿”撰写的 Flutter 基础教程系列中的第二篇,他将带着大家用一个小时,利用声网 Flutter SDK实现视频直播、发评论、送礼物等基础功能。


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


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


声网作为最早支持 Flutter 平台的 SDK 厂商之一, 其 RTC SDK 实现主要来自于封装好的 C/C++ 等 native 代码,而这些代码会被打包为对应平台的动态链接库,最后通过 Dart 的 FFI(ffigen) 进行封装调用,减少了 Flutter 和原生平台交互时在 Channel 上的性能开销。

开始之前

接下来让我们进入正题,既然选择了 Flutter + 声网的实现路线,那么在开始之前肯定有一些需要准备的前置条件,首先是为了满足声网 RTC SDK 的使用条件,开发环境必须为:


  • Flutter 2.0 或更高版本

  • Dart 2.14.0 或更高版本


从目前 Flutter 和 Dart 版本来看,上面这个要求并不算高,然后就是你需要注册一个声网开发者账号 ,从而获取后续配置所需的 App ID 和 Token 等配置参数。


如果对于配置“门清”,可以忽略跳过这部分直接看下一章节。

创建项目

首先可以在声网控制台的项目管理页面上点击创建项目,然后在弹出框选输入项目名称,之后选择「互动直播」场景和「安全模式(APP ID + Token)」 即可完成项目创建。



根据法规,创建项目需要实名认证,这个必不可少,另外使用场景不必太过纠结,项目创建之后也是可以根据需要自己修改。

获取 App ID

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



App ID 也算是敏感信息之一,所以尽量妥善保存,避免泄密。

获取 Token

为提高项目的安全性,声网推荐了使用 Token 对加入频道的用户进行鉴权,在生产环境中,一般为保障安全,是需要用户通过自己的服务器去签发 Token,而如果是测试需要,可以在项目详情页面的「临时 token 生成器」获取临时 Token:


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



**这里得到的 Token 和频道名就可以直接用于后续的测试,**如果是用在生产环境上,建议还是在服务端签发 Token ,签发 Token 除了 App ID 还会用到 App 证书,获取 App 证书同样可以在项目详情的应用配置上获取。



更多服务端签发 Token 可见 token server 文档

开始开发

通过前面的配置,我们现在拥有了 App ID、 频道名和一个有效的临时 Token ,接下里就是在 Flutter 项目里引入声网的 RTC SDK :agora_rtc_engine

项目配置

首先在 Flutter 项目的 pubspec.yaml 文件中添加以下依赖,其中 agora_rtc_engine 这里引入的是 6.1.0 版本 。


其实 permission_handler 并不是必须的,只是因为视频通话项目必不可少需要申请到麦克风和相机权限,所以这里推荐使用 permission_handler 来完成权限的动态申请。



dependencies: flutter: sdk: flutter
agora_rtc_engine: ^6.1.0 permission_handler: ^10.2.0
复制代码


这里需要注意的是, Android 平台不需要特意在主工程的 AndroidManifest.xml文件上添加 uses-permission ,因为 SDK 的 AndroidManifest.xml 已经添加过所需的权限。


iOS 和 macOS 可以直接在 Info.plist 文件添加 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 提前获取所需的麦克风和摄像头权限。


@overridevoid 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 分钟使用额度。如在开发过程中遇到疑问,可在声网开发者社区与官方工程师交流。

用户头像

声网

关注

还未添加个人签名 2021-02-05 加入

声网(NASDAQ:API)成立于2014年。开发者可通过声网API,在应用内构建多种实时音视频互动场景。使用声网服务的包括小米、陌陌、斗鱼、哔哩哔哩、新东方、小红书、HTC VIVE 、Yalla等遍布全球的巨头、独角兽企业。

评论

发布
暂无评论
基于声网 Flutter SDK 实现互动直播_flutter_声网_InfoQ写作社区