高品质的音视频能力是怎样的? | Qcon 全球软件开发大会·上海站邀请函 了解详情
写点什么

Agora 教程:构建你的第一个 Flutter 视频通话应用

  • 2020-02-25
  • 本文字数:8349 字

    阅读完需:约 27 分钟

Agora 教程:构建你的第一个 Flutter 视频通话应用

我们基于 Agora SDK 封装了 Agora Flutter SDK ,开发者可以在 Flutter 应用中快速实现稳定、可靠的实时音视频通话、互动直播应用。本文将介绍如何使用 Agora Flutter SDK 快速构建一个简单的移动跨平台视频通话应用。


环境准备

在 Flutter 中文网(flutterchina.club)上,关于搭建开放环境的教程已经相对比较完善了,有关 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.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 {  @override  State<StatefulWidget> createState() {    return new IndexState();  }}class IndexState extends State<IndexPage> {  @override  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>[              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 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 的状态以做组件重绘。



注意: 不要忘了 override dispose 方法在这个组件的生命周期结束时释放 _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 进行视频通话之前,我们需要进行以下初始化工作。初始化工作应该在整个页面生命周期中只做一次,因此这里我们需要 override initState 方法,在这个方法里做好初始化。


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,请访问 docs.gora.io 参阅官方文档。


在以上的代码中我们主要创建了声网的媒体 SDK 实例并监听了关键事件,接下去我们会开始做视频流的处理。


在一般的视频通话中,对于本地设备来说一共会有两种视频流,本地流与远端流 - 前者需要通过本地摄像头采集渲染并发送出去,后者需要接收远端流的数据后渲染。现在我们需要动态地将最多 4 人的视频流渲染到通话页面。


我们会以大致这样的结构渲染通话页面。



这里和首页不同的是,放置通话操作按钮的工具栏是覆盖在视频上的,因此这里我们会使用 Stack 组件来放置层叠组件。


为了更好地区分 UI 构建,我们将视频构建与工具栏构建分为两个方法。


本地流创建与渲染

要渲染本地流,需要在初始化 SDK 完成后创建一个供视频流渲染的容器,然后通过 SDK 将本地流渲染到对应的容器上。声网 SDK 提供了 createNativeView 的方法以创建容器,在获取到容器并且成功渲染到容器视图上后,我们就可以利用 SDK 加入频道与其他客户端互通了。


voidinitialize() {       _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(intuid, Function(intviewId) finished) {       Widgetview = AgoraRtcEngine.createNativeView(uid, (viewId) {         setState(() {           _getVideoSession(uid).viewId = viewId;           if(finished != null) {             finished(viewId);           }         });       });       VideoSessionsession = 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();    }
复制代码


工具栏(挂断、静音、切换摄像头)

在实现完视频流布局后,我们接下来实现视频通话的操作工具栏。工具栏里有三个按钮,分别对应静音、挂断、切换摄像头的顺序。用简单的 flexRow 布局即可。


/// 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 SDK 可在 Github 获取。


Agora Flutter SDK:


https://github.com/AgoraIO/Flutter-SDK


Quickstart Demo:


https://github.com/AgoraIO-Community/Agora-Flutter-Quickstart


本文转载自声网 Agora 公众号。


原文链接:https://mp.weixin.qq.com/s/3qmt5HUmkXqH0AhwpuX5yA


2020-02-25 17:48482

评论

发布
暂无评论
发现更多内容

极速体验docker容器健康

程序员欣宸

容器化 docekr 11月月更

如何在知乎平台上做营销推广:推荐几种引流方式

石头IT视角

promise执行顺序面试题令我头秃,你能作对几道

loveX001

JavaScript

vue组件通信方式有哪些?

bb_xiaxia1998

Vue

圆满落幕!回顾 eBPF 技术的发展与挑战

OpenAnolis小助手

Linux 云原生 ebpf 云栖大会 龙蜥社区

百度前端react面试题总结

beifeng1996

React

vue组件通信6种方式总结(常问知识点)

bb_xiaxia1998

Vue

年前端react面试打怪升级之路

beifeng1996

React

这样回答前端面试题才能拿到offer

loveX001

JavaScript

跟着卷卷龙一起学Camera--信号采样01

卷卷龙

ISP camera 11月月更

企业级业务架构设计:方法论与实践 学习笔记

程序员架构进阶

业务架构 TOGAF 11月日更 Zachman

js异步编程面试题你能答上来几道

loveX001

JavaScript

React源码分析3-render阶段(穿插scheduler和reconciler)

goClient1992

React

ElasticSearch这些坑记得避开

知了一笑

Java 架构

ReactDOM.render在react源码中执行之后发生了什么?

flyzz177

React

Linux中 dir 命令还能这样玩!

wljslmz

Linux 11月月更

从纯函数讲起,一窥最深刻的函子 Monad

掘金安东尼

前端 11月月更

计算机网络:数据链路层功能

timerring

计算机网络 11月月更 数据链路层

chrome调试工具之Elements

格斗家不爱在外太空沉思

浏览器 11月月更 elements

跟着卷卷龙一起学Camera--MIPI 03

卷卷龙

ISP camera 11月月更

React组件之间的通信方式总结(下)

beifeng1996

React

一大波vue面试题及答案精心整理

bb_xiaxia1998

Vue

React源码解读之React Fiber

flyzz177

React

跟着卷卷龙一起学Camera--信号采样02

卷卷龙

ISP camera 11月月更

前端必会面试题总结

loveX001

JavaScript

React组件之间的通信方式总结(上)

beifeng1996

React

React源码分析4-深度理解diff算法

goClient1992

React

决策树-泰坦尼克号幸存者预测

烧灯续昼2002

Python 机器学习 算法 sklearn 11月月更

React源码分析2-深入理解fiber

goClient1992

React

React Context源码是怎么实现的呢

flyzz177

React

Ansible 部署的时候提示错误 SSH password instead

HoneyMoose

Agora 教程:构建你的第一个 Flutter 视频通话应用_语言 & 开发_张乾泽_InfoQ精选文章