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 sdkagora_rtc_engine: ^0.9.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 {@overrideState<StatefulWidget> createState() {return new IndexState();}
}class IndexState extends State<IndexPage> {@overrideWidget 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实现以下功能,

  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;@overridevoid dispose() {// dispose input controller_channelController.dispose();super.dispose();}@overrideWidget 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的状态以做组件重绘。

注意: 不要忘了overridedispose方法在这个组件的生命周期结束时释放_controller

前往通话页面

到这里我们的首页基本就算完成了,最后我们在onJoin中创建MaterialPageRoute将用户导航到通话页面,在这里我们将获取的频道名作为通话页面构造函数的参数传递到下一个页面CallPage

import './call.dart';class IndexState extends State<IndexPage> {...onJoin() {// update input validationsetState(() {_channelController.text.isEmpty? _validateError = true: _validateError = false;});if (_channelController.text.isNotEmpty) {// push video page with given channel nameNavigator.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 pagefinal 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> {@overrideWidget 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> {@overridevoid initState() {super.initState();initialize();}void initialize() {_initAgoraRtcEngine();_addAgoraEventHandlers();}/// Create agora sdk instance and initialzevoid _initAgoraRtcEngine() {AgoraRtcEngine.create(APP_ID);AgoraRtcEngine.enableVideo();}/// Add agora event handlersvoid _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 & previewAgoraRtcEngine.setupLocalVideo(viewId, 1);AgoraRtcEngine.startPreview();// state can access widget directlyAgoraRtcEngine.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 viewvoid _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提供的onUserJoinedonUserOffline回调来判断是否有其他用户进出当前频道,若有新用户加入频道,就为他创建一个渲染容器并做对应的渲染;若有用户离开频道,则去掉他的渲染容器。

    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 objectvoid _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 viewsList<Widget> _getRenderViews() {return _sessions.map((session) => session.view).toList();}/// Video view wrapperWidget _videoView(view) {return Expanded(child: Container(child: view));}/// Video view row wrapperWidget _expandedVideoRow(List<Widget> views) {List<Widget> wrappedViews =views.map((Widget view) => _videoView(view)).toList();return Expanded(child: Row(children: wrappedViews,));}/// Video layout wrapperWidget _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 layoutWidget _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组装起来。

    @overrideWidget 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方法释放对应的原生容器,

    @overridevoid 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 找到

构建你的第一个Flutter视频通话应用相关推荐

  1. flutter 如何判断在哪个页面_Agora 教程:构建你的第一个 Flutter 视频通话应用

    作者:张乾泽,声网Agora 工程师 我们基于 Agora SDK 封装了 Agora Flutter SDK ,开发者可以在 Flutter 应用中快速实现稳定.可靠的实时音视频通话.互动直播应用. ...

  2. 开始使用-编写你的第一个Flutter应用程序

    2019独角兽企业重金招聘Python工程师标准>>> 这是创建您的第一个Flutter应用程序的指南. 如果您熟悉面向对象的代码和基本编程概念(如变量,循环和条件),则可以完成本教 ...

  3. 编写你的第一个 Flutter App

    需求: 完成一个简单的移动应用程序,功能是:为一个创业公司生成建议的名称.用户可以选择和取消选择的名称.保存(收藏)喜欢的名称.该代码一次生成十个名称,当用户滚动时,会生成一新批名称.用户可以点击导航 ...

  4. Flutter初体验(二)—— 创建第一个Flutter APP

    Flutter初体验(二)--- 创建第一个Flutter APP 在第一篇文章 Flutter初体验(一)---Mac 安装配置,学习了配置 Flutter 开发环境,并运行了Demo项目,本篇根据 ...

  5. 12_第一个Flutter程序

    2019独角兽企业重金招聘Python工程师标准>>> 使用 package 在这一步中,你将开始使用一个名为 english_words 的开源软件包,其中包含数千个最常用的英文单 ...

  6. 第一个Flutter demo——实现无限循环列表

    第一个Flutter demo(一) 参照flutter官网,实现第一个Flutter应用.第一部分:实现无限循环列表 第一部分功能介绍: 从零开始创建了一个 Flutter 应用: 编写 Dart ...

  7. Flutter 学习第四天 第一个flutter项目

    这个转自我自己的有道云 想看图片去那里 文档:Day2_26 Dart 面向对象 异步语法.md 链接:http://note.youdao.com/noteshare?id=1dc3ee54f0dd ...

  8. 写一个Flutter手势追踪插件

    /   今日科技快讯   / 近日字节跳动组织升级,张利东担任字节跳动(中国)董事长,全面协调公司运营,包括字节跳动中国的战略.商业化.公共事务.公共关系.财务.人力:抖音CEO张楠将担任字节跳动(中 ...

  9. Flutter入门——创建第一个Flutter项目

    Flutter入门--创建第一个Flutter项目 一.创建项目 第一个项目使用Android Studio创建,步骤如下: 先打开Android Studio,会有一个创建新的Flutter应用的选 ...

最新文章

  1. 美多商城之用户登录(账号登录)
  2. 资源推荐 | 知识图谱顶会文献集锦(附链接)
  3. ASSERT(断言)的用法
  4. Spark编程基础(Python版)
  5. 计算机中的 marshal 是什么意思?(列集:将数据从某种格式存为流格式的操作)(序列化)(marshalling、unmarshalling散集)
  6. sqlsever使用charindex查询中文字符返回固定值_10个MySQL使用技巧及30个搜索优化方法...
  7. Dreamoon Likes Sequences CodeForces - 1330D(组合数学+位运算)
  8. Java 8: LocalDate、LocalTime 、LocalDateTime 处理日期时间
  9. UI设计灵感|逻辑感十足的数据可视化界面设计
  10. 输入两棵二叉树A,B,判断B是不是A的子结构。(我们约定空树不是任意一个树的子结构)
  11. 初识Jasima-调度仿真系列教程预告
  12. NYOJ题目66-分数拆分
  13. java怎么写程序_用Java程序怎么写?
  14. 剖析Linux内核源码分析《入门技术栈》
  15. C语言超市商品管理系统代码(精)
  16. 【Fortran】STOP语句
  17. Word打开和关闭速度均很慢的解决方法
  18. HTML----基础案例(与笔记对应)
  19. 汉语拼音的36个韵母该怎么学?
  20. matlab直流电机初始转速,直流电机转速控制的matlab实验.doc

热门文章

  1. 当 Docker 遇到 Intellij IDEA,再次解放了生产力~
  2. 拒绝躺平,Redis选择实现了自己的VM
  3. 用了 3 年 Apollo,最后我选择了 Nacos,原因不多说了
  4. 盘点 15 个好用的 API 接口管理神器
  5. 大白话带你认识Kafka
  6. 总奖金200万的算法赛方案汇总!
  7. 中科大倪茹:感谢开源,我从入门竞赛到Top 10的经验分享
  8. 王敏捷 - 深度学习框架这十年!
  9. 辞去美国终身教职回国的帅教授,拟增列为顶尖985大学博导
  10. 爷青回!GAN生成的超级马里奥关卡,可以永不通关的那种!