
实时视频通话能够拉近人与人之间的距离,为用户提供沉浸式的交流体验,帮助你的 app 提高用户黏性。
今天我们就来看一下如何使用 Agora Flutter SDK 快速构建一个简单的移动跨平台视频通话应用。
本文为「声网 SDK 教程」系列内容
我们希望可以使用 Flutter+Agora Flutter SDK 实现一个简单的视频通话应用,这个视频通话应用需要包含以下功能:
● 加入通话房间
● 视频通话
● 前后摄像头切换
● 本地静音/取消静音
声网的视频通话是按通话房间区分的,同一个通话房间内的用户都可以互通。为了方便区分,这个演示会需要一个简单的表单页面让用户提交选择加入哪一个房间。同时一个房间内可以容纳最多 4 个用户,当用户数不同时我们需要展示不同的布局。
效果如下:


本过程需要Flutter 2.0 或更高版本
在 Flutter 官网上,关于搭建开放环境的教程已经相对比较完善了,有关 IDE 与环境配置的过程本文不再赘述。
本文使用 MacOS 下的 VS Code 作为主开发环境。
首先在 VS Code 选择查看->命令面板(或直接使用 cmd + shift + P )调出命令面板,输入 flutter 后选择 Flutter: New Project 创建一个新的 Flutter 项目,项目的名字为 agora_flutter_quickstart,随后等待项目创建完成即可。
现在执行启动->启动调试(或 F5)即可看到一个最简单的计数App

