构建你的第一个Flutter视频通话应用

news/2024/5/22 16:27:20

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


http://lihuaxi.xjx100.cn/news/268102.html

相关文章

Linux之重定向命令

1. 重定向命令的介绍 重定向也称为输出重定向&#xff0c;把在终端执行命令的结果保存到目标文件。 2. 重定向命令的使用 命令说明>如果文件存在会覆盖原有文件内容&#xff0c;相当于文件操作中的‘w’模式>>如果文件存在会追加写入文件末尾&#xff0c;相当于文件…

机器学习将会如何影响软件开发和测试?看完这文就懂了

原文作者 | Nate Nead责编 | 晋兆雨&#xff0c;编译| 风车云马机器学习(ML)和人工智能(AI)经常被认为是通向未来世界的大门&#xff0c;在这个世界里机器人像人一样互动&#xff0c;它们可以在各个方面都比人类聪明。如今&#xff0c;机器学习已经被应用在世界各地数以万计的应…

[译]Web Inspector开始支持CSS区域

最近,开发人员和设计师们可以在WebKit中尝试使用CSS区域特性了,我们认为是时候给他们一些开发工具了.最新版本的Chrome Canary中的web inspector现在已经支持下面这些功能: 查找文档中所有的命名流.显示每个命名流的内容和区域链.高亮页面中的CSS区域,就像是把鼠标放在web insp…

NALU的解释

一个图像序列的组成&#xff1a;SPSPPSSEI一个I帧若干个P帧。SPS、PPS、SEI、一个I帧、一个P帧都可以称为一个NALU NALU结构&#xff1a;开始码NALU头NALU数据 &#xff08;开始码&#xff1a; 00 00 00 01分隔符&#xff09; NALU数据为编码器编出来的图像信息或图像数据 在…

pandas画时间序列图

import pandas as pd import matplotlib.pyplot as plt import numpy as np %matplotlib inline # 新建一个时间序列 t_range pd.date_range(2016-01-01, 2016-12-31, freqH) t_range# DatetimeIndex([2016-01-01 00:00:00, 2016-01-01 01:00:00, # 2016-01-01…

日期处理工具类 -【二】

1、返回本周的第一天(周日为每周第一天) 1 /**2 * 返回本周的第一天(周日为每周第一天)3 * return4 */5 public static String getTheFirstDayOfThisWeek(){6 SimpleDateFormat format new SimpleDateFormat("yyyy-MM-dd");7 Calendar cal Calendar.get…

Commonjs规范

为什么有模块化 1.方便代码维护2.每个功能放到一个模块内3.解决命名问题&#xff0c;全局变量污染问题常见的模块化 1.我们写方法写属性都放在对象里(单例模式) 缺陷声明的对象也有可能命名冲突,不能完全解决上述问题var obj {a:1,init(){}fn(){} } 复制代码2.自执行函数(IIFE…

详解GCN、GAT、凸优化、贝叶斯、MCMC、LDA

如果你准备发AI方向的论文&#xff0c;或准备从事科研工作或已在企业中担任AI算法岗的工作。那么我真诚的向大家推荐&#xff0c;贪心学院《高阶机器学习研修班》&#xff0c;目前全网上应该找不到类似体系化的课程。课程精选了四大主题进行深入的剖析讲解&#xff0c;四个模块…