4

如何基于 Flutter 快速实现一个视频通话应用

 2 years ago
source link: https://segmentfault.com/a/1190000040946748
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

今天,我们将会一起开发一个包含 RTE (实时互动)场景的 Flutter 应用。

靠自研开发包含实时互动功能的应用非常繁琐,你要解决维护服务器、负载均衡等难题,同时还要保证稳定的低延迟。

那么,如何才能在较短的时间内,将实时互动功能添加到 Flutter 应用中?你可以通过声网Agora SDK 来进行开发。在本教程中,我将带大家了解如何使用 Agora Flutter SDK 订阅多个频道的过程。(多频道是什么样场景呢?我们稍后举些例子。)

为什么要加入多个频道?

在进入正式开发之前,我们先看看为什么有人或者说实时互动场景需要订阅多个频道。

加入多个频道的主要原因是可以同时跟踪多个群组的实时互动活动,或者同时与各个群组互动。各种使用场景包括线上的分组讨论室、多会议场景、等待室、活动会议等。

我们先创建一个 Flutter 项目。打开你的终端,找到你的开发文件夹,然后输入以下内容。

flutter create agora_multi_channel_demo

找到 pubspec.yaml,并在该文件中添加以下依赖项。

dependencies:
  flutter:
    sdk: flutter


  cupertino_icons: ^1.0.0
  agora_rtc_engine: ^3.2.1
  permission_handler: ^5.1.0+2

在添加包的时候要注意这边的缩进,否则可能会出现错误。

在你的项目文件夹中,运行以下命令来安装所有的依赖项:

flutter pub get

一旦我们有了所有的依赖项,就可以创建文件结构了。找到 lib 文件夹,创建一个像这样的文件目录结构:

图片

创建登录页面

登录页面只需读取用户想要加入的两个频道即可。在本教程中,我们只保留两个频道,但如果你想的话也可以加入更多的频道:

import 'package:agora_multichannel_video/pages/lobby_page.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

class LoginPage extends StatefulWidget {
  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final rteChannelNameController = TextEditingController();
  final rtcChannelNameController = TextEditingController();
  bool _validateError = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        title: Text('Agora Multi-Channel Demo'),
        elevation: 0,
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          clipBehavior: Clip.antiAliasWithSaveLayer,
          physics: BouncingScrollPhysics(),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.12,
              ),
              Center(
                child: Image(
                  image: NetworkImage(
                      'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png'),
                  height: MediaQuery.of(context).size.height * 0.17,
                ),
              ),
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.1,
              ),
              Container(
                width: MediaQuery.of(context).size.width * 0.8,
                child: TextFormField(
                  controller: rteChannelNameController,
                  decoration: InputDecoration(
                    labelText: 'Broadcast channel Name',
                    labelStyle: TextStyle(color: Colors.black54),
                    errorText:
                        _validateError ? 'Channel name is mandatory' : null,
                    border: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    enabledBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.black, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                  ),
                ),
              ),
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.03,
              ),
              Container(
                width: MediaQuery.of(context).size.width * 0.8,
                child: TextFormField(
                  controller: rtcChannelNameController,
                  decoration: InputDecoration(
                    labelText: 'RTC channel Name',
                    labelStyle: TextStyle(color: Colors.black54),
                    errorText:
                        _validateError ? 'RTC Channel name is mandatory' : null,
                    border: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    enabledBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.black, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.blue, width: 2),
                      borderRadius: BorderRadius.circular(20),
                    ),
                  ),
                ),
              ),
              SizedBox(height: MediaQuery.of(context).size.height * 0.05),
              Container(
                width: MediaQuery.of(context).size.width * 0.35,
                child: MaterialButton(
                  onPressed: onJoin,
                  color: Colors.blueAccent,
                  child: Padding(
                    padding: EdgeInsets.symmetric(
                        horizontal: MediaQuery.of(context).size.width * 0.01,
                        vertical: MediaQuery.of(context).size.height * 0.02),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: <Widget>[
                        Text(
                          'Join',
                          style: TextStyle(
                              color: Colors.white, fontWeight: FontWeight.bold),
                        ),
                        Icon(
                          Icons.arrow_forward,
                          color: Colors.white,
                        ),
                      ],
                    ),
                  ),
                ),
              )
            ],
          ),
        ),
      ),
    );
  }

  Future<void> onJoin() async {
    setState(() {
      rteChannelNameController.text.isEmpty &&
              rtcChannelNameController.text.isEmpty
          ? _validateError = true
          : _validateError = false;
    });

    await _handleCameraAndMic(Permission.camera);
    await _handleCameraAndMic(Permission.microphone);

    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => LobbyPage(
          rtcChannelName: rtcChannelNameController.text,
          rteChannelName: rteChannelNameController.text,
        ),
      ),
    );
  }

  Future<void> _handleCameraAndMic(Permission permission) async {
    final status = await permission.request();
    print(status);
  }
}