看起来我们有了一个很好的开始:) 接下去我们需要对我们新建的项目做一下简单的配置以使其可以引用和使用 agora flutter sdk。
打开项目根目录下的 pubspec.yaml 文件,在 dependencies 下添加 agora_rtc_engine: ^5.3.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 sdkagora_rtc_engine: ^5.3.0dev_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 {StatecreateState () {return new IndexState();}}class IndexState extends State<IndexPage> {Widget build(BuildContext context) {// UI}onJoin() {//TODO}}
现在我们需要开始在 build 方法中构造首页的 UI。

按上图分解 UI 后,我们可以将我们的首页代码修改如下:
@overrideWidget 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>[: <Widget>[]),: <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 实现以下功能:
1、为 Join 按钮添加回调导航到通话页面
2、对频道名做检查,若尝试加入频道时频道名为空,则在 TextField 上提示错误。
TextField输入校验
TextField 自身提供了一个 decoration 属性,我们可以提供一个 InputDecoration 的对象来标识 TextField 的装饰样式。InputDecoration 里的 errorText 属性非常适合在我们这里被拿来使用。
同时我们利用 TextEditingController 对象来记录 TextField 的值,以判断当前是否应该显示错误。因此经过简单的修改后,我们的 TextField 代码就变成了这样。
final _channelController = TextEditingController();/// if channel textfield is validated to have errorbool _validateError = false;void dispose() {// dispose input controller_channelController.dispose();super.dispose();}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 validationsetState(() {_channelController.text.isEmpty? _validateError = true: _validateError = false;});}
在点击加入频道按钮的时候回触发 onJoin 回调,回调中会先通过 setState 更新 TextField 的状态以做组件重绘。

注意: 不要忘了 override dispose方法在这个组件的生命周期结束时释放 _channelController。
void dispose() {// dispose input controller_channelController.dispose();super.dispose();}
前往通话页面
到这里我们的首页基本就算完成了,最后我们在 onJoin 中创建 MaterialPageRoute 将用户导航到通话页面,在这里我们将获取的频道名作为通话页面构造函数的参数传递到下一个页面 CallPage。
同样在 /lib/src/pages 目录下,我们需要新建一个 call.dart 文件,在这个文件里我们会实现我们最重要的实时视频通话逻辑。首先还是需要创建我们的 CallPage 类。如果你还记得我们在 IndexPage 的实现,CallPage 会需要在构造函数中带入一个参数作为频道名。
class CallPage extends StatefulWidget {/// non-modifiable channel name of the pagefinal String channelName;/// Creates a call page with given channel name.const CallPage({Key key, this.channelName}) : super(key: key);_CallPageState createState() {return new _CallPageState();}}class _CallPageState extends State<CallPage> {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 进行视频通话之前,我们需要进行以下初始化工作。初始化工作应该在整个页面生命周期中只做一次,因此这里我们需要 override initState 方法,在这个方法里做好初始化。
import 'dart:async';import 'package:agora_rtc_engine/rtc_engine.dart';import 'package:agora_rtc_engine/rtc_local_view.dart' as RtcLocalView;import 'package:agora_rtc_engine/rtc_remote_view.dart' as RtcRemoteView;import 'package:flutter/material.dart';import '../utils/settings.dart';class CallPage extends StatefulWidget {/// non-modifiable channel name of the pagefinal String? channelName;/// non-modifiable client role of the pagefinal ClientRole? role;/// Creates a call page with given channel name.const CallPage({Key? key, this.channelName, this.role}) : super(key: key);_CallPageState createState() => _CallPageState();}class _CallPageState extends State<CallPage> {final _users = <int>[];final _infoStrings =[]; bool muted = false;late RtcEngine _engine;void initState() {super.initState();// initialize agora sdkinitialize();}Future<void> initialize() async {await _initAgoraRtcEngine();_addAgoraEventHandlers();}/// Create agora sdk instance and initializeFuture<void> _initAgoraRtcEngine() async {_engine = await RtcEngine.create(appId);await _engine.enableVideo();}/// Add agora event handlersvoid _addAgoraEventHandlers() {_engine.setEventHandler(RtcEngineEventHandler(error: (code) {// sdk error}, joinChannelSuccess: (channel, uid, elapsed) {// join channel success}, userJoined: (uid, elapsed) {// there's a new user joining this channel}, userOffline: (uid, elapsed) {// there's an existing user leaving this channel}));}}
注意: 有关如何获取声网 APP_ID,请参阅声网官方文档。
在以上的代码中我们主要创建了声网的媒体 SDK 实例并监听了关键事件,接下去我们会开始做视频流的处理。
在一般的视频通话中,对于本地设备来说一共会有两种视频流,本地流与远端流 - 前者需要通过本地摄像头采集渲染并发送出去,后者需要接收远端流的数据后渲染。现在我们需要动态地将最多 4 人的视频流渲染到通话页面。
我们会以大致这样的结构渲染通话页面。

这里和首页不同的是,放置通话操作按钮的工具栏是覆盖在视频上的,因此这里我们会使用 Stack 组件来放置层叠组件。
为了更好地区分UI构建,我们将视频构建与工具栏构建分为两个方法:
本地流创建与渲染
要渲染本地流,需要在初始化 SDK 完成后创建一个供视频流渲染的容器,然后通过 SDK 将本地流渲染到对应的容器上。声网 SDK 提供了 SurfaceView Widget 渲染视频流,我们可以利用 SDK 加入频道与其他客户端互通了。
...final _users = <int>[];/// Create agora sdk instance and initializeFuture<void> _initAgoraRtcEngine() async {_engine = await RtcEngine.create(appId);await _engine.enableVideo();await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);await _engine.setClientRole(widget.role!);}/// Add agora event handlersvoid _addAgoraEventHandlers() {_engine.setEventHandler(RtcEngineEventHandler(error: (code) {setState(() {final info = 'onError: $code';_infoStrings.add(info);});}, joinChannelSuccess: (channel, uid, elapsed) {setState(() {final info = 'onJoinChannel: $channel, uid: $uid';_infoStrings.add(info);});}, leaveChannel: (stats) {setState(() {_infoStrings.add('onLeaveChannel');_users.clear();});}, userJoined: (uid, elapsed) {setState(() {final info = 'userJoined: $uid';_infoStrings.add(info);_users.add(uid);});}, userOffline: (uid, elapsed) {setState(() {final info = 'userOffline: $uid';_infoStrings.add(info);_users.remove(uid);});}, firstRemoteVideoFrame: (uid, width, height, elapsed) {setState(() {final info = 'firstRemoteVideo: $uid ${width}x $height';_infoStrings.add(info);});}));}/// Helper function to get list of native viewsList_getRenderViews() { final Listlist = []; if (widget.role == ClientRole.Broadcaster) {list.add(RtcLocalView.SurfaceView());}_users.forEach((int uid) => list.add(RtcRemoteView.SurfaceView(channelId: widget.channelName!, uid: uid)));return list;}
远端流监听与渲染
远端流的监听其实我们已经在前面的初始化代码中提及了,我们可以监听 SDK 提供的 userJoined 与 userOffline 回调来判断是否有其他用户进出当前频道,若有新用户加入频道,就加入到 _users 数组;若有用户离开频道,则从 _users 数组移除。
视频流布局
在有了 _users 数组,且每一个本地/远端流都有了一个对应的 SurfaceView 后,我们就可以开始对视频流进行布局了。
...class _CallPageState extends State{ final _users =[]; final _infoStrings =[]; bool muted = false;late RtcEngine _engine;...Helper function to get list of native views_getRenderViews() {final Listlist = []; if (widget.role == ClientRole.Broadcaster) {list.add(RtcLocalView.SurfaceView());}uid) => list.add(: widget.channelName!, uid: uid)));return list;}Video view wrapperWidget _videoView(view) {return Expanded(child: Container(child: view));}Video view row wrapperWidget _expandedVideoRow(Listviews) { final wrappedViews = views.map(_videoView).toList(); return Expanded(child: Row(children: wrappedViews,),);}Video layout wrapperWidget _viewRows() {final views = _getRenderViews();switch (views.length) {case 1:return Container(child: Column(children:[_videoView(views[0])], ));case 2:return Container(child: Column(children:[ _expandedVideoRow([views[0]]),_expandedVideoRow([views[1]])],));case 3:return Container(child: Column(children:[ 2)),3))],));case 4:return Container(child: Column(children:[ 2)),4))],));default:}return Container();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('Agora Flutter QuickStart'),),backgroundColor: Colors.black,body: Center(child: Stack(children:[ _viewRows(),],),),);}}
工具栏(挂断、静音、切换摄像头)
在实现完视频流布局后,我们接下来实现视频通话的操作工具栏。工具栏里有三个按钮,分别对应静音、挂断、切换摄像头的顺序。用简单的 flex Row 布局即可。
class _CallPageState extends State{ final _users =[]; final _infoStrings =[]; bool muted = false;late RtcEngine _engine;...Toolbar layoutWidget _toolbar() {if (widget.role == ClientRole.Audience) return Container();return Container(alignment: Alignment.bottomCenter,padding: const EdgeInsets.symmetric(vertical: 48),child: Row(mainAxisAlignment: MainAxisAlignment.center,children:[ RawMaterialButton(onPressed: _onToggleMute,child: Icon(muted ? Icons.mic_off : Icons.mic,color: muted ? Colors.white : Colors.blueAccent,size: 20.0,),shape: CircleBorder(),elevation: 2.0,fillColor: muted ? Colors.blueAccent : Colors.white,padding: const EdgeInsets.all(12.0),),RawMaterialButton(onPressed: () => _onCallEnd(context),child: Icon(Icons.call_end,color: Colors.white,size: 35.0,),shape: CircleBorder(),elevation: 2.0,fillColor: Colors.redAccent,padding: const EdgeInsets.all(15.0),),RawMaterialButton(onPressed: _onSwitchCamera,child: Icon(Icons.switch_camera,color: Colors.blueAccent,size: 20.0,),shape: CircleBorder(),elevation: 2.0,fillColor: Colors.white,padding: const EdgeInsets.all(12.0),)],),);}Info panel to show logsWidget _panel() {return Container(padding: const EdgeInsets.symmetric(vertical: 48),alignment: Alignment.bottomCenter,child: FractionallySizedBox(heightFactor: 0.5,child: Container(padding: const EdgeInsets.symmetric(vertical: 48),child: ListView.builder(reverse: true,itemCount: _infoStrings.length,itemBuilder: (BuildContext context, int index) {if (_infoStrings.isEmpty) {return Text(// return type can't be null, a widget was required}return Padding(padding: const EdgeInsets.symmetric(vertical: 3,horizontal: 10,),child: Row(mainAxisSize: MainAxisSize.min,children: [Flexible(child: Container(padding: const EdgeInsets.symmetric(vertical: 2,horizontal: 5,),decoration: BoxDecoration(color: Colors.yellowAccent,borderRadius: BorderRadius.circular(5),),child: Text(_infoStrings[index],style: TextStyle(color: Colors.blueGrey),),),)],),);},),),),);}void _onCallEnd(BuildContext context) {Navigator.pop(context);}void _onToggleMute() {{muted = !muted;});_engine.muteLocalAudioStream(muted);}void _onSwitchCamera() {_engine.switchCamera();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('Agora Flutter QuickStart'),),backgroundColor: Colors.black,body: Center(child: Stack(children:[ _viewRows(),_panel(),_toolbar(),],),),);}}
清理
若只在当前页面使用声网 SDK,则需要在离开前调用 destroy 接口将 SDK 实例销毁。若需要跨页面使用,则推荐将 SDK 实例做成单例以供不同页面访问。
class _CallPageState extends State<CallPage> {final _users = <int>[];final _infoStrings =[]; bool muted = false;late RtcEngine _engine;void dispose() {// clear users_users.clear();_dispose();super.dispose();}Future<void> _dispose() async {// destroy sdkawait _engine.leaveChannel();await _engine.destroy();}...}
最终效果:

截止文章撰写时间,Flutter SDK 已经发布到 3.0,已支持 Android/iOS/macOS/Windows/Web 平台,社区也越来越活越和成熟。最新的声网 Flutter SDK agora_rtc_engine 已支持 Android/iOS/macOS/Windows/Web 平台,你可以使用一套代码实现跨平台音视频功能。本文内容主要面向初学者,希望对于想要使用 Flutter 开发 RTC 应用的同学有所帮助。
文章中讲解的完整代码都可以在 Agora-Flutter-Quickstart 找到。更多示例可以参考 agora_rtc_engine example。
(正文完)
关注「声网开发者」
关注实时互动领域的
技术实践、行业洞察、人物观点
☟☟☟

