上周,我們發布了Agora Flutter SDK之后,吸引了 Flutter 社區的諸多關注。Google Flutter 與 Dart 的產品負責人 Tim Sneath,在看到 Agora Flutter SDK 后,還特意在社交媒體上發推點贊。
今天我們就來看一下如何使用 Agora Flutter SDK 快速構建一個簡單的移動跨平臺視頻通話應用。
環境準備
在 Flutter 中文網(flutterchina.club)上,關于搭建開放環境的教程已經相對比較完善了,有關 IDE 與環境配置的過程本文不再贅述,若 Flutter 安裝有問題,可以執行 flutter doctor 做配置檢查。
本文使用 MacOS 下的 VS Code 作為主開發環境。
目標
我們希望可以使用 Flutter+Agora Flutter SDK 實現一個簡單的視頻通話應用,這個視頻通話應用需要包含以下功能,
加入通話房間
視頻通話
前后攝像頭切換
本地靜音/取消靜音
聲網的視頻通話是按通話房間區分的,同一個通話房間內的用戶都可以互通。為了方便區分,這個演示會需要一個簡單的表單頁面讓用戶提交選擇加入哪一個房間。同時一個房間內可以容納最多 4 個用戶,當用戶數不同時我們需要展示不同的布局。
想清楚了?動手擼代碼了。
項目創建
首先在 VS Code 選擇查看->命令面板(或直接使用 cmd + shift + P)調出命令面板,輸入 flutter 后選擇Flutter: New Project創建一個新的 Flutter 項目,項目的名字為agora_flutter_quickstart,隨后等待項目創建完成即可。
現在執行啟動->啟動調試(或 F5)即可看到一個最簡單的計數 App。
看起來我們有了一個很好的開始
接下去我們需要對我們新建的項目做一下簡單的配置以使其可以引用和使用 Agora Flutter SDK。
打開項目根目錄下的 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 會自動執行flutter packages get更新依賴。
應用首頁
在項目配置完成后,我們就可以開始開發了。首先我們需要創建一個頁面文件替換掉默認示例代碼中的MyHomePage類。我們可以在lib/src下創建一個pages目錄,并創建一個index.dart文件。
如果你已經完成了官方教程 Write your first Flutter app,那么以下代碼對你來說就應該不難理解。
classIndexPageextendsStatefulWidget {
@override
State
returnnewIndexState();
}
}
classIndexStateextendsState
@override
Widget build(BuildContext context) {
// UI
}
onJoin() {
//TODO
}
}
現在我們需要開始在build方法中構造首頁的 UI。
按上圖分解 UI 后,我們可以將我們的首頁代碼修改如下
@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,
),
)
],
))
],
)),
));
}
執行 F5 啟動查看,應該可以看到下圖
看起來不錯!但也只是看起來不錯。我們的UI現在只能看,還不能交互。我們希望可以基于現在的 UI 實現以下功能,
1. 為 Join 按鈕添加回調導航到通話頁面
2. 對頻道名做檢查,若嘗試加入頻道時頻道名為空,則在 TextField 上提示錯誤
TextField 輸入校驗
TextField 自身提供了一個decoration屬性,我們可以提供一個InputDecoration的對象來標識 TextField 的裝飾樣式。InputDecoration里的errorText屬性非常適合在我們這里被拿來使用, 同時我們利用TextEditingController對象來記錄 TextField 的值,以判斷當前是否應該顯示錯誤。因此經過簡單的修改后,我們的 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;
});
}
在點擊加入頻道按鈕的時候回觸發onJoin回調,回調中會先通過setState更新 TextField 的狀態以做組件重繪。
注意: 不要忘了 overridedispose方法在這個組件的生命周期結束時釋放_controller。
前往通話頁面
到這里我們的首頁基本就算完成了,最后我們在onJoin中創建MaterialPageRoute將用戶導航到通話頁面,在這里我們將獲取的頻道名作為通話頁面構造函數的參數傳遞到下一個頁面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,
)));
}
}
通話頁面
同樣在/lib/src/pages目錄下,我們需要新建一個call.dart文件,在這個文件里我們會實現我們最重要的實時視頻通話邏輯。首先還是需要創建我們的CallPage類。如果你還記得我們在IndexPage的實現,CallPage會需要在構造函數中帶入一個參數作為頻道名。
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:
)));
}
}
這里需要注意的是,我們并不需要把參數在創建state實例的時候傳入,state可以直接訪問widget.channelName獲取到組件的屬性。
引入聲網SDK
因為我們在最開始已經在pubspec.yaml中添加了agora_rtc_engine的依賴,因此我們現在可以直接通過以下方式引入聲網 SDK。
import'package:agora_rtc_engine/agora_rtc_engine.dart';
引入后即可以使用創建聲網媒體引擎實例。在使用聲網 SDK 進行視頻通話之前,我們需要進行以下初始化工作。初始化工作應該在整個頁面生命周期中只做一次,因此這里我們需要 overrideinitState方法,在這個方法里做好初始化。
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
};
}
}
注意:有關如何獲取聲網 APP_ID,請訪問 docs.gora.io 參閱官方文檔。
在以上的代碼中我們主要創建了聲網的媒體 SDK 實例并監聽了關鍵事件,接下去我們會開始做視頻流的處理。
在一般的視頻通話中,對于本地設備來說一共會有兩種視頻流,本地流與遠端流 - 前者需要通過本地攝像頭采集渲染并發送出去,后者需要接收遠端流的數據后渲染。現在我們需要動態地將最多4人的視頻流渲染到通話頁面。
我們會以大致這樣的結構渲染通話頁面。
這里和首頁不同的是,放置通話操作按鈕的工具欄是覆蓋在視頻上的,因此這里我們會使用Stack組件來放置層疊組件。
為了更好地區分 UI 構建,我們將視頻構建與工具欄構建分為兩個方法。
本地流創建與渲染
要渲染本地流,需要在初始化 SDK 完成后創建一個供視頻流渲染的容器,然后通過 SDK 將本地流渲染到對應的容器上。聲網 SDK 提供了createNativeView的方法以創建容器,在獲取到容器并且成功渲染到容器視圖上后,我們就可以利用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 與容器信息創建了一個VideoSession對象并添加到_sessions中,這主要是為了視頻布局需要,這塊稍后會詳細觸及。
遠端流監聽與渲染
遠端流的監聽其實我們已經在前面的初始化代碼中提及了,我們可以監聽 SDK 提供的onUserJoined與onUserOffline回調來判斷是否有其他用戶進出當前頻道,若有新用戶加入頻道,就為他創建一個渲染容器并做對應的渲染;若有用戶離開頻道,則去掉他的渲染容器。
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的作用是在本地保存一份當前頻道內的視頻流列表信息。因此在用戶加入的時候,需要創建對應的VideoSession對象并添加到sessions,在用戶離開的時候,則需要刪除對應的VideoSession實例。
視頻流布局
在有了_sessions數組,且每一個本地/遠端流都有了一個對應的原生渲染容器后,我們就可以開始對視頻流進行布局了。
/// 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();
}
工具欄(掛斷、靜音、切換攝像頭)
在實現完視頻流布局后,我們接下來實現視頻通話的操作工具欄。工具欄里有三個按鈕,分別對應靜音、掛斷、切換攝像頭的順序。用簡單的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();
}
最終整合
現在兩個部分的 UI 都完成了,我們接下去要將這兩個組件通過Stack組裝起來。
@override
Widget build(BuildContext context) {
returnScaffold(
appBar: AppBar(
title: Text(widget.channelName),
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
children:
)));
清理
若只在當前頁面使用聲網 SDK,則需要在離開前調用destroy接口將 SDK 實例銷毀。若需要跨頁面使用,則推薦將 SDK 實例做成單例以供不同頁面訪問。同時也要注意對原生渲染容器的釋放,可以至直接使用removeNativeView方法釋放對應的原生容器。
@override
void dispose() {
// clean up native views & destroy sdk
_sessions.forEach((session) {
AgoraRtcEngine.removeNativeView(session.viewId);
});
_sessions.clear();
AgoraRtcEngine.destroy();
super.dispose();
}
最終效果:
總結
Flutter 作為新生事物,難免還是有他不成熟的地方,但我們已經從他現在的進步上看到了巨大的潛力。從目前的體驗來看,只要有充足的社區資源,在 Flutter 上開發跨平臺應用還是比較舒服的。聲網提供的 Flutter SDK 基本已經覆蓋了原生 SDK 提供的大部分方法,開發體驗基本可以和原生 SDK 開發保持一致。這次也是基于學習的態度寫下了這篇文章,希望對于想要使用 Flutter 開發 RTC 應用的同學有所幫助。
文中講解的完整代碼及 Agora Flutter SDK 可在 Github 獲取。
Agora Flutter SDK:
https://github.com/AgoraIO/Flutter-SDK
Quickstart Demo:
https://github.com/AgoraIO-Community/Agora-Flutter-Quickstart
-
視頻通話
+關注
關注
0文章
49瀏覽量
11768
原文標題:構建你的第一個Flutter視頻通話應用
文章出處:【微信號:shengwang-agora,微信公眾號:聲網Agora】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論