Flutter+WebRTC开发点对点加密即时通讯APP--WebRTC视频通话真机实战
Flutter+WebRTC开发点对点加密即时通讯APP--WebRTC视频通话真机实战
开篇
基于Flutter+WebRTC,开发一款点对点加密、跨端、即时通讯APP,实现文字、音视频通话聊天,同时支持图片、短视频等文件传输功能,计划支持Windows、Android平台。我准备将自己的学习和实践过程记录下来,同时分享给大家,欢迎大家一起研讨交流。这个工程是利用自己的业余时间来实现的,不定时更新。本篇文章是基于WebRTC协议的视频通话APP Android真机开发实战,我们在两个Androi真机上实现WebRTC端到端互联,进行视频通话。
APP总体设计
本次我们基于WebRTC协议实现在Android真机上的视频通话APP开发。目前我们手上有一台一加8T手机和一台红米4X手机,使用这两个手机连接Vscode进行真机调试,Vscode连接真机的方式,我们在前边的文章已经记录过,这里不再详细说明。这次APP的开发总体思路是,在一个手机上构建服务端,在另一个手机上构建客户端,客户端生成WebRTC的offer,然后手动送给服务端,然后再将服务端产生的answer手动送给送给客户端,然后再手动将服务端的candidate发送给客户端,实现点对点WebRTC连接建立。有关于offer、answer、candidate等WebRTC的基础知识,请翻看我之前的文章。
Android权限申请与前置设置
我们新建两个Flutter项目,一个作为服务端,一个作为客户端。项目创建完成后为我们的APP进行Android设备摄像头、麦克风等权限申请,修改android\app\src\main\AndroidManifest.xml
文件,添加下边的代码。
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
接着将android\app\build.gradle
文件中的flutter.minSdkVersion替换成23,这样APP就才可以正常工作哦,详细细节请翻阅我之前的文章。
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.example.WebRTCpracticesingle"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
主页UI实现
我们首先实现一个初始化界面,在这个界面放置一个按钮,点击按钮之后进行媒体获取并且跳转到视频渲染界面,将视频画面展示出来。我们创建名为MyHomePage的无状态组件,外层使用脚手架组件Scaffold,在Appbar中设置显示client或者server来进行服务端和客户端APP的区分。最后实现一个TextButton文字按钮,点击按钮进行路由跳转,跳转到视频渲染界面。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("client webrtc"),
),
body: TextButton(
onPressed: () {
Navigator.push(context,
MaterialPageRoute(builder: (BuildContext context) => GetMedia()));
},
child: const Text("client webrtc"),
),
);
}
}
GetMedia视频渲染展示界面实现
接下来实现视频渲染界面,在这个界面需要实现的功能包括本地和远端视频展示,本地媒体获取,与对端连接建立等功能。因为我们是手动交换offer、answer、candidate,所以我们在这个界面同时放置offer、answer、candidate的展示和设置按钮。
首先先定义本地和远端RTCVideoRenderer,接着再定义一个RTCPeerConnection,和一个MediaStream。
final _localVideoRenderer = RTCVideoRenderer();
late RTCPeerConnection localConnection;
late MediaStream localstream;
final _remoteVideoRenderer = RTCVideoRenderer();
定义初始化函数,在StatefulWidget有状态组件的initState函数中调用此函数,将本地和远端RTCVideoRenderer在组件初始化时进行初始化。
void initRenderers() async {
await _localVideoRenderer.initialize();
await _remoteVideoRenderer.initialize();
}
接着定义三个Text输入框控件,用来手动进行offer、answer、candidate设置和读取。
final ansController = TextEditingController();
final candiController = TextEditingController();
final setRemoteDescriptionController = TextEditingController();
我们使用StatefulWidget有状态组件来开发GetMedia界面,在组件的initState()函数中调用 initRenderers()进行RTC初始化。
class GetMedia extends StatefulWidget {
const GetMedia({super.key});
@override
State<GetMedia> createState() => _GetMediaState();
}
class _GetMediaState extends State<GetMedia> {
@override
void initState() {
super.initState();
initRenderers();
}
@override
Widget build(BuildContext context) {
return Scaffold()
}
}
client端从上之下依次布局本地和远端视频展示界面,offer文本框,set remote description文本框,set candidate文本框,server端从上之下依次布局本地和远端视频展示界面,set remote description文本框,anwwser文本框,set candidate文本框。之所以这么设置,是因为信息交换流程就是这样的,首先client产生offer,client将offer交给server用来set remote description,然后server产生answer,server将answer交给client用来set remote description,最后set candidate就可以建立连接了。最后在放置两个按钮用来开启和关闭视频渲染。
我们最外层使用脚手架组件Scaffold,顶部菜单栏展示客户端或者服务器提示信息,接着使用竖向布局Column,在Column中使用两个Expanded,第一个来布局视频展示画面,第二个布局文本框。
Scaffold(
appBar: AppBar(
title: const Text("server"),
),
body: Column(
children: [
Expanded(
),
Expanded(
),
],
),
);
视频渲染界面实现
在第一个Expanded中,我们布局视频渲染界面。使用Row横向布局组件放置两个视频展示界面,每个视频展示界面使用Stack堆叠组件,底层放置视频展示框,顶层放置文字,用来指示是本地还是远端视频。视频展示我们使用容器Container来包裹RTCVideoView组件,RTCVideoView组件是插件提供的组件,将我们前边初始化的_localVideoRenderer传入就可以渲染视频了。
Expanded(
flex: 1,
child: Row(children: [
Flexible(
child: Stack(
alignment: Alignment.topCenter,
children: [
Container(
margin: const EdgeInsets.fromLTRB(5.0, 5.0, 5.0, 5.0),
decoration: const BoxDecoration(color: Colors.black),
child: RTCVideoView(_localVideoRenderer),
),
const Text(
"local",
style: TextStyle(
fontSize: 18,
color: Colors.amber,
),
)
],
),
),
Flexible(
child: Stack(
alignment: Alignment.topCenter,
children: [
Container(
margin: const EdgeInsets.fromLTRB(5.0, 5.0, 5.0, 5.0),
decoration: const BoxDecoration(color: Colors.black),
child: RTCVideoView(_remoteVideoRenderer),
),
const Text(
"remote",
style: TextStyle(
fontSize: 18,
color: Colors.amber,
),
)
],
),
),
]),
),
参数设置文本框界面实现
在第二个Expanded中放置文本框,用来进行offer、answer、candidate交换和设置。接下来,每个文本框和按钮配对放置到一个竖向布局Column中,结合使用Expanded组件实现布局自适应。TextField中分别指定我们前边定义好的控制器,将最大长度maxLength限制取消,同时支持多行输入。最后在设置两个按钮用来开启和关闭视频渲染。
Expanded(
flex: 2,
child: Column(
children: [
Expanded(
child: Column(
children: [
Expanded(
flex: 3,
child: TextField(
controller: setRemoteDescriptionController,
keyboardType: TextInputType.multiline,
maxLength: TextField.noMaxLength,
),
),
Expanded(
flex: 1,
child: ElevatedButton(
onPressed: () {
setState(() {});
},
child: const Text("Set Remote Description"),
),
),
],
),
),
Expanded(
child: Column(
children: [
Expanded(
flex: 3,
child: TextField(
controller: ansController,
keyboardType: TextInputType.multiline,
maxLength: TextField.noMaxLength,
),
),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() {});
},
child: const Text("anwser"),
),
),
],
),
),
Expanded(
child: Column(
children: [
Expanded(
flex: 3,
child: TextField(
controller: candiController,
keyboardType: TextInputType.multiline,
maxLength: TextField.noMaxLength,
),
),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() {});
},
child: const Text("Set Candidate"),
),
),
],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
setState(() {});
}),
const SizedBox(
width: 10,
),
FloatingActionButton(
child: const Icon(Icons.close),
onPressed: () {
setState(() {});
},
),
],
),
),
],
),
),
效果图:
WebRT参数配置
我们通过mediaConstraints来配置视频和音频参数, 设置audio为true来开启音频,video的设置就比较多啦啦,可以通过width和heigth设置视频分辨率,通过facingMode设置前置或者后置摄像头,frameRate可以设置视频帧率,ideal表示设置参数为理想值。设置好参数,在getUserMedia()函数中调用。
mediaConstraints 参数是一个包含了video 和 audio两个成员的MediaStreamConstraints 对象,用于说明请求的媒体类型。必须至少一个类型或者两个同时可以被指定。例如,使用 1280x720 的摄像头分辨率:
{
audio: true,
video: { width: 1280, height: 720 }
}
强制要求获取特定的尺寸时,可以使用关键字min、max 或者 exact(就是 min == max)。以下参数表示要求获取最低为 1280x720 的分辨率。
{
audio: true,
video: {
width: { min: 1280 },
height: { min: 720 }
}
}
当请求包含一个 ideal(应用最理想的)值时,这个值有着更高的权重,意味着浏览器会先尝试找到最接近指定的理想值的设定或者摄像头(如果设备拥有不止一个摄像头)。
{
audio: true,
video: {
width: { min: 1024, ideal: 1280, max: 1920 },
height: { min: 776, ideal: 720, max: 1080 }
}
}
优先使用前置摄像头(如果有的话):
{ audio: true, video: { facingMode: "user" } }
在我们的代码实现中,我们用下边的配置,分辨率为8000*6000,使用前置摄像头,帧率60帧:
final Map<String, dynamic> mediaConstraints = {
'audio': true,
'video': {
"width": {"ideal": 8000},
"heigth": {"ideal": 6000},
'facingMode': 'user', //'facingMode': 'environment',
"frameRate": {
"ideal": 60,
},
}
};
调用函数为:
localstream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
sdpConstraints为sdp约束,是一个可选对象参数:
/*
final Map<String, dynamic> sdpConstraints = {//可选参数
"mandatory": {
"OfferToReceiveAudio": true,
"OfferToReceiveVideo": true,
},
"optional": [],
};*/
调用函数为:
localConnection.createAnswer(sdpConstraints);
pcConstraints为可选参数,configuration必须传递,在configuration中可以指定iceServers服务器,我们不需要iceServers服务器,所以这里设置为空。
/*
final Map<String, dynamic> pcConstraints = {//可选参数
"mandatory": {},
"optional": [],
};*/
Map<String, dynamic> configuration = {
"iceServers": [
// {"url": "stun:stun.l.google.com:19302"},
]
};
调用函数为:
createPeerConnection(configuration, pcConstraints);
媒体获取函数
接下来实现媒体获取函数,首先调用getUserMedia函数获得localstream,接着将localstream赋值给_localVideoRenderer.srcObject,然后调用createPeerConnection创建本地的localConnection。使用localConnection.onIceCandidate 监听candidate事件,当产生candidate时将candidate打印出来,方便我们手动进行candidate的交换。记得调用 localConnection.addTrack将媒体轨道赋值给本地localConnection,使用localConnection.onAddTrack监听添加轨道事件,当检测到对方添加了轨道,将其赋值给远端对象_remoteVideoRenderer。
_getlocalUserMedia() async {
localstream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
_localVideoRenderer.srcObject = localstream;
localConnection = await createPeerConnection(configuration, pcConstraints);
localConnection.onIceCandidate = (candidate) {
print("""candidate********************""");
print(json.encode({
'candidate': candidate.candidate.toString(),
'sdpMid': candidate.sdpMid.toString(),
'sdpMlineIndex': candidate.sdpMLineIndex,
}));
candiController.text = "";
candiController.text = json.encode({
'candidate': candidate.candidate.toString(),
'sdpMid': candidate.sdpMid.toString(),
'sdpMlineIndex': candidate.sdpMLineIndex,
});
print("""candidate********************ok""");
};
localConnection.onConnectionState = (state) {
print(state);
};
for (MediaStreamTrack track in localstream.getTracks()) {
localConnection.addTrack(track, localstream);
}
localstream.getAudioTracks()[0].setTorch(true);
localConnection.onAddTrack = (stream, track) {
_remoteVideoRenderer.srcObject = stream;
};
}
媒体关闭函数
我们再实现一个关闭函数,可以关闭我们的视频。
close() {
localstream.dispose();
_localVideoRenderer.srcObject = null;
localConnection.close();
_remoteVideoRenderer.srcObject = null;
}
offer配置函数
client为发起方,所以要产生offer,我们构建如下的offer产生函数,将产生的offer内容变换为json格式,同时在offer输入框显示。
void makeoffer() async {
RTCSessionDescription offer =
await localConnection.createOffer(sdpConstraints);
localConnection.setLocalDescription(offer);
print(offer.sdp);
var session = parse(offer.sdp.toString());
offController.text = "";
offController.text = json.encode(session);
print(json.encode(session));
}
anwser配置函数
server为接受方,所以要根据offer产生anwser,我们构建如下的anwser产生函数,将产生的anwser内容变换为json格式,同时在anwser输入框显示。
makeanwser() async {
RTCSessionDescription answer =
await localConnection.createAnswer(sdpConstraints);
localConnection.setLocalDescription(answer);
print(answer.sdp);
var session = parse(answer.sdp.toString());
ansController.text = "";
ansController.text = json.encode(session);
}
setRemoteDescription配置远程描述函数
无论offer或者anwser,都是一个远程描述sdp,我们手动将offer发送给server,server需要将offer设置为远端描述,同理,server将anwser发送给client,client需要将收到的anwser设置为远端描述。经过上述交换,server和client就有了对方的若干信息,后边就可以协商建立连接了。
void makesetRemoteDescription() async {
String jsonString = setRemoteDescriptionController.text;
dynamic session = await jsonDecode(jsonString);
String sdp = write(session, null);
RTCSessionDescription description = RTCSessionDescription(sdp, 'answer');
print(description.toMap());
await localConnection.setRemoteDescription(description);
}
添加Candidate函数
最后一步就是交换Candidate信息了,之前交换了offer和answer,但是这并不包含IP信息,所以还需要交换IP信息,Candidate的作用就是交换IP信息。
WebRTC可以使用IP直接连接,若IP直接连接失败则寻找中介服务器进行IP信息交换,再进行IP直连,如果这样也不行的话,最后还有一招,就是使用中间服务器进行数据中转。对应着的,收集candidates的方式包括:获取本机host address,从STUN服务器获取srvflx address,从TURN服务器获取relay address三种。
void makeaddCandidate() async {
String jsonString = candiController.text;
dynamic session = await jsonDecode(jsonString);
print(session['candidate']);
dynamic candidate = RTCIceCandidate(
session['candidate'], session['sdpMid'], session['sdpMlineIndex']);
await localConnection.addCandidate(candidate);
}
Candidate格式如下:
{"candidate":"candidate:2149273515 1 udp 2122260223 192.168.31.105 44327 typ host generation 0 ufrag /GmP network-id 3 network-cost 10","sdpMid":"0","sdpMlineIndex":0}
2149273515:foundation是用于标志和区分来自同一个stun的不同的候选者,ID标识。 1:表明ICE的组ID udp:协议类型 2122260223:priority表示优先级 192.168.31.105 44327:IP地址和端口
WebRT视频连接建立
最后,按照连接建立流程,手动交换offer、anwser、Candidate,成功建立视频通话连接:
转载自:https://juejin.cn/post/7330054489078120489