在成功提交频道名称时,会触发 PermissionHandler(),这是一个来自外部包(permission_handler)的类,我们将使用这个类来获取用户在调用过程中的摄像头和麦克风的权限。

现在,在我们开始开发我们的可以连接多个频道的大厅之前,在 utils.dart 文件夹下的 utils.dart 中单独保留 App ID。

const appID = '<---Enter your App ID here--->';

如果你了解过多人通话或互动直播,你会发现,我们在这里要写的大部分代码是相似的。这两种情况下的主要区别是,之前我们是依靠一个频道来连接一个群组。但是现在一个人可以同时加入多个频道。

在一个单频道视频通话中,我们看到了如何创建一个 RtcEngine 类的实例并加入一个频道。在这里我们也是以同样的过程开始的,如下:

_engine = await RtcEngine.create(appID);
await _engine.enableVideo();
await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.rteChannelName, null, 0);

注意:该项目是作为开发环境下的参考,不推荐用于生产环境。建议在生产环境中运行的所有 RTE App 都使用Token鉴权。关于 Agora 平台中基于 Token 的身份验证的更多信息,请参考声网官方文档:https://docs.agora.io/cn/

我们看到,在创建一个RtcEngine实例后,需要将Channel Profile设置为Live Streaming,并根据用户输入加入所需的频道。

_addAgoraEventHandlers() 函数处理了我们在这个项目中需要的所有主要回调。在示例中,我只是想在有他们的 uid 的 RTE 频道中创建一个用户列表。

void _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, reason) {
        setState(() {
          final info = 'userOffline: $uid , reason: $reason';
          _infoStrings.add(info);
          _users.remove(uid);
        });
      },
    ));
  }

uid 的列表是动态维护的,因为每次用户加入或离开频道时它都会更新。

这就设置了我们的主频道或大厅,在这里可以显示主播直播,现在订阅其他频道需要一个 RtcChannel 的实例,只有这样你才能加入第二个频道。

_channel = await RtcChannel.create(widget.rtcChannelName);
_addRtcChannelEventHandlers();
await _engine.setClientRole(ClientRole.Broadcaster);
await _channel.joinChannel(null, null, 0, ChannelMediaOptions(true, true));
await _channel.publish();

RtcChannel 是用频道名来初始化的,所以我们用用户给的其他输入来处理这个问题。一旦它被初始化,我们调用 ChannelMediaOptions() 类的加入频道函数,这个类寻找两个参数:autoSubscribeAudio 和autoSubscribeVideo。由于它期望的是一个布尔值,你可以根据你的要求传递 ture 或 false。

对于 RtcChannel,我们看到了类似的事件处理程序,不过我们将为该特定频道中的用户创建另一个用户列表。

