【Flutter✖️WebRTC】WebRTCについて学んだことのまとめ
この記事は、Flutter大学アドベントカレンダー2022 21日目の記事です。
はじめに
もうすぐで2022年が終わり、2023年になりますね。皆さんの今年の思い出はなんでしょうか?
私は、本業の方で携わっていた案件で触ったWebRTCが印象に残っています。
仕組みやどのように実装すれば良いか分からず、苦労しました。
なので、今回は開発する中で勉強したWebRTCを整理しつつ、サンプルコードの実装を行っていこうと思います。
前半にWebRTCの仕組み、後半にサンプルコードの実装について説明していきますので、気になるところを見て参考にしてもらえたら嬉しいです。
WebRTC とは
まずWebRTCとは、「Web Real-Time Communication」の略称で、リアルタイムにAPIを経由して音声やビデオ、データの通信を確立する技術のことを言います。
このWebRTCの基盤は1999年頃にスウェーデンにあるGlobal IP Solutions社で初めて開発されました。
それまでは、専用のアプリケーションを導入したり、使用可能なデバイスに制限があったりと使用するために必要となる条件が多くありました。
しかし、WebRTCが開発されたことで、利用のハードルが大きく下がり、 Webブラウザがあればどの端末からでも簡単にオンラインでビデオ通話をしたりするアプリケーションを実現できるようになったのです。
対応ブラウザ
◆PC
・ Google Chrome
・ Microsoft Edge
・ Mozilla Firefox
・ Safari
・ Opera
◆モバイル
iOS
・ Safari
Android
・ Google Chrome
・ Mozilla Firefox
・ Opera Mobile
皆さんが普段から使っている主要なブラウザはほとんど対応しています。
WebRTCの仕組み
WebRTCは P2P(Peer to Peer)と呼ばれる通信方式によってリアルタイムでの通信を可能にしています。
WebRTCの仕組み
2人のユーザ間で接続されるまでの流れは以下です。
- ユーザ1が自分のSDPを生成し、シグナリングサーバーにOfferというメッセージと共に生成したSDPを送る
- ユーザ2がシグナリングサーバーからOfferというメッセージとユーザ1のSDPを受けとる
- ユーザ2が自分のSDPを生成し、Answerというメッセージと共に生成したSDPをシグナリングサーバーに送る
- ユーザ1がAnswerというメッセージとユーザ2のSDPを受けとる
- ユーザ1とユーザ2のP2P接続が確立する
STUNサーバーは接続のための障害を回避するために使用される。
仕組みはこちらの記事を参考にさせていただいてます。
P2P方式とは?
P2P方式とは、ネットワーク上の機器同士がサーバーを介さずに直接通信するためのもので、一般的なWebRTCの通信方式です。
従来は、クライアント・サーバー方式と呼ばれる、データを提供するサーバーと提供されたデータを利用するクライアントに役割を分けるが一般的でした。
しかしこの方式では、多くの通信が特定のサーバーに集中してしまい、サーバーの負荷が大きくなってしまう危険がありました。
そのため、サーバーを介さないデータのやりとりにより、サーバーへの負荷が分散されるという特徴や機器同士が個別に通信を確立しているので、ある機器に障害が発生しても、他の機器同士の通信に影響を与えないという特徴を持つP2P方式が採用されるようになったのです。
SDP(Session Description Protocol)とは?
P2P接続を確立させるために必要なのがSDP(Session Description Protocol)です。
これは、P2P通信のための基本情報(IPアドレスやポート番号,暗号化通信を行うための公開鍵情報など)やビデオやオーディオのストリーム情報,データチャネルなどの情報が記述されたものです。
シグナリングサーバーとは?
P2P通信を行うためには上記のSDPをクライアント間で交換する必要があります。
そのため、シグナリングサーバーと呼ばれるサーバーをその仲介役として用い、通信に必要な情報を取得できるようにしています。
ICE(Interactive Connectivity Establishment)とは?
ICEはInteractive Connectivity Establishmentの 略称で、通信可能な経路を確認するためのプロトコルのことです。
SDPで、お互いのネットワーク情報が判明したら、次に継続して接続するための経路を見つけてあげる必要があります。その経路をICEがピックアップしてくれるのです。
ICEがピックアアップしてくれた経路の候補は、ICE Candidateと呼ばれており、WebRTCでは見つかった順に接続を試み、最初に繋がった経路で通信が行われます。
また、このICEですが、NAT超えを実現するためのメカニズムでもあります。
現在のインターネット、特にクライアント側にはNATが入っており、相手のプライベートIPアドレスの取得ができなません。
そのため、P2P通信を行うのにICEでNAT超えをする必要があるのです。
NATについて
NATとは、プライベートIPアドレスやグローバルIPアドレスを変換する機能のことです。
一般的にインターネット接続は、以下の手順でデータのやり取りを行っています。
- パソコンのプライベートIPアドレスを、ルーターが自身の持つグローバルIPアドレスに変換して、データを送る
- 接続先からデータが返ってきたら、ルーターが接続先をパソコンのプライベートIPアドレスに変換してくれて、データを返す
つまり、異なるネットワークにある機器同士で接続を行いたい場合、双方のルーターが持つグローバルIPアドレスに接続することになります。
しかし、ルーターにデータが送られてきても、その先の機器が持つプライベートIPアドレスが不明なので、インターネット上から直接接続することができません。
だから、プライベートIPアドレスしか持たないデバイス同士を、インターネットを通じて通信することができるようにする技術であるNAT超えが必要になってきます。
ICEは RFC 5245 で標準化されており、STUN (Session Traversal Utilities for NATs) と TURN (Traversal Using Relays around NAT) プロトコルを使います。
STUNサーバーとは?
STUNサーバーは外部ネットワークから見たIPアドレスを教えてくれるサーバーです。
このSTUNサーバーが教えてくれたアドレスと自身のパソコンのアドレスとを比較することでNAT越えが必要か判断できます。
TURNサーバーとは?
実際に企業で導入しているネットワークにはNAT越えだけでなく、ファイアウォール超える必要がある場合がほとんどです。
ファイアウォールとは、基本的なセキュリティ対策の1つで、サイバー攻撃などの対策として、ポート制御やウイルス感染を防ぐ役割を担っているものです。
このファイアウォールをセキュアに超えるために使われるのがTURNサーバーです。
TURNサーバーはP2P通信をしたいPCの間に立って、データの中継してくれます。
STUNサーバ/TURNサーバの参考記事は以下です。
この2つのサーバーが上手く組み合わさることで、P2P通信においてNATが生み出した障壁を越えることができます。
サンプルコード
準備
まず対応プラットフォームごとに必要な準備をします。
こちらはflutter_webrtcのドキュメントに記載されている通りに行います。
iOS
Info.plistファイルに次のエントリを追加します
<project root>/ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) Camera Usage!</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) Microphone Usage!</string>
このエントリにより、アプリはカメラとマイクにアクセスできます。
Android
AndroidManifest.xmlファイルに、次のアクセス許可が存在することを確認します。
<project root>/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" />
Bluetooth デバイスを使用する必要がある場合は、以下を追加します。
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
現在、公式の WebRTC jar は EglBase インターフェイスで静的メソッドを使用しています。
そのため、ビルド設定を Java 8 に変更する必要があります。
次のコードを``<project root>/android/app/build.gradle```に追加します。
android {
//...
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
サンプルコード
今回はこちらのサンプルを真似させていただきました。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:sdp_transform/sdp_transform.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter WebRTC Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter WebRTC Demo'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _localVideoRenderer = RTCVideoRenderer();
final _remoteVideoRenderer = RTCVideoRenderer();
final sdpController = TextEditingController();
bool _offer = false;
RTCPeerConnection? _peerConnection;
MediaStream? _localStream;
initRenderer() async {
await _localVideoRenderer.initialize();
await _remoteVideoRenderer.initialize();
}
_getUserMedia() async {
final Map<String, dynamic> mediaConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
}
};
MediaStream stream =
await navigator.mediaDevices.getUserMedia(mediaConstraints);
_localVideoRenderer.srcObject = stream;
return stream;
}
_createPeerConnecion() async {
Map<String, dynamic> configuration = {
"iceServers": [
{"url": "stun:stun.l.google.com:19302"},
]
};
final Map<String, dynamic> offerSdpConstraints = {
"mandatory": {
"OfferToReceiveAudio": true,
"OfferToReceiveVideo": true,
},
"optional": [],
};
_localStream = await _getUserMedia();
RTCPeerConnection pc =
await createPeerConnection(configuration, offerSdpConstraints);
pc.addStream(_localStream!);
pc.onIceCandidate = (e) {
if (e.candidate != null) {
print(json.encode({
'candidate': e.candidate.toString(),
'sdpMid': e.sdpMid.toString(),
'sdpMlineIndex': e.sdpMLineIndex,
}));
}
};
pc.onIceConnectionState = (e) {
print(e);
};
pc.onAddStream = (stream) {
print('addStream: ' + stream.id);
_remoteVideoRenderer.srcObject = stream;
};
return pc;
}
void _createOffer() async {
RTCSessionDescription description =
await _peerConnection!.createOffer({'offerToReceiveVideo': 1});
var session = parse(description.sdp.toString());
print(json.encode(session));
_offer = true;
_peerConnection!.setLocalDescription(description);
}
void _createAnswer() async {
RTCSessionDescription description =
await _peerConnection!.createAnswer({'offerToReceiveVideo': 1});
var session = parse(description.sdp.toString());
print(json.encode(session));
_peerConnection!.setLocalDescription(description);
}
void _setRemoteDescription() async {
String jsonString = sdpController.text;
dynamic session = await jsonDecode(jsonString);
String sdp = write(session, null);
RTCSessionDescription description =
RTCSessionDescription(sdp, _offer ? 'answer' : 'offer');
print(description.toMap());
await _peerConnection!.setRemoteDescription(description);
}
void _addCandidate() async {
String jsonString = sdpController.text;
dynamic session = await jsonDecode(jsonString);
print(session['candidate']);
dynamic candidate = RTCIceCandidate(
session['candidate'], session['sdpMid'], session['sdpMlineIndex']);
await _peerConnection!.addCandidate(candidate);
}
void initState() {
initRenderer();
_createPeerConnecion().then((pc) {
_peerConnection = pc;
});
// _getUserMedia();
super.initState();
}
void dispose() async {
await _localVideoRenderer.dispose();
sdpController.dispose();
super.dispose();
}
SizedBox videoRenderers() => SizedBox(
height: 210,
child: Row(children: [
Flexible(
child: Container(
key: const Key('local'),
margin: const EdgeInsets.fromLTRB(5.0, 5.0, 5.0, 5.0),
decoration: const BoxDecoration(color: Colors.black),
child: RTCVideoView(_localVideoRenderer),
),
),
Flexible(
child: Container(
key: const Key('remote'),
margin: const EdgeInsets.fromLTRB(5.0, 5.0, 5.0, 5.0),
decoration: const BoxDecoration(color: Colors.black),
child: RTCVideoView(_remoteVideoRenderer),
),
),
]),
);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: [
videoRenderers(),
Row(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: TextField(
controller: sdpController,
keyboardType: TextInputType.multiline,
maxLines: 4,
maxLength: TextField.noMaxLength,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _createOffer,
child: const Text("Offer"),
),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: _createAnswer,
child: const Text("Answer"),
),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: _setRemoteDescription,
child: const Text("Set Remote Description"),
),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: _addCandidate,
child: const Text("Set Candidate"),
),
],
)
],
),
],
));
}
}
最後に
最後までお読みいただきありがとうございました。
リモートワークが増え、Web会議ツールを使うことも多くなった今。ビデオ通話機能をアプリに組み込むことも増えてくると思います。
概念は複雑で難しく、勉強に苦労しますが、実装するだけであれば少し勉強するだけでできます。
まだflutter_webrtcを使ったことがない人はこれを機に一度使ってみてはいかがでしょうか。
本記事が少しでもWebRTCの理解の助けとなれば嬉しいです。
それでは良いお年を!!
補足
flutter_webrtcのドキュメントにもリンクが載っているサンプルコードを触ってみるのも勉強になると思います。
機能面では、こちらの方が参考になるものがあるかもです。
あと、こちらのYoutubeも勉強になりました。
api側の実装も含めて説明してくれているので、真似てみると力になると思います。
①webrtcについての説明
②実装part1
③実装part2
参考記事
ここまで勉強する必要はないと思いますが、もっとWebRTCの仕組みについて学びたい人はこちらの記事を見てみるのも良いと思います。
◆その他参考記事
◆サンプル参考記事
Discussion