互動直播是實現很多熱門場景的基礎,例如直播帶貨、秀場直播,還有類似抖音的直播 PK等。本文是由聲網社群的開發者「小猿」撰寫的Flutter基礎教學系列中的第二篇,他將帶著大家用一個小時,利用聲網 Flutter SDK 實現視訊直播、發評論、送禮物等基礎功能。
開發一個跨平臺的的直播的功能需要多久?如果直播還需要支援各種互動效果呢?
我給出的答案是不到一個小時,在 Flutter + 聲網 SDK 的加持下,你可以在一個小時之內就完成一個互動直播的雛形。
聲網作為最早支援 Flutter 平臺的 SDK 廠商之一, 其 RTC SDK 實現主要來自於封裝好的 C/C++ 等 native 程式碼,而這些程式碼會被打包為對應平臺的動態連結庫,最後通過 Dart 的 FFI(ffigen) 進行封裝呼叫,減少了 Flutter 和原生平臺互動時在 Channel 上的效能開銷。
接下來讓我們進入正題,既然選擇了 Flutter + 聲網的實現路線,那麼在開始之前肯定有一些需要準備的前置條件,首先是為了滿足聲網 RTC SDK 的使用條件,開發環境必須為:
從目前 Flutter 和 Dart 版本來看,上面這個要求並不算高,然後就是你需要註冊一個聲網開發者賬號 ,從而獲取後續設定所需的 App ID 和 Token 等設定引數。
如果對於設定「門清」,可以忽略跳過這部分直接看下一章節。
首先可以在聲網控制檯的專案管理頁面上點選建立專案,然後在彈出框選輸入專案名稱,之後選擇「互動直播」場景和「安全模式(APP ID + Token)」 即可完成專案建立。
根據法規,建立專案需要實名認證,這個必不可少,另外使用場景不必太過糾結,專案建立之後也是可以根據需要自己修改。
在專案列表點選建立好的專案設定,進入專案詳情頁面之後,會看到基本資訊欄目有個 App ID 的欄位,點選如下圖所示圖示,即可獲取專案的 App ID。
App ID 也算是敏感資訊之一,所以儘量妥善儲存,避免洩密。
為提高專案的安全性,聲網推薦了使用 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 的 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 裡有很多回撥通知,而一般情況下我們比如常用到的會是下面這幾個:
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 狀態,比如當前使用者時候處於頻道內顯示對方的頭像和資料,提示使用者進入直播間,接收觀眾傳送的訊息等。
接下來因為我們的需求是「互動直播」,所以就會有觀眾和主播的概念,所以如下程式碼所示:
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,根據角色的不同:
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加入直播間就可以了,其中這些引數都是必須的:
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 來進入直播間:
接著我們只需要通過 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 實現互動的能力。
首先是「訊息互動」:
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 還引入了另外兩個第三方包:
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"))
],
),
),
],
),
);
}
}
從上面可以看到,其實跑完基礎流程很簡單,回顧一下前面的內容,總結下來就是:
從申請賬號到開發 Demo ,利用聲網的 SDK 開發一個「互動直播」從需求到實現大概只過了一個小時,雖然上述實現的功能和效果還很粗糙,但是主體流程很快可以跑通了。
歡迎開發者們也嘗試體驗聲網 SDK,實現實時音視訊互動場景。現註冊聲網賬號下載 SDK,可獲得每月免費 10000 分鐘使用額度。如在開發過程中遇到疑問,可在聲網開發者社群與官方工程師交流。
同時在 Flutter 的加持下,程式碼可以在行動端和 PC 端得到複用,這對於有音視訊需求的中小型團隊來說無疑是最優組合之一。