Flutter+WebRTC+gRPC开发点对点加密即时通讯APP--WebRTC视频通话真机实战

Flutter+WebRTC+gRPC开发点对点加密即时通讯APP--WebRTC视频通话真机实战

开篇

基于Flutter+WebRTC+gRPC,开发一款点对点加密、跨端、即时通讯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文件,添加下边的代码。

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就才可以正常工作哦,详细细节请翻阅我之前的文章。

xml 复制代码
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文字按钮,点击按钮进行路由跳转,跳转到视频渲染界面。

dart 复制代码
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。

dart 复制代码
final _localVideoRenderer = RTCVideoRenderer();
late RTCPeerConnection localConnection;
late MediaStream localstream;
final _remoteVideoRenderer = RTCVideoRenderer();

定义初始化函数,在StatefulWidget有状态组件的initState函数中调用此函数,将本地和远端RTCVideoRenderer在组件初始化时进行初始化。

dart 复制代码
void initRenderers() async {
  await _localVideoRenderer.initialize();
  await _remoteVideoRenderer.initialize();
}

接着定义三个Text输入框控件,用来手动进行offer、answer、candidate设置和读取。

dart 复制代码
final ansController = TextEditingController();
final candiController = TextEditingController();
final setRemoteDescriptionController = TextEditingController();

我们使用StatefulWidget有状态组件来开发GetMedia界面,在组件的initState()函数中调用 initRenderers()进行RTC初始化。

dart 复制代码
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,第一个来布局视频展示画面,第二个布局文本框。

dart 复制代码
Scaffold(
      appBar: AppBar(
        title: const Text("server"),
      ),
      body: Column(
        children: [
          Expanded(
          ),
          Expanded(
          ),
        ],
      ),
);

视频渲染界面实现

在第一个Expanded中,我们布局视频渲染界面。使用Row横向布局组件放置两个视频展示界面,每个视频展示界面使用Stack堆叠组件,底层放置视频展示框,顶层放置文字,用来指示是本地还是远端视频。视频展示我们使用容器Container来包裹RTCVideoView组件,RTCVideoView组件是插件提供的组件,将我们前边初始化的_localVideoRenderer传入就可以渲染视频了。

dart 复制代码
 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限制取消,同时支持多行输入。最后在设置两个按钮用来开启和关闭视频渲染。

dart 复制代码
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 的摄像头分辨率:

js 复制代码
{
  audio: true,
  video: { width: 1280, height: 720 }
}

强制要求获取特定的尺寸时,可以使用关键字min、max 或者 exact(就是 min == max)。以下参数表示要求获取最低为 1280x720 的分辨率。

js 复制代码
{
  audio: true,
  video: {
    width: { min: 1280 },
    height: { min: 720 }
  }
}

当请求包含一个 ideal(应用最理想的)值时,这个值有着更高的权重,意味着浏览器会先尝试找到最接近指定的理想值的设定或者摄像头(如果设备拥有不止一个摄像头)。

js 复制代码
{
  audio: true,
  video: {
    width: { min: 1024, ideal: 1280, max: 1920 },
    height: { min: 776, ideal: 720, max: 1080 }
  }
}

优先使用前置摄像头(如果有的话):

js 复制代码
{ audio: true, video: { facingMode: "user" } }

在我们的代码实现中,我们用下边的配置,分辨率为8000*6000,使用前置摄像头,帧率60帧:

dart 复制代码
final Map<String, dynamic> mediaConstraints = {
  'audio': true,
  'video': {
    "width": {"ideal": 8000},
    "heigth": {"ideal": 6000},
    'facingMode': 'user', //'facingMode': 'environment',
    "frameRate": {
      "ideal": 60,
    },
  }
};

调用函数为:

dart 复制代码
localstream = await navigator.mediaDevices.getUserMedia(mediaConstraints);

sdpConstraints为sdp约束,是一个可选对象参数:

dart 复制代码
/*
final Map<String, dynamic> sdpConstraints = {//可选参数
  "mandatory": {
    "OfferToReceiveAudio": true,
    "OfferToReceiveVideo": true,
  },
  "optional": [],
};*/

调用函数为:

dart 复制代码
localConnection.createAnswer(sdpConstraints);

pcConstraints为可选参数,configuration必须传递,在configuration中可以指定iceServers服务器,我们不需要iceServers服务器,所以这里设置为空。

dart 复制代码
/*
final Map<String, dynamic> pcConstraints = {//可选参数
  "mandatory": {},
  "optional": [],
};*/
Map<String, dynamic> configuration = {
  "iceServers": [
    // {"url": "stun:stun.l.google.com:19302"},
  ]
};

调用函数为:

dart 复制代码
createPeerConnection(configuration, pcConstraints);

媒体获取函数

接下来实现媒体获取函数,首先调用getUserMedia函数获得localstream,接着将localstream赋值给_localVideoRenderer.srcObject,然后调用createPeerConnection创建本地的localConnection。使用localConnection.onIceCandidate 监听candidate事件,当产生candidate时将candidate打印出来,方便我们手动进行candidate的交换。记得调用 localConnection.addTrack将媒体轨道赋值给本地localConnection,使用localConnection.onAddTrack监听添加轨道事件,当检测到对方添加了轨道,将其赋值给远端对象_remoteVideoRenderer。

dart 复制代码
_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;
  };
}

媒体关闭函数

我们再实现一个关闭函数,可以关闭我们的视频。

dart 复制代码
close() {
  localstream.dispose();
  _localVideoRenderer.srcObject = null;
  localConnection.close();
  _remoteVideoRenderer.srcObject = null;
}

offer配置函数

client为发起方,所以要产生offer,我们构建如下的offer产生函数,将产生的offer内容变换为json格式,同时在offer输入框显示。

dart 复制代码
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输入框显示。

dart 复制代码
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就有了对方的若干信息,后边就可以协商建立连接了。

dart 复制代码
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三种。

dart 复制代码
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格式如下:

json 复制代码
 {"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,成功建立视频通话连接:

相关推荐
程序员老刘1 小时前
一杯奶茶钱,PicGo + 阿里云 OSS 搭建永久稳定的个人图床
flutter·markdown
奋斗的小青年!!5 小时前
OpenHarmony Flutter 拖拽排序组件性能优化与跨平台适配指南
flutter·harmonyos·鸿蒙
小雨下雨的雨6 小时前
Flutter 框架跨平台鸿蒙开发 —— Stack 控件之三维层叠艺术
flutter·华为·harmonyos
行者967 小时前
OpenHarmony平台Flutter手风琴菜单组件的跨平台适配实践
flutter·harmonyos·鸿蒙
小雨下雨的雨9 小时前
Flutter 框架跨平台鸿蒙开发 —— Flex 控件之响应式弹性布局
flutter·ui·华为·harmonyos·鸿蒙系统
cn_mengbei9 小时前
Flutter for OpenHarmony 实战:CheckboxListTile 复选框列表项详解
flutter
cn_mengbei9 小时前
Flutter for OpenHarmony 实战:Switch 开关按钮详解
flutter
奋斗的小青年!!9 小时前
OpenHarmony Flutter实战:打造高性能订单确认流程步骤条
flutter·harmonyos·鸿蒙
Coder_Boy_9 小时前
Flutter基础介绍-跨平台移动应用开发框架
spring boot·flutter
cn_mengbei9 小时前
Flutter for OpenHarmony 实战:Slider 滑块控件详解
flutter