基于 Agora Flutter SDK,构建你的第一个 Flutter 视频通话应用(2022)

基于 Agora Flutter SDK,构建你的第一个 Flutter 视频通话应用

实时视频通话能够拉近人与人之间的距离,为用户提供沉浸式的交流体验,帮助你的 app 提高用户黏性。

今天我们就来看一下如何使用 Agora Flutter SDK 快速构建一个简单的移动跨平台视频通话应用。

目标

我们希望可以使用Flutter+Agora Flutter SDK实现一个简单的视频通话应用,这个视频通话应用需要包含以下功能,

  • 加入通话房间
  • 视频通话
  • 前后摄像头切换
  • 本地静音/取消静音

声网的视频通话是按通话房间区分的,同一个通话房间内的用户都可以互通。为了方便区分,这个演示会需要一个简单的表单页面让用户提交选择加入哪一个房间。同时一个房间内可以容纳最多4个用户,当用户数不同时我们需要展示不同的布局。

效果如下:
screenshot-1

screenshot-2

环境准备

本文需要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 sdk
  agora_rtc_engine: ^5.3.0

dev_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后,我们可以将我们的首页代码修改如下,

@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 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方法在这个组件的生命周期结束时释放_channelController

  @override
  void dispose() {
    // dispose input controller
    _channelController.dispose();
    super.dispose();
  }

前往通话页面

到这里我们的首页基本就算完成了,最后我们在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方法,在这个方法里做好初始化。

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 page
  final String? channelName;

  /// non-modifiable client role of the page
  final ClientRole? role;

  /// Creates a call page with given channel name.
  const CallPage({Key? key, this.channelName, this.role}) : super(key: key);

  @override
  _CallPageState createState() => _CallPageState();
}

class _CallPageState extends State<CallPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  bool muted = false;
  late RtcEngine _engine;

  @override
  void initState() {
    super.initState();
    // initialize agora sdk
    initialize();
  }

  Future<void> initialize() async {
    await _initAgoraRtcEngine();
    _addAgoraEventHandlers();
  }

  /// Create agora sdk instance and initialize
  Future<void> _initAgoraRtcEngine() async {
    _engine = await RtcEngine.create(appId);
    await _engine.enableVideo();
  }

  /// Add agora event handlers
  void _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 initialize
  Future<void> _initAgoraRtcEngine() async {
    _engine = await RtcEngine.create(appId);
    await _engine.enableVideo();
    await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
    await _engine.setClientRole(widget.role!);
  }

  /// Add agora event handlers
  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, 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 views
  List<Widget> _getRenderViews() {
    final List<StatefulWidget> list = [];
    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提供的userJoineduserOffline 回调来判断是否有其他用户进出当前频道,若有新用户加入频道,就加入到_users数组;若有用户离开频道,则从_users数组移除。

视频流布局

在有了_users数组,且每一个本地/远端流都有了一个对应的SurfaceView后,我们就可以开始对视频流进行布局了。

...

class _CallPageState extends State<CallPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  bool muted = false;
  late RtcEngine _engine;

  ...

  /// Helper function to get list of native views
  List<Widget> _getRenderViews() {
    final List<StatefulWidget> list = [];
    if (widget.role == ClientRole.Broadcaster) {
      list.add(RtcLocalView.SurfaceView());
    }
    _users.forEach((int uid) => list.add(
        RtcRemoteView.SurfaceView(channelId: widget.channelName!, uid: uid)));
    return list;
  }

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

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

  /// Video layout wrapper
  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();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Agora Flutter QuickStart'),
      ),
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: <Widget>[
            _viewRows(),
          ],
        ),
      ),
    );
  }
}

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

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

class _CallPageState extends State<CallPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  bool muted = false;
  late RtcEngine _engine;

  ...

  /// Toolbar layout
  Widget _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: <Widget>[
          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 logs
  Widget _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(
                    "null"); // 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() {
    setState(() {
      muted = !muted;
    });
    _engine.muteLocalAudioStream(muted);
  }

  void _onSwitchCamera() {
    _engine.switchCamera();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Agora Flutter QuickStart'),
      ),
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: <Widget>[
            _viewRows(),
            _panel(),
            _toolbar(),
          ],
        ),
      ),
    );
  }
}

清理

若只在当前页面使用声网SDK,则需要在离开前调用destroy接口将SDK实例销毁。若需要跨页面使用,则推荐将SDK实例做成单例以供不同页面访问。

class _CallPageState extends State<CallPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  bool muted = false;
  late RtcEngine _engine;

  @override
  void dispose() {
    // clear users
    _users.clear();
    _dispose();
    super.dispose();
  }

  Future<void> _dispose() async {
    // destroy sdk
    await _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

推荐阅读
相关专栏
SDK 教程
127 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。