likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 52

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"),
      ),
    );
  }
}

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

Flutter+WebRTC开发点对点加密即时通讯APP--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(() {});
               },
             ),
           ],
         ),
       ),
     ],
   ),
 ),

 效果图:

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

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

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);
}

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

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,成功建立视频通话连接:

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

转载自:https://juejin.cn/post/7330054489078120489
评论
请登录