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

科技资讯 投稿 6200 0 评论

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

前言


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

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

开始之前

    Flutter 2.0 或更高版本
  • Dart 2.14.0 或更高版本

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

创建项目

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

获取 App ID

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

获取 Token

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

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

开始开发

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

项目配置

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 已经添加过所需的权限。

Info.plist文件添加NSCameraUsageDescriptionNSCameraUsageDescription的权限声明,或者在 Xcode 的 Info 栏目添加Privacy - Microphone Usage DescriptionPrivacy - Camera Usage Description

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

使用声网 SDK

获取权限

permission_handler的request提前获取所需的麦克风和摄像头权限。

@override
void initState( {
  super.initState(;

  _requestPermissionIfNeed(;
}

Future<void> _requestPermissionIfNeed( async {
  await [Permission.microphone, Permission.camera].request(;
}

因为是测试项目,默认我们可以在应用首页就申请获得。

初始化引擎

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

渲染画面

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 绘制出来,展示当前有多少观众在线。

    主播只需要输入自己的 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进行轮循展示后移除。

互动开发

前面我们初始化时注册了一个 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,后面对应的是用户发送的文本内容

在收到 [ *** ] 格式的消息时弹出一个动画,类似粉丝送礼。

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 还引入了另外两个第三方包:

    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消息

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

同时在 Flutter 的加持下,代码可以在移动端和 PC 端得到复用,这对于有音视频需求的中小型团队来说无疑是最优组合之一。

编程笔记 » 基于声网 Flutter SDK 实现互动直播

赞同 (37) or 分享 (0)
游客 发表我的评论   换个身份
取消评论

表情
(0)个小伙伴在吐槽