構建你的第一個Flutter視頻通話應用

做者:張乾澤,聲網Agora 工程師android

如對咱們的Flutter插件開發過程感興趣,或遇到實時音視頻相關開發問題,歡迎訪問聲網 Agora問答版塊,發帖與咱們的工程師交流。git

Flutter 1.0 發佈也已經有一段時間了,春節後聲網 Agora 以 Flutter 插件的形式推出了 Agora Flutter SDK,能夠幫助 Flutter 開發者快速實現 Flutter 視頻通話應用。github

如今咱們就來看一下如何使用Agora Flutter SDK快速構建一個簡單的移動跨平臺視頻通話應用。數組

環境準備

Flutter中文網上,關於搭建開放環境的教程已經相對比較完善了,有關IDE與環境配置的過程本文再也不贅述,若Flutter安裝有問題,能夠執行flutter doctor作配置檢查。markdown

本文使用MacOS下的VS Code做爲主開發環境。session

目標

咱們但願可使用Flutter+Agora Flutter SDK實現一個簡單的視頻通話應用,這個視頻通話應用須要包含如下功能,併發

  • 加入通話房間
  • 視頻通話
  • 先後攝像頭切換
  • 本地靜音/取消靜音

聲網的視頻通話是按通話房間區分的,同一個通話房間內的用戶均可以互通。爲了方便區分,這個演示會須要一個簡單的表單頁面讓用戶提交選擇加入哪個房間。同時一個房間內能夠容納最多4個用戶,當用戶數不一樣時咱們須要展現不一樣的佈局。app

想清楚了?動手擼代碼了。ide

項目建立

首先在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.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的狀態以作組件重繪。

注意: 不要忘了overridedispose方法在這個組件的生命週期結束時釋放_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進行視頻通話以前,咱們須要進行如下初始化工做。初始化工做應該在整個頁面生命週期中只作一次,所以這裏咱們須要overrideinitState方法,在這個方法裏作好初始化。

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,請參閱聲網官方文檔

在以上的代碼中咱們主要建立了聲網的媒體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 & 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(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 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();
	}
複製代碼

工具欄(掛斷、靜音、切換攝像頭)

在實現完視頻流佈局後,咱們接下來實現視頻通話的操做工具欄。工具欄裏有三個按鈕,分別對應靜音、掛斷、切換攝像頭的順序。用簡單的flex Row佈局便可。

/// 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應用的同窗有所幫助。

文章中講解的完整代碼均可以在 Github 找到

如在開發過程當中遇到問題,請移步 RTC 開發者社區,本文做者會及時回覆。

相關文章
相關標籤/搜索