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 找到。