Flutter 1.0 發佈也已經有一段時間了,春節後聲網發佈了Flutter平臺上的Agora Flutter SDK(一個基於 Flutter 開發的 Plugin),今天我們就來看一下如何使用Agora Flutter SDK快速構建一個簡單的移動跨平臺視頻通話應用。
環境準備
在Flutter中文網上,關於搭建開放環境的教程已經相對比較完善了,有關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,那麼以下代碼對你來說就應該不難理解。
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實現以下功能,
- 爲Join按鈕添加回調導航到通話頁面
- 對頻道名做檢查,若嘗試加入頻道時頻道名爲空,則在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';
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
獲取到組件的屬性。
引入聲網SDK
因爲我們在最開始已經在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應用的同學有所幫助。
文章中講解的完整代碼都可以在 Agora-Flutter-Quickstart 找到。