前言 互動直播是實現很多熱門場景的基礎,例如直播帶貨、秀場直播,還有類似抖音的直播 PK等。本文是由聲網社區的開發者“小猿”撰寫的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 等配置參數。
如果對於配置“門清”,可以忽略跳過這部分直接看下一章節。
創建項目
首先可以在聲網控制台的項目管理頁面上點擊創建項目,然後在彈出框選輸入項目名稱,之後選擇「互動直播」場景和「安全模式(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 開發一個「互動直播」從需求到實現大概只過了一個小時,雖然上述實現的功能和效果還很粗糙,但是主體流程很快可以跑通了。
歡迎開發者們也嘗試體驗聲網 SDK,實現實時音視頻互動場景。現註冊聲網賬號下載 SDK,可獲得每月免費 10000 分鐘使用額度。如在開發過程中遇到疑問,可在聲網開發者社區與官方工程師交流。
同時在 Flutter 的加持下,代碼可以在移動端和 PC 端得到復用,這對於有音視頻需求的中小型團隊來說無疑是最優組合之一。