void _addRtcChannelEventHandlers() {
    _channel.setEventHandler(RtcChannelEventHandler(
      error: (code) {
        setState(() {
          _infoStrings.add('Rtc Channel onError: $code');
        });
      },
      joinChannelSuccess: (channel, uid, elapsed) {
        setState(() {
          final info = 'Rtc Channel onJoinChannel: $channel, uid: $uid';
          _infoStrings.add(info);
        });
      },
      leaveChannel: (stats) {
        setState(() {
          _infoStrings.add('Rtc Channel onLeaveChannel');
          _users2.clear();
        });
      },
      userJoined: (uid, elapsed) {
        setState(() {
          final info = 'Rtc Channel userJoined: $uid';
          _infoStrings.add(info);
          _users2.add(uid);
        });
      },
      userOffline: (uid, reason) {
        setState(() {
          final info = 'Rtc Channel userOffline: $uid , reason: $reason';
          _infoStrings.add(info);
          _users2.remove(uid);
        });
      },
    ));
  }

_users2 列表中包含了使用 RtcChannel 类创建的频道中所有人的 ID。

有了这个,你就可以在你的应用程序中添加多个频道。接下来,让我们看看我们如何创建 Widget,以便这些视频可以显示在我们的屏幕上。

我们首先添加 RtcEngine 的视图。在这个例子中,我将使用一个占据屏幕最大空间的网格视图。

List<Widget> _getRenderViews() {
    final List<StatefulWidget> list = [];
    list.add(RtcLocalView.SurfaceView());
    return list;
  }

  Widget _videoView(view) {
    return Expanded(child: Container(child: view));
  }

  Widget _expandedVideoRow(List<Widget> views) {
    final wrappedViews = views.map<Widget>(_videoView).toList();
    return Expanded(
      child: Row(
        children: wrappedViews,
      ),
    );
  }

  Widget _viewRows() {
    final 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();
  }

对于 RtcChannel,我将使用一个位于屏幕底部的可滚动的 ListView。这样一来,用户可以通过滚动列表来查看所有出现在频道中的用户。

List<Widget> _getRenderRtcChannelViews() {
    final List<StatefulWidget> list = [];
    _users2.forEach(
      (int uid) => list.add(
        RtcRemoteView.SurfaceView(
          uid: uid,
          channelId: widget.rtcChannelName,
          renderMode: VideoRenderMode.FILL,
        ),
      ),
    );
    return list;
  }

  Widget _viewRtcRows() {
    final views = _getRenderRtcChannelViews();
    if (views.length > 0) {
      print("NUMBER OF VIEWS : ${views.length}");
      return ListView.builder(
        scrollDirection: Axis.horizontal,
        itemCount: views.length,
        itemBuilder: (BuildContext context, int index) {
          return Align(
            alignment: Alignment.bottomCenter,
            child: Container(
              height: 200,
              width: MediaQuery.of(context).size.width * 0.25,
              child: _videoView(views[index])),
          );
        },
      );
    } else {
      return Align(
        alignment: Alignment.bottomCenter,
        child: Container(),
      );
    }
  }

在调用中,你的应用程序的风格或对齐用户视频的方式完全由你决定。需要寻找的关键元素或小组件是 _getRenderViews() 和 _getRenderRtcChannelViews(),它们返回一个用户视频列表。使用这个列表,你可以按照你的选择来定位你的用户和他们的视频,类似于 _viewRows() 和 _viewRtcRows() 小组件。

使用这些小组件,我们可以将它们添加到我们的支架上。在这里,我将使用一个堆栈将_viewRows() 放在 _viewRtcRows 之 上。

Widg et build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Lobby'),
      ),
      body: Stack(
        children: <Widget>[
          _viewRows(),
          _viewRtcRows(),
          _panel()
        ],
      ),
    );
  }

我已经在我们的堆栈中添加了另一个名为 _panel 的小组件,我们使用这个小组件来显示我们频道上发生的所有事件。

Widget _panel() {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 48),
      alignment: Alignment.topLeft,
      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 null;
              }
              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),
                        ),
                      ),
                    )
                  ],
                ),
              );
            },
          ),
        ),
      ),
    );
  }

