构建你的第一个Flutter视频通话应用
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 找到。