开发一个跨渠道的的直播的功用需求多久?假如直播还需求支撑各种互动作用呢?
我给出的答案是不到一个小时,在 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)」 即可完结项目创立。
依据法规,创立项目需求实名认证,这个必不可少,别的运用场景不用太过纠结,项目创立之后也是能够依据需求自己修正。
获取 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
的更多参数装备支撑如下所示:
参数 | 描绘 |
---|---|
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
。
烘托画面
接下来便是烘托画面,如下代码所示,在 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 端得到复用,这关于有音视频需求的中小型团队来说无疑是最优组合之一。