这样一来,用户就可以添加两个频道并且同时查看。但是让我们思考一个例子,在这个例子中,你需要加入两个以上的频道实时互动。在这种情况下,你可以用一个独特的频道名称简单地创建更多的 RtcChannel 类的实例。使用同一个实例,你就可以加入多个频道。

最后,你需要创建一个 dispose() 方法,来清除两个频道的用户列表,并为我们订阅的所有频道调用 leaveChannel() 方法。

@override
   void dispose() {
    // clear users
    _users.clear();
    _users2.clear();
    // leave channel 
    _engine.leaveChannel();
    _engine.destroy();
    _channel.unpublish();
    _channel .leaveChannel();
    _channel.destroy();
    super.dispose();
  }

当应用完成开发后,通过它你可以使用声网Agora SDK 加入多个频道,你可以运行应用并在设备上测试。在你的终端中导航到项目目录,并运行这个命令。

flutter run

通过能够同时加入多个频道的声网Agora Flutter SDK,你已经实现了你自己的直播 App。

获取本文 Demo:https://github.com/Meherdeep/agora-flutter-multi-channel

获取更多教程、Demo、技术帮助,请点击「阅读原文」访问声网开发者社区。

图片


Recommend

  • 45

    随着web技术的发展,使用场景和需求也越来越复杂,客户端不再满足于简单的请求得到状态的需求。实时通讯越来越多应用于各个领域。 HTTP是最常用的客户端与服务端的通信技术,但是HTTP通信只能由客户端发起,无法及时获取服务端的...

  • 56

    随着web技术的发展,使用场景和需求也越来越复杂,客户端不再满足于简单的请求得到状态的需求。实时通讯越来越多应用于各个领域。 HTTP是最常用的客户端与服务端的通信技术,但是HTTP通信只能由客户端发起,无法及时获取服务端的数据改变。只能依靠定期轮询来获取...

  • 34

    作者:Songlcy 原文:https://blog.csdn.net/u013718120/article/details/86621278 版权声明:本文为  Songlcy  原创文章,未经博主同意请勿转载! 项目已开源到 Github:Vistor,欢迎大家 for...

  • 14

    一、前言 相信做过移动端视频开发的同学应该了解,想要实现视频从普通播放到全屏播放的逻辑并不是很简单,比如在 GSYVideoPlayer 中的动态全屏切换效果,就使用了创建全新的 Surface 来替换实现: 创建全新的 Surface ,并将对于的 Vie

  • 27

    朋友的单位有一个小型的图书室,图书室中摆放了很多的书,每本书都被编号放在对应的区域,为了让大家更快、更容易找到这些书,他联系我,让我帮他弄一个图书查询系统,通过用户输入能模糊匹配到对应的结果,并且提供书籍对应的地点。...

  • 4

    虚拟 DOM 是目前主流前端框架的技术核心之一,本文阐述如何实现一个简单的虚拟 DOM 系统。为什么需要虚拟 DOM?...

  • 8

    今天,我们将会一起开发一个包含 RTE (实时互动)场景的 Flutter 应用。 靠...

  • 5

    如何快速定位 Flutter APP 内存泄漏2021-12-30 Flutter当一个 APP 随着业务发展,无用功能越堆越多,参与开发的人员队伍越发壮大,代码大爆炸式膨胀,尽管有一系列的例如人工review...

  • 4

    theme: smartblue 原文链接: https://medium.com/flutter/i-o-pinball-powered-by-flutter-and-firebase-d22423f3f5d 本次 Google I/O 与 Flutter 团队合作,重新构想了一款使用 Flutter 和 Firebase 构建...

  • 4

    我刚入职的时候,公司使用 RSA 公司的 token,所谓的 token 就是一个像优盘一样的硬件,每隔 30 秒会产生一个 6 位数字,这个数字作为一次性密码,也即标题里提到的 OTP,one-time-password 的缩写。 后来手机普及后,出现了很多软件实现的 OTP,比如 QQ...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK