上周,我們發(fā)布了Agora Flutter SDK之后,吸引了 Flutter 社區(qū)的諸多關(guān)注。Google Flutter 與 Dart 的產(chǎn)品負(fù)責(zé)人 Tim Sneath,在看到 Agora Flutter SDK 后,還特意在社交媒體上發(fā)推點(diǎn)贊。
今天我們就來(lái)看一下如何使用 Agora Flutter SDK 快速構(gòu)建一個(gè)簡(jiǎn)單的移動(dòng)跨平臺(tái)視頻通話應(yīng)用。
環(huán)境準(zhǔn)備
在 Flutter 中文網(wǎng)(flutterchina.club)上,關(guān)于搭建開放環(huán)境的教程已經(jīng)相對(duì)比較完善了,有關(guān) IDE 與環(huán)境配置的過(guò)程本文不再贅述,若 Flutter 安裝有問(wèn)題,可以執(zhí)行 flutter doctor 做配置檢查。
本文使用 MacOS 下的 VS Code 作為主開發(fā)環(huán)境。
目標(biāo)
我們希望可以使用 Flutter+Agora Flutter SDK 實(shí)現(xiàn)一個(gè)簡(jiǎn)單的視頻通話應(yīng)用,這個(gè)視頻通話應(yīng)用需要包含以下功能,
加入通話房間
視頻通話
前后攝像頭切換
本地靜音/取消靜音
聲網(wǎng)的視頻通話是按通話房間區(qū)分的,同一個(gè)通話房間內(nèi)的用戶都可以互通。為了方便區(qū)分,這個(gè)演示會(huì)需要一個(gè)簡(jiǎn)單的表單頁(yè)面讓用戶提交選擇加入哪一個(gè)房間。同時(shí)一個(gè)房間內(nèi)可以容納最多 4 個(gè)用戶,當(dāng)用戶數(shù)不同時(shí)我們需要展示不同的布局。
想清楚了?動(dòng)手?jǐn)]代碼了。
項(xiàng)目創(chuàng)建
首先在 VS Code 選擇查看->命令面板(或直接使用 cmd + shift + P)調(diào)出命令面板,輸入 flutter 后選擇Flutter: New Project創(chuàng)建一個(gè)新的 Flutter 項(xiàng)目,項(xiàng)目的名字為agora_flutter_quickstart,隨后等待項(xiàng)目創(chuàng)建完成即可。
現(xiàn)在執(zhí)行啟動(dòng)->啟動(dòng)調(diào)試(或 F5)即可看到一個(gè)最簡(jiǎn)單的計(jì)數(shù) App。
看起來(lái)我們有了一個(gè)很好的開始
接下去我們需要對(duì)我們新建的項(xiàng)目做一下簡(jiǎn)單的配置以使其可以引用和使用 Agora Flutter SDK。
打開項(xiàng)目根目錄下的 pubspec.yaml 文件,在dependencies下添加agora_rtc_engine:^0.9.0,
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
# add agora rtc sdk
agora_rtc_engine: ^0.9.0
dev_dependencies:
flutter_test:
sdk: flutter
保存后 VS Code 會(huì)自動(dòng)執(zhí)行flutter packages get更新依賴。
應(yīng)用首頁(yè)
在項(xiàng)目配置完成后,我們就可以開始開發(fā)了。首先我們需要?jiǎng)?chuàng)建一個(gè)頁(yè)面文件替換掉默認(rèn)示例代碼中的MyHomePage類。我們可以在lib/src下創(chuàng)建一個(gè)pages目錄,并創(chuàng)建一個(gè)index.dart文件。
如果你已經(jīng)完成了官方教程 Write your first Flutter app,那么以下代碼對(duì)你來(lái)說(shuō)就應(yīng)該不難理解。
classIndexPageextendsStatefulWidget {
@override
State
returnnewIndexState();
}
}
classIndexStateextendsState
@override
Widget build(BuildContext context) {
// UI
}
onJoin() {
//TODO
}
}
現(xiàn)在我們需要開始在build方法中構(gòu)造首頁(yè)的 UI。
按上圖分解 UI 后,我們可以將我們的首頁(yè)代碼修改如下
@override
Widget build(BuildContext context) {
returnScaffold(
appBar: AppBar(
title: Text('Agora Flutter QuickStart'),
),
body: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20),
height: 400,
child: Column(
children:
Row(children:
Row(children:
Expanded(
child: TextField(
decoration: InputDecoration(
border: UnderlineInputBorder(
borderSide: BorderSide(width: 1)),
hintText: 'Channel name'),
))
]),
Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Row(
children:
Expanded(
child: RaisedButton(
onPressed: () => onJoin(),
child: Text("Join"),
color: Colors.blueAccent,
textColor: Colors.white,
),
)
],
))
],
)),
));
}
執(zhí)行 F5 啟動(dòng)查看,應(yīng)該可以看到下圖
看起來(lái)不錯(cuò)!但也只是看起來(lái)不錯(cuò)。我們的UI現(xiàn)在只能看,還不能交互。我們希望可以基于現(xiàn)在的 UI 實(shí)現(xiàn)以下功能,
1. 為 Join 按鈕添加回調(diào)導(dǎo)航到通話頁(yè)面
2. 對(duì)頻道名做檢查,若嘗試加入頻道時(shí)頻道名為空,則在 TextField 上提示錯(cuò)誤
TextField 輸入校驗(yàn)
TextField 自身提供了一個(gè)decoration屬性,我們可以提供一個(gè)InputDecoration的對(duì)象來(lái)標(biāo)識(shí) TextField 的裝飾樣式。InputDecoration里的errorText屬性非常適合在我們這里被拿來(lái)使用, 同時(shí)我們利用TextEditingController對(duì)象來(lái)記錄 TextField 的值,以判斷當(dāng)前是否應(yīng)該顯示錯(cuò)誤。因此經(jīng)過(guò)簡(jiǎn)單的修改后,我們的 TextField 代碼就變成了這樣:
final _channelController = TextEditingController();
/// if channel textfield is validated to have error
bool _validateError = false;
@override
void dispose() {
// dispose input controller
_channelController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
...
TextField(
controller: _channelController,
decoration: InputDecoration(
errorText: _validateError
? "Channel name is mandatory"
: null,
border: UnderlineInputBorder(
borderSide: BorderSide(width: 1)),
hintText: 'Channel name'),
))
...
}
onJoin() {
// update input validation
setState(() {
_channelController.text.isEmpty
? _validateError = true
: _validateError = false;
});
}
在點(diǎn)擊加入頻道按鈕的時(shí)候回觸發(fā)onJoin回調(diào),回調(diào)中會(huì)先通過(guò)setState更新 TextField 的狀態(tài)以做組件重繪。
注意: 不要忘了 overridedispose方法在這個(gè)組件的生命周期結(jié)束時(shí)釋放_(tái)controller。
前往通話頁(yè)面
到這里我們的首頁(yè)基本就算完成了,最后我們?cè)趏nJoin中創(chuàng)建MaterialPageRoute將用戶導(dǎo)航到通話頁(yè)面,在這里我們將獲取的頻道名作為通話頁(yè)面構(gòu)造函數(shù)的參數(shù)傳遞到下一個(gè)頁(yè)面CallPage。
import'./call.dart';
classIndexStateextendsState
...
onJoin() {
// update input validation
setState(() {
_channelController.text.isEmpty
? _validateError = true
: _validateError = false;
});
if (_channelController.text.isNotEmpty) {
// push video page with given channel name
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => newCallPage(
channelName: _channelController.text,
)));
}
}
通話頁(yè)面
同樣在/lib/src/pages目錄下,我們需要新建一個(gè)call.dart文件,在這個(gè)文件里我們會(huì)實(shí)現(xiàn)我們最重要的實(shí)時(shí)視頻通話邏輯。首先還是需要?jiǎng)?chuàng)建我們的CallPage類。如果你還記得我們?cè)贗ndexPage的實(shí)現(xiàn),CallPage會(huì)需要在構(gòu)造函數(shù)中帶入一個(gè)參數(shù)作為頻道名。
classCallPageextendsStatefulWidget {
/// non-modifiable channel name of the page
finalString channelName;
/// Creates a call page with given channel name.
constCallPage({Key key, this.channelName}) : super(key: key);
@override
_CallPageState createState() {
returnnew_CallPageState();
}
}
class_CallPageStateextendsState
@override
Widget build(BuildContext context) {
returnScaffold(
appBar: AppBar(
title: Text(widget.channelName),
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
children:
)));
}
}
這里需要注意的是,我們并不需要把參數(shù)在創(chuàng)建state實(shí)例的時(shí)候傳入,state可以直接訪問(wèn)widget.channelName獲取到組件的屬性。
引入聲網(wǎng)SDK
因?yàn)槲覀冊(cè)谧铋_始已經(jīng)在pubspec.yaml中添加了agora_rtc_engine的依賴,因此我們現(xiàn)在可以直接通過(guò)以下方式引入聲網(wǎng) SDK。
import'package:agora_rtc_engine/agora_rtc_engine.dart';
引入后即可以使用創(chuàng)建聲網(wǎng)媒體引擎實(shí)例。在使用聲網(wǎng) SDK 進(jìn)行視頻通話之前,我們需要進(jìn)行以下初始化工作。初始化工作應(yīng)該在整個(gè)頁(yè)面生命周期中只做一次,因此這里我們需要 overrideinitState方法,在這個(gè)方法里做好初始化。
class_CallPageStateextendsState
@override
void initState() {
super.initState();
initialize();
}
void initialize() {
_initAgoraRtcEngine();
_addAgoraEventHandlers();
}
/// Create agora sdk instance and initialze
void _initAgoraRtcEngine() {
AgoraRtcEngine.create(APP_ID);
AgoraRtcEngine.enableVideo();
}
/// Add agora event handlers
void _addAgoraEventHandlers() {
AgoraRtcEngine.onError = (int code) {
// sdk error
};
AgoraRtcEngine.onJoinChannelSuccess =
(String channel, int uid, int elapsed) {
// join channel success
};
AgoraRtcEngine.onUserJoined = (int uid, int elapsed) {
// there's a new user joining this channel
};
AgoraRtcEngine.onUserOffline = (int uid, int reason) {
// there's an existing user leaving this channel
};
}
}
注意:有關(guān)如何獲取聲網(wǎng) APP_ID,請(qǐng)?jiān)L問(wèn) docs.gora.io 參閱官方文檔。
在以上的代碼中我們主要?jiǎng)?chuàng)建了聲網(wǎng)的媒體 SDK 實(shí)例并監(jiān)聽了關(guān)鍵事件,接下去我們會(huì)開始做視頻流的處理。
在一般的視頻通話中,對(duì)于本地設(shè)備來(lái)說(shuō)一共會(huì)有兩種視頻流,本地流與遠(yuǎn)端流 - 前者需要通過(guò)本地?cái)z像頭采集渲染并發(fā)送出去,后者需要接收遠(yuǎn)端流的數(shù)據(jù)后渲染?,F(xiàn)在我們需要?jiǎng)討B(tài)地將最多4人的視頻流渲染到通話頁(yè)面。
我們會(huì)以大致這樣的結(jié)構(gòu)渲染通話頁(yè)面。
這里和首頁(yè)不同的是,放置通話操作按鈕的工具欄是覆蓋在視頻上的,因此這里我們會(huì)使用Stack組件來(lái)放置層疊組件。
為了更好地區(qū)分 UI 構(gòu)建,我們將視頻構(gòu)建與工具欄構(gòu)建分為兩個(gè)方法。
本地流創(chuàng)建與渲染
要渲染本地流,需要在初始化 SDK 完成后創(chuàng)建一個(gè)供視頻流渲染的容器,然后通過(guò) SDK 將本地流渲染到對(duì)應(yīng)的容器上。聲網(wǎng) SDK 提供了createNativeView的方法以創(chuàng)建容器,在獲取到容器并且成功渲染到容器視圖上后,我們就可以利用SDK加入頻道與其他客戶端互通了。
void initialize() {
_initAgoraRtcEngine();
_addAgoraEventHandlers();
// use _addRenderView everytime a native video view is needed
_addRenderView(0, (viewId) {
// local view setup & preview
AgoraRtcEngine.setupLocalVideo(viewId, 1);
AgoraRtcEngine.startPreview();
// state can access widget directly
AgoraRtcEngine.joinChannel(null, widget.channelName, null, 0);
});
}
/// Create a native view and add a new video session object
/// The native viewId can be used to set up local/remote view
void _addRenderView(int uid, Function(int viewId) finished) {
Widget view = AgoraRtcEngine.createNativeView(uid, (viewId) {
setState(() {
_getVideoSession(uid).viewId = viewId;
if (finished != null) {
finished(viewId);
}
});
});
VideoSession session = VideoSession(uid, view);
_sessions.add(session);
}
注意:代碼最后利用 uid 與容器信息創(chuàng)建了一個(gè)VideoSession對(duì)象并添加到_sessions中,這主要是為了視頻布局需要,這塊稍后會(huì)詳細(xì)觸及。
遠(yuǎn)端流監(jiān)聽與渲染
遠(yuǎn)端流的監(jiān)聽其實(shí)我們已經(jīng)在前面的初始化代碼中提及了,我們可以監(jiān)聽 SDK 提供的onUserJoined與onUserOffline回調(diào)來(lái)判斷是否有其他用戶進(jìn)出當(dāng)前頻道,若有新用戶加入頻道,就為他創(chuàng)建一個(gè)渲染容器并做對(duì)應(yīng)的渲染;若有用戶離開頻道,則去掉他的渲染容器。
AgoraRtcEngine.onUserJoined = (int uid, int elapsed) {
setState(() {
_addRenderView(uid, (viewId) {
AgoraRtcEngine.setupRemoteVideo(viewId, 1, uid);
});
});
};
AgoraRtcEngine.onUserOffline = (int uid, int reason) {
setState(() {
_removeRenderView(uid);
});
};
/// Remove a native view and remove an existing video session object
void _removeRenderView(int uid) {
VideoSession session = _getVideoSession(uid);
if (session != null) {
_sessions.remove(session);
}
AgoraRtcEngine.removeNativeView(session.viewId);
}
注意:_sessions的作用是在本地保存一份當(dāng)前頻道內(nèi)的視頻流列表信息。因此在用戶加入的時(shí)候,需要?jiǎng)?chuàng)建對(duì)應(yīng)的VideoSession對(duì)象并添加到sessions,在用戶離開的時(shí)候,則需要?jiǎng)h除對(duì)應(yīng)的VideoSession實(shí)例。
視頻流布局
在有了_sessions數(shù)組,且每一個(gè)本地/遠(yuǎn)端流都有了一個(gè)對(duì)應(yīng)的原生渲染容器后,我們就可以開始對(duì)視頻流進(jìn)行布局了。
/// Helper function to get list of native views
List
return _sessions.map((session) => session.view).toList();
}
/// Video view wrapper
Widget _videoView(view) {
returnExpanded(child: Container(child: view));
}
/// Video view row wrapper
Widget _expandedVideoRow(List
List
views.map((Widget view) => _videoView(view)).toList();
returnExpanded(
child: Row(
children: wrappedViews,
));
}
/// Video layout wrapper
Widget _viewRows() {
List
switch (views.length) {
case1:
returnContainer(
child: Column(
children:
));
case2:
returnContainer(
child: Column(
children:
_expandedVideoRow([views[0]]),
_expandedVideoRow([views[1]])
],
));
case3:
returnContainer(
child: Column(
children:
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 3))
],
));
case4:
returnContainer(
child: Column(
children:
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 4))
],
));
default:
}
returnContainer();
}
工具欄(掛斷、靜音、切換攝像頭)
在實(shí)現(xiàn)完視頻流布局后,我們接下來(lái)實(shí)現(xiàn)視頻通話的操作工具欄。工具欄里有三個(gè)按鈕,分別對(duì)應(yīng)靜音、掛斷、切換攝像頭的順序。用簡(jiǎn)單的flexRow布局即可。
/// Toolbar layout
Widget _toolbar() {
returnContainer(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.symmetric(vertical: 48),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children:
RawMaterialButton(
onPressed: () => _onToggleMute(),
child: newIcon(
muted ? Icons.mic : Icons.mic_off,
color: muted ? Colors.white : Colors.blueAccent,
size: 20.0,
),
shape: newCircleBorder(),
elevation: 2.0,
fillColor: muted?Colors.blueAccent : Colors.white,
padding: constEdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: () => _onCallEnd(context),
child: newIcon(
Icons.call_end,
color: Colors.white,
size: 35.0,
),
shape: newCircleBorder(),
elevation: 2.0,
fillColor: Colors.redAccent,
padding: constEdgeInsets.all(15.0),
),
RawMaterialButton(
onPressed: () => _onSwitchCamera(),
child: newIcon(
Icons.switch_camera,
color: Colors.blueAccent,
size: 20.0,
),
shape: newCircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: constEdgeInsets.all(12.0),
)
],
),
);
}
void _onCallEnd(BuildContext context) {
Navigator.pop(context);
}
void _onToggleMute() {
setState(() {
muted = !muted;
});
AgoraRtcEngine.muteLocalAudioStream(muted);
}
void _onSwitchCamera() {
AgoraRtcEngine.switchCamera();
}
最終整合
現(xiàn)在兩個(gè)部分的 UI 都完成了,我們接下去要將這兩個(gè)組件通過(guò)Stack組裝起來(lái)。
@override
Widget build(BuildContext context) {
returnScaffold(
appBar: AppBar(
title: Text(widget.channelName),
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
children:
)));
清理
若只在當(dāng)前頁(yè)面使用聲網(wǎng) SDK,則需要在離開前調(diào)用destroy接口將 SDK 實(shí)例銷毀。若需要跨頁(yè)面使用,則推薦將 SDK 實(shí)例做成單例以供不同頁(yè)面訪問(wèn)。同時(shí)也要注意對(duì)原生渲染容器的釋放,可以至直接使用removeNativeView方法釋放對(duì)應(yīng)的原生容器。
@override
void dispose() {
// clean up native views & destroy sdk
_sessions.forEach((session) {
AgoraRtcEngine.removeNativeView(session.viewId);
});
_sessions.clear();
AgoraRtcEngine.destroy();
super.dispose();
}
最終效果:
總結(jié)
Flutter 作為新生事物,難免還是有他不成熟的地方,但我們已經(jīng)從他現(xiàn)在的進(jìn)步上看到了巨大的潛力。從目前的體驗(yàn)來(lái)看,只要有充足的社區(qū)資源,在 Flutter 上開發(fā)跨平臺(tái)應(yīng)用還是比較舒服的。聲網(wǎng)提供的 Flutter SDK 基本已經(jīng)覆蓋了原生 SDK 提供的大部分方法,開發(fā)體驗(yàn)基本可以和原生 SDK 開發(fā)保持一致。這次也是基于學(xué)習(xí)的態(tài)度寫下了這篇文章,希望對(duì)于想要使用 Flutter 開發(fā) RTC 應(yīng)用的同學(xué)有所幫助。
文中講解的完整代碼及 Agora Flutter SDK 可在 Github 獲取。
Agora Flutter SDK:
https://github.com/AgoraIO/Flutter-SDK
Quickstart Demo:
https://github.com/AgoraIO-Community/Agora-Flutter-Quickstart
-
視頻通話
+關(guān)注
關(guān)注
0文章
49瀏覽量
11787
原文標(biāo)題:構(gòu)建你的第一個(gè)Flutter視頻通話應(yīng)用
文章出處:【微信號(hào):shengwang-agora,微信公眾號(hào):聲網(wǎng)Agora】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論