作者:張乾澤,聲網Agora 工程師
Flutter 1.0 釋出也已經有一段時間了,春節後聲網 Agora 以 Flutter 外掛的形式推出了 Agora Flutter SDK,可以幫助 Flutter 開發者快速實現 Flutter 視訊通話應用。
現在我們就來看一下如何使用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應用的同學有所幫助。
文章中講解的完整程式碼都可以在 Github 找到。
如在開發過程中遇到問題,請移步 RTC 開發者社群,本文作者會及時回覆。