做者:張乾澤,聲網Agora 工程師android
如對咱們的Flutter插件開發過程感興趣,或遇到實時音視頻相關開發問題,歡迎訪問聲網 Agora問答版塊,發帖與咱們的工程師交流。git
Flutter 1.0 發佈也已經有一段時間了,春節後聲網 Agora 以 Flutter 插件的形式推出了 Agora Flutter SDK,能夠幫助 Flutter 開發者快速實現 Flutter 視頻通話應用。github
如今咱們就來看一下如何使用Agora Flutter SDK快速構建一個簡單的移動跨平臺視頻通話應用。數組
在Flutter中文網上,關於搭建開放環境的教程已經相對比較完善了,有關IDE與環境配置的過程本文再也不贅述,若Flutter安裝有問題,能夠執行flutter doctor作配置檢查。markdown
本文使用MacOS下的VS Code做爲主開發環境。session
咱們但願可使用Flutter+Agora Flutter SDK實現一個簡單的視頻通話應用,這個視頻通話應用須要包含如下功能,併發
聲網的視頻通話是按通話房間區分的,同一個通話房間內的用戶均可以互通。爲了方便區分,這個演示會須要一個簡單的表單頁面讓用戶提交選擇加入哪個房間。同時一個房間內能夠容納最多4個用戶,當用戶數不一樣時咱們須要展現不一樣的佈局。app
想清楚了?動手擼代碼了。ide
首先在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,那麼如下代碼對你來講就應該不難理解。
class IndexPage extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return new IndexState();
}
}
class IndexState extends State<IndexPage> {
@override
Widget build(BuildContext context) {
// UI
}
onJoin() {
//TODO
}
}
複製代碼
如今咱們須要開始在build
方法中構造首頁的UI。
按上圖分解UI後,咱們能夠將咱們的首頁代碼修改以下,
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Agora Flutter QuickStart'),
),
body: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20),
height: 400,
child: Column(
children: <Widget>[
Row(children: <Widget>[]),
Row(children: <Widget>[
Expanded(
child: TextField(
decoration: InputDecoration(
border: UnderlineInputBorder(
borderSide: BorderSide(width: 1)),
hintText: 'Channel name'),
))
]),
Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Row(
children: <Widget>[
Expanded(
child: RaisedButton(
onPressed: () => onJoin(),
child: Text("Join"),
color: Colors.blueAccent,
textColor: Colors.white,
),
)
],
))
],
)),
));
}
複製代碼
執行F5啓動查看,應該能夠看到下圖,
看起來不錯!但也只是看起來不錯。咱們的UI如今只能看,還不能交互。咱們但願能夠基於如今的UI實現如下功能,
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';
class IndexState extends State<IndexPage> {
...
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) => new CallPage(
channelName: _channelController.text,
)));
}
}
複製代碼
一樣在/lib/src/pages
目錄下,咱們須要新建一個call.dart
文件,在這個文件裏咱們會實現咱們最重要的實時視頻通話邏輯。首先仍是須要建立咱們的CallPage
類。若是你還記得咱們在IndexPage
的實現,CallPage
會須要在構造函數中帶入一個參數做爲頻道名。
class CallPage extends StatefulWidget {
/// non-modifiable channel name of the page
final String channelName;
/// Creates a call page with given channel name.
const CallPage({Key key, this.channelName}) : super(key: key);
@override
_CallPageState createState() {
return new _CallPageState();
}
}
class _CallPageState extends State<CallPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.channelName),
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: <Widget>[],
)));
}
}
複製代碼
這裏須要注意的是,咱們並不須要把參數在建立state
實例的時候傳入,state
能夠直接訪問widget.channelName
獲取到組件的屬性。
由於咱們在最開始已經在pubspec.yaml
中添加了agora_rtc_engine
的依賴,所以咱們如今能夠直接經過如下方式引入聲網sdk。
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
複製代碼
引入後便可以使用建立聲網媒體引擎實例。在使用聲網SDK進行視頻通話以前,咱們須要進行如下初始化工做。初始化工做應該在整個頁面生命週期中只作一次,所以這裏咱們須要overrideinitState
方法,在這個方法裏作好初始化。
class _CallPageState extends State<CallPage> {
@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,請參閱聲網官方文檔。
在以上的代碼中咱們主要建立了聲網的媒體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<Widget> _getRenderViews() {
return _sessions.map((session) => session.view).toList();
}
/// Video view wrapper
Widget _videoView(view) {
return Expanded(child: Container(child: view));
}
/// Video view row wrapper
Widget _expandedVideoRow(List<Widget> views) {
List<Widget> wrappedViews =
views.map((Widget view) => _videoView(view)).toList();
return Expanded(
child: Row(
children: wrappedViews,
));
}
/// Video layout wrapper
Widget _viewRows() {
List<Widget> views = _getRenderViews();
switch (views.length) {
case 1:
return Container(
child: Column(
children: <Widget>[_videoView(views[0])],
));
case 2:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow([views[0]]),
_expandedVideoRow([views[1]])
],
));
case 3:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 3))
],
));
case 4:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 4))
],
));
default:
}
return Container();
}
複製代碼
在實現完視頻流佈局後,咱們接下來實現視頻通話的操做工具欄。工具欄裏有三個按鈕,分別對應靜音、掛斷、切換攝像頭的順序。用簡單的flex Row
佈局便可。
/// Toolbar layout
Widget _toolbar() {
return Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.symmetric(vertical: 48),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RawMaterialButton(
onPressed: () => _onToggleMute(),
child: new Icon(
muted ? Icons.mic : Icons.mic_off,
color: muted ? Colors.white : Colors.blueAccent,
size: 20.0,
),
shape: new CircleBorder(),
elevation: 2.0,
fillColor: muted?Colors.blueAccent : Colors.white,
padding: const EdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: () => _onCallEnd(context),
child: new Icon(
Icons.call_end,
color: Colors.white,
size: 35.0,
),
shape: new CircleBorder(),
elevation: 2.0,
fillColor: Colors.redAccent,
padding: const EdgeInsets.all(15.0),
),
RawMaterialButton(
onPressed: () => _onSwitchCamera(),
child: new Icon(
Icons.switch_camera,
color: Colors.blueAccent,
size: 20.0,
),
shape: new CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.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) {
return Scaffold(
appBar: AppBar(
title: Text(widget.channelName),
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: <Widget>[_viewRows(), _toolbar()],
)));
複製代碼
若只在當前頁面使用聲網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應用的同窗有所幫助。
文章中講解的完整代碼均可以在 Github 找到。
如在開發過程當中遇到問題,請移步 RTC 開發者社區,本文做者會及時回覆。