前言
本文是由聲網社群的開發者“小猿”撰寫的Flutter基礎教程系列中的第一篇。本文除了講述實現多人視訊通話的過程,還有一些 Flutter 開發方面的知識點。該系列將基於聲網 Fluttter SDK 實現視訊通話、互動直播,並嘗試虛擬背景等更多功能的實現。
如果你有一個實現 “多人視訊通話” 的場景需求,你會選擇從零實現還是接第三方 SDK?如果在這個場景上你還需要支援跨平臺,你會選擇怎麼樣的技術路線?
我的答案是:Flutter + 聲網 SDK,這個組合可以完美解決跨平臺和多人視訊通話的所有痛點,因為:
- Flutter 天然支援手機端和 PC 端的跨平臺能力,並擁有不錯的效能表現
- 聲網的 Flutter RTC SDK 同樣支援 Android、iOS、MacOS 和 Windows 等平臺,同時也是難得針對 Flutter 進行了全平臺支援和最佳化的音影片 SDK
在開始之前,有必要提前簡單介紹一下聲網的 RTC SDK 相關實現,這也是我選擇聲網的原因。
聲網屬於是國內最早一批做 Flutter SDK 全平臺支援的廠家,聲網的 Flutter SDK 之所以能在 Flutter 上最早保持多平臺的支援,原因在於聲網並不是使用常規的 Flutter Channel 去實現平臺音影片能力:
聲網的 RTC SDK 的邏輯實現都來自於封裝好的 C/C++ 等 native 程式碼,而這些程式碼會被打包為對應平臺的動態連結庫,例如.dll、.so 、.dylib ,最後透過 Dart 的 FFI(ffigen) 進行封裝呼叫。
這樣做的好處在於:
- Dart 可以和 native SDK 直接通訊,減少了 Flutter 和原生平臺互動時在 Channel 上的效能開銷;
- C/C++ 相關實現在獲得更好效能支援的同時,也不需要過度依賴原生平臺的 API ,可以得到更靈活和安全的 API 支援。
如果說這樣做有什麼壞處,那大概就是 SDK 的底層開發和維護成本會劇增,不過從使用者角度來看,這無異是一個絕佳的選擇。
開發之前
接下來讓我們進入正題,既然選擇了 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 進行初始化。
注意這裡需要在請求完許可權之後再初始化引擎,並更新初始化成功狀態 initStatus,因為沒成功初始化之前不能使用 RtcEngine。
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
late final RtcEngine _engine;
///初始化狀態
late final Future<bool?> initStatus;
@override
void initState() {
super.initState();
///請求完成許可權後,初始化引擎,更新初始化成功狀態
initStatus = _requestPermissionIfNeed().then((value) async {
await _initEngine();
return true;
}).whenComplete(() => setState(() {}));
}
Future<void> _initEngine() async {
//建立 RtcEngine
_engine = createAgoraRtcEngine();
// 初始化 RtcEngine
await _engine.initialize(RtcEngineContext(
appId: appId,
));
···
}
接著我們需要透過registerEventHandler註冊一系列回撥方法,在 RtcEngineEventHandler 裡有很多回撥通知,而一般情況下我們比如常用到的會是下面這 5 個:
- onError :判斷錯誤型別和錯誤資訊
- onJoinChannelSuccess:加入頻道成功
- onUserJoined:有使用者加入了頻道
- onUserOffline:有使用者離開了頻道
- onLeaveChannel:離開頻道
///是否加入聊天
bool isJoined = false;
/// 記錄加入的使用者id
Set<int> remoteUid = {};
Future<void> _initEngine() async {
···
_engine.registerEventHandler(RtcEngineEventHandler(
// 遇到錯誤
onError: (ErrorCodeType err, String msg) {
print('[onError] err: $err, msg: $msg');
},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
// 加入頻道成功
setState(() {
isJoined = true;
});
},
onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
// 有使用者加入
setState(() {
remoteUid.add(rUid);
});
},
onUserOffline:
(RtcConnection connection, int rUid, UserOfflineReasonType reason) {
// 有使用者離線
setState(() {
remoteUid.removeWhere((element) => element == rUid);
});
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
// 離開頻道
setState(() {
isJoined = false;
remoteUid.clear();
});
},
));
}
使用者可以根據上面的回撥來判斷 UI 狀態,比如當前使用者處於頻道內時顯示對方的頭像和資料,其他使用者加入和離開頻道時更新當前 UI 等。
接下來因為我們的需求是「多人視訊通話」,所以還需要呼叫 enableVideo 開啟影片模組支援,同時我們還可以對影片編碼進行一些簡單配置,比如透過 VideoEncoderConfiguration 配置 :
- dimensions:配置影片的解析度尺寸,預設是 640x360
- frameRate:配置影片的幀率,預設是 15 fps Future<void> _initEngine() async {
Future<void> _initEngine() async {
···
// 開啟影片模組支援
await _engine.enableVideo();
// 配置影片編碼器,編碼影片的尺寸(畫素),幀率
await _engine.setVideoEncoderConfiguration(
const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 640, height: 360),
frameRate: 15,
),
);
await _engine.startPreview();
}
更多引數配置支援如下所示:
最後呼叫 startPreview 開啟畫面預覽功能,接下來只需要把初始化好的 Engine 配置到 AgoraVideoView 控制元件就可以完成渲染。
渲染畫面
接下來就是渲染畫面,如下程式碼所示,在 UI 上加入 AgoraVideoView 控制元件,並把上面初始化成功_engine,透過VideoViewController配置到 AgoraVideoView ,就可以完成本地檢視的預覽。
根據前面的initStatus狀態,在_engine初始化成功後才載入 AgoraVideoView。
Scaffold(
appBar: AppBar(),
body: FutureBuilder<bool?>(
future: initStatus,
builder: (context, snap) {
if (snap.data != true) {
return Center(
child: new Text(
"初始化ing",
style: TextStyle(fontSize: 30),
),
);
}
return AgoraVideoView(
controller: VideoViewController(
rtcEngine: _engine,
canvas: const VideoCanvas(uid: 0),
),
);
}),
);
這裡還有另外一個引數 VideoCanvas ,其中的 uid 是用來標誌使用者id的,這裡因為是本地使用者,這裡暫時用 0 表示 。
如果需要加入頻道,可以呼叫 joinChannel 方法加入對應頻道,以下的引數都是必須的,其中:
- token 就是前面臨時生成的 Token
- channelId 就是前面的渠道名
- uid 和上面一樣邏輯
- channelProfile 選擇 channelProfileLiveBroadcasting ,因為我們需要的是多人通話。
- clientRoleType 選擇 clientRoleBroadcaster,因為我們需要多人通話,所以我們需要進來的使用者可以交流傳送內容。
Scaffold(
appBar: AppBar(),
body: FutureBuilder<bool?>(
future: initStatus,
builder: (context, snap) {
if (snap.data != true) {
return Center(
child: new Text(
"初始化ing",
style: TextStyle(fontSize: 30),
),
);
}
return AgoraVideoView(
controller: VideoViewController(
rtcEngine: _engine,
canvas: const VideoCanvas(uid: 0),
),
);
}),
);
同樣的道理,透過前面的 RtcEngineEventHandler ,我們可以獲取到加入頻道使用者的 uid(rUid) ,所以還是AgoraVideoView,但是我們使用 VideoViewController.remote根據 uid 和頻道id去建立 controller ,配合 SingleChildScrollView 在頂部顯示一排可以左右滑動的使用者小窗效果。
用 Stack 巢狀層級。
Scaffold(
appBar: AppBar(),
body: Stack(
children: [
AgoraVideoView(
·····
),
Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.of(remoteUid.map(
(e) =>
SizedBox(
width: 120,
height: 120,
child: AgoraVideoView(
controller: VideoViewController.remote(
rtcEngine: _engine,
canvas: VideoCanvas(uid: e),
connection: RtcConnection(channelId: channel),
),
),
),
)),
),
),
)
],
),
);
這裡的 remoteUid 就是一個儲存加入到 channel 的 uid 的 Set 物件。
最終執行效果如下圖所示,引擎載入成功之後,點選 FloatingActionButton 加入,可以看到移動端和PC端都可以正常通訊互動,並且不管是通話質量還是畫面流暢度都相當優秀,可以感受到聲網 SDK 的完成度還是相當之高的。
紅色是我自己加上的打碼。
在使用該例子測試了 12 人同時線上通話效果,基本和微信視訊會議沒有差別,以下是完整程式碼:
class VideoChatPage extends StatefulWidget {
const VideoChatPage({Key? key}) : super(key: key);
@override
State<VideoChatPage> createState() => _VideoChatPageState();
}
class _VideoChatPageState extends State<VideoChatPage> {
late final RtcEngine _engine;
///初始化狀態
late final Future<bool?> initStatus;
///是否加入聊天
bool isJoined = false;
/// 記錄加入的使用者id
Set<int> remoteUid = {};
@override
void initState() {
super.initState();
initStatus = _requestPermissionIfNeed().then((value) async {
await _initEngine();
return true;
}).whenComplete(() => setState(() {}));
}
Future<void> _requestPermissionIfNeed() async {
await [Permission.microphone, Permission.camera].request();
}
Future<void> _initEngine() async {
//建立 RtcEngine
_engine = createAgoraRtcEngine();
// 初始化 RtcEngine
await _engine.initialize(RtcEngineContext(
appId: appId,
));
_engine.registerEventHandler(RtcEngineEventHandler(
// 遇到錯誤
onError: (ErrorCodeType err, String msg) {
print('[onError] err: $err, msg: $msg');
},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
// 加入頻道成功
setState(() {
isJoined = true;
});
},
onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
// 有使用者加入
setState(() {
remoteUid.add(rUid);
});
},
onUserOffline:
(RtcConnection connection, int rUid, UserOfflineReasonType reason) {
// 有使用者離線
setState(() {
remoteUid.removeWhere((element) => element == rUid);
});
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
// 離開頻道
setState(() {
isJoined = false;
remoteUid.clear();
});
},
));
// 開啟影片模組支援
await _engine.enableVideo();
// 配置影片編碼器,編碼影片的尺寸(畫素),幀率
await _engine.setVideoEncoderConfiguration(
const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 640, height: 360),
frameRate: 15,
),
);
await _engine.startPreview();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Stack(
children: [
FutureBuilder<bool?>(
future: initStatus,
builder: (context, snap) {
if (snap.data != true) {
return Center(
child: new Text(
"初始化ing",
style: TextStyle(fontSize: 30),
),
);
}
return AgoraVideoView(
controller: VideoViewController(
rtcEngine: _engine,
canvas: const VideoCanvas(uid: 0),
),
);
}),
Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.of(remoteUid.map(
(e) => SizedBox(
width: 120,
height: 120,
child: AgoraVideoView(
controller: VideoViewController.remote(
rtcEngine: _engine,
canvas: VideoCanvas(uid: e),
connection: RtcConnection(channelId: channel),
),
),
),
)),
),
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// 加入頻道
_engine.joinChannel(
token: token,
channelId: channel,
uid: 0,
options: ChannelMediaOptions(
channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
clientRoleType: ClientRoleType.clientRoleBroadcaster,
),
);
},
),
);
}
進階調整
最後我們再來個進階調整,前面 remoteUid 儲存的只是遠端使用者 id ,如果我們將 remoteUid 修改為 remoteControllers 用於儲存 VideoViewController ,那麼就可以簡單實現畫面切換,比如「點選使用者畫面實現大小切換」這樣的需求。
如下程式碼所示,簡單調整後邏輯為:
- remoteUid 從儲存遠端使用者 id 變成了 remoteControllers 的 Map<int,VideoViewController>
- 新增了currentController用於儲存當前大畫面下的 VideoViewController ,預設是使用者自己
- registerEventHandler 裡將 uid 儲存更改為 VideoViewController 的建立和儲存
- 在小窗處增加 InkWell 點選,在單擊之後切換 VideoViewController 實現畫面切換
class VideoChatPage extends StatefulWidget {
const VideoChatPage({Key? key}) : super(key: key);
@override
State<VideoChatPage> createState() => _VideoChatPageState();
}
class _VideoChatPageState extends State<VideoChatPage> {
late final RtcEngine _engine;
///初始化狀態
late final Future<bool?> initStatus;
///當前 controller
late VideoViewController currentController;
///是否加入聊天
bool isJoined = false;
/// 記錄加入的使用者id
Map<int, VideoViewController> remoteControllers = {};
@override
void initState() {
super.initState();
initStatus = _requestPermissionIfNeed().then((value) async {
await _initEngine();
///構建當前使用者 currentController
currentController = VideoViewController(
rtcEngine: _engine,
canvas: const VideoCanvas(uid: 0),
);
return true;
}).whenComplete(() => setState(() {}));
}
Future<void> _requestPermissionIfNeed() async {
await [Permission.microphone, Permission.camera].request();
}
Future<void> _initEngine() async {
//建立 RtcEngine
_engine = createAgoraRtcEngine();
// 初始化 RtcEngine
await _engine.initialize(RtcEngineContext(
appId: appId,
));
_engine.registerEventHandler(RtcEngineEventHandler(
// 遇到錯誤
onError: (ErrorCodeType err, String msg) {
print('[onError] err: $err, msg: $msg');
},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
// 加入頻道成功
setState(() {
isJoined = true;
});
},
onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
// 有使用者加入
setState(() {
remoteControllers[rUid] = VideoViewController.remote(
rtcEngine: _engine,
canvas: VideoCanvas(uid: rUid),
connection: RtcConnection(channelId: channel),
);
});
},
onUserOffline:
(RtcConnection connection, int rUid, UserOfflineReasonType reason) {
// 有使用者離線
setState(() {
remoteControllers.remove(rUid);
});
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
// 離開頻道
setState(() {
isJoined = false;
remoteControllers.clear();
});
},
));
// 開啟影片模組支援
await _engine.enableVideo();
// 配置影片編碼器,編碼影片的尺寸(畫素),幀率
await _engine.setVideoEncoderConfiguration(
const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 640, height: 360),
frameRate: 15,
),
);
await _engine.startPreview();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Stack(
children: [
FutureBuilder<bool?>(
future: initStatus,
builder: (context, snap) {
if (snap.data != true) {
return Center(
child: new Text(
"初始化ing",
style: TextStyle(fontSize: 30),
),
);
}
return AgoraVideoView(
controller: currentController,
);
}),
Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
///增加點選切換
children: List.of(remoteControllers.entries.map(
(e) => InkWell(
onTap: () {
setState(() {
remoteControllers[e.key] = currentController;
currentController = e.value;
});
},
child: SizedBox(
width: 120,
height: 120,
child: AgoraVideoView(
controller: e.value,
),
),
),
)),
),
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// 加入頻道
_engine.joinChannel(
token: token,
channelId: channel,
uid: 0,
options: ChannelMediaOptions(
channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
clientRoleType: ClientRoleType.clientRoleBroadcaster,
),
);
},
),
);
}
}
完整程式碼如上圖所示,執行後效果如下圖所示,可以看到畫面在點選之後可以完美切換,這裡主要提供一個大體思路,如果有興趣的可以自己最佳化並新增切換動畫效果。
另外如果你想切換前後攝像頭,可以透過 _engine.switchCamera(); 等 API 簡單實現。
總結
從上面可以看到,其實跑完基礎流程很簡單,回顧一下前面的內容,總結下來就是:
- 申請麥克風和攝像頭許可權
- 建立和透過 App ID 初始化引擎
- 註冊 RtcEngineEventHandler 回撥用於判斷狀態
- 開啟和配置影片編碼支援,並且啟動預覽 startPreview
- 呼叫 joinChannel 加入對應頻道
- 透過 AgoraVideoView 和 VideoViewController 配置顯示本地和遠端使用者畫面
當然,聲網 SDK 在多人視訊通話領域還擁有各類豐富的底層介面,例如虛擬背景、美顏、空間音效、音訊混合等等,這些我們後面在進階內容裡講到,更多 API 效果可以查閱 Flutter RTC API 獲取。
額外擴充
最後做個內容擴充,這部分和實際開發可能沒有太大關係,純粹是一些技術補充。
如果使用過 Flutter 開發過影片類相關專案的應該知道,Flutter 裡可以使用外界紋理和PlatfromView兩種方式實現畫面接入,而由此對應的是 AgoraVideoView 在使用 VideoViewController 時,是有 useFlutterTexture 和 useAndroidSurfaceView 兩個可選引數。
這裡我們不討論它們之間的優劣和差異,只是讓大家可以更直觀理解聲網 SDK 在不同平臺渲染時的差異,作為擴充知識點補充。
首先我們看 useFlutterTexture,從原始碼中我們可以看到:
- 在 macOS 和 windows 版本中,聲網 SDK 預設只支援 Texture 這種外界紋理的實現,這主要是因為 PC 端的一些 API 限制導致。
- Android 上並不支援配置為 Texture ,只支援 PlatfromView 模式,這裡應該是基於效能考慮。
- 只有 iOS 支援 Texture 模式或者 PlatfromView 的渲染模式可選擇,所以 useFlutterTexture 更多是針對 iOS 生效。
而針對 useAndroidSurfaceView 引數,從原始碼中可以看到,它目前只對 android 平臺生效,但是如果你去看原生平臺的 java 原始碼實現,可以看到其實不管是 AgoraTextureView 配置還是 AgoraSurfaceView 配置,最終 Android 平臺上還是使用 TextureView 渲染,所以這個引數目前來看不會有實際的作用。
最後,就像前面說的 , 聲網 SDK 是透過 Dart FFI 呼叫底層動態庫進行支援,而這些呼叫目前看是透過AgoraRtcWrapper進行,比如透過 libAgoraRtcWrapper.so 再去呼叫 lib-rtc-sdk.so ,如果對於這一塊感興趣的,可以繼續深入探索一下。