Twilioブログ

流行りのFlutterでWebRTCのビデオ通話アプリを作ってみた

こんにちは。KDDIウェブコミュニケーションズ 技術部の山本です。
今、流行っているFlutterでTwilioお得意のビデオ通話アプリを作ってみたので紹介します。

しかし、本記事ではTwilioは利用していません。
Twilioとの連携は、今後公開される別記事をご参照ください。

CTA_まずはtwilioを使ってみる。

本記事の流れ

  1. 目的・背景
  2. ざっくり、WebRTCはどんな仕組み?
  3. 実装のポイント
  4. 利用するパッケージ
  5. カメラの映像データ(VideoStream)取得
  6. WebRTCの接続
  7. 今後の課題

目的・背景

背景(きっかけ)

WebRTCを利用したアプリケーションをブラウザ(React)で作成していたのですが、Desktopアプリで動かしたいという要望が出てきました。

Reactをそのまま活用できる、ReactNativeやElectronの検討を進めていると……

メンバーの1人が「フラッターってのがあって、これならDesktop(Mac/Windows)はもちろん、スマホ(Android,iOS)、ブラウザのアプリも一式作成できるらしいですよ!」 と教えてくれました。

なので「まずはやってみよう!」ってことで作ってみました。

目的

Flutterでビデオ通話アプリを、Desktopとスマホで実施する。
ついでに、Flutterの技術習得。

ざっくり、WebRTCはどんな仕組み?

  • Web Real Time Communication(WebRTC)
  • Interactive Connectivity Establishment (ICE)
  • Session Traversal Utilities for NAT (STUN)
  • Traversal Using Relays around NAT (TURN)
  • Session Description Protocol (SDP)
  • Offer/Answer
  • ICE Candidates
  • Signaling Server

ベースの考え方

WebRTCについて調べていて思ったこと「説明記事やドキュメントを読めば読むほど理解が難しい……」
なので、ざっくりどんな仕組みかを知りたくて絵にしてみました。

WebRTCに置き換えると

ざっくり解析

  • WebRTC接続を行う承諾をとる仕組みが、Offer/Answer SDP
  • 自分の位置(外から接続するときの接続経路)を確認するサーバが、ICE SERVER
  • 自分の位置(外から接続するときの接続経路)情報が、ICE Candidate
  • Offer/Answer SDPやICE Candidateをやり取りする場所が、シグナリングサーバ

多少のコミュニケーション順序の違いはあるが、おおむねこの理解であっているはずです。

Flutterでの実装のポイント

それでは、前置きはこれくらいにしてFlutterでの実装のポイントを説明していきます。

利用するパッケージ

dependencies:
  flutter_webrtc: ^0.5.8
  io: ^0.3.4
  html: ^0.14.0+4
  web_socket_channel: ^1.2.0
  • flutter_webrtc
    • Flutter Mobile / Desktop / Web用のWebRTCプラグイン
  • web_socket_channel
    • WebSocket接続のラッパーを提供するプラグイン
  • io / html
    • web_socket_channelで利用する

カメラの映像データ(VideoStream)取得

カメラの映像データ(VideoStream)取得する処理

  Future _createStream() async {
    final Map<string, dynamic=""> mediaConstraints = {
      'audio': true,
      'video': {
        'mandatory': {
          'minWidth': '640',
          'minHeight': '480',
          'minFrameRate': '30',
        },
        ...WebRTC.platformIsDesktop ? {} : {'facingMode': 'user'},
        'optional': [],
      }
    };

    MediaStream stream;
    try {
      stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
    } catch (e) {
      print(e.toString());
    }
    return stream;
  }
</string,>
  • ...WebRTC.platformIsDesktop ? {} : {'facingMode': 'user'},
    • スマホの場合はフロントカメラを対象とし、デスクトップの場合は指定しない。
  • stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
    • カメラの映像データ(VideoStream)を指定した条件で取得

カメラの映像データ(VideoStream)を表示する処理

1.RTCVideoRendererを宣言して、初期化する。

RTCVideoRenderer _localRenderer = RTCVideoRenderer(); await _localRenderer.initialize(); 

2.上記で取得した「カメラの映像データ(VideoStream)」を設定する。

dart _localRenderer.srcObject = stream; 

3.RTCVideoViewを利用して、表示する。
 また、「mirror」を指定することで、鏡(左右逆転)に変更する。

dart child: RTCVideoView(_localRenderer, mirror: true), 

WebRTCの接続

「ざっくり、WebRTCはどんな仕組み?」の絵で記載していた通り、WebRTCには呼び出しする人(誘う人)と呼び出しを受ける人(誘われた人)がいます。

処理は、どちらも動くように作られているので、各ブロックでどちらの人が対象かを記載します。

[呼び出し元] - 呼び出しする人(誘う人)で動く処理
[呼び出し先] - 呼び出しを受ける人(誘われた人)で動く処理
[呼び出し元/先] - どちらも動く処理

[呼び出し元/先] RTCPeerConnection作成

1.ICE SERVERを指定して、Peer-To-Peer接続情報を作成する。

        RTCPeerConnection pc = await createPeerConnection({
          {
            'iceServers': [
              {'url': 'stun:stun.l.google.com:19302'}
            ]
          },
          ...{'sdpSemantics': sdpSemantics}
        }, _config);
    

2.相手と共有するストリーム情報を設定する。

        switch (sdpSemantics) {
          case 'plan-b':
            await pc.addStream(_localStream);
            break;
          case 'unified-plan':
            _localStream.getTracks().forEach((track) {
              pc.addTrack(track, _localStream);
            });
            break;
        }
    

3.相手から共有されたストリーム情報を受信した時の処理を設定する。

        switch (sdpSemantics) {
          case 'plan-b':
             pc.onAddStream = (MediaStream stream) {
               onAddRemoteStream?.call(session, stream);
             };
            break;
          case 'unified-plan':
             pc.onTrack = (event) {
               if (event.track.kind == 'video') {
                 onAddRemoteStream?.call(session, event.streams[0]);
               }
             };
            break;
        }
     

4.ICE SERVERから接続情報(ice candidate)を受信した時の処理を設定する。

【POINT接続情報(ice candidate)は、受信したら、相手にWebSocketで送信!!!

        pc.onIceCandidate = (candidate) {
          if (candidate == null) {
            print('onIceCandidate: complete!');
             return;
          }
          Map sendCandidate = {
            'remoteId': session.remoteId,
            'candidate': {
              'sdpMLineIndex': candidate.sdpMlineIndex,
              'sdpMid': candidate.sdpMid,
              'candidate': candidate.candidate,
            }
          };
          print(sendCandidate);
          _channel.sink.add(_encoder.convert(sendCandidate));
        };
    

5.ICE SERVERとの接続状態を受信した時の処理を設定する。(今回は、特に記載していません)

        pc.onIceConnectionState = (state) {};
    

6.相手のストリームが削除された時の処理を設定する。

        pc.onRemoveStream = (stream) {
          onRemoveRemoteStream?.call(session, stream);
        };
    

[呼び出し元]Offer SDP送信

1.Offer SDP作成

          RTCSessionDescription s = await _session.pc.createOffer();
          await _session.pc.setLocalDescription(s);
    

2.Offer SDPをWebSocketで送信

          _channel.sink.add(_encoder.convert(sendOffer));
    

[呼び出し先]Offer SDP受信〜Answer SDP送信

1.Offer SDPをRTCPeerConnectionに設定する。

        await _session.pc.setRemoteDescription(RTCSessionDescription(sdp, type));
    

2. Answer SDP作成

          RTCSessionDescription s = await _session.pc.createAnswer();
          await _session.pc.setLocalDescription(s);
    

3.Answer SDPをWebSocketで送信

          _channel.sink.add(_encoder.convert(sendAnswer));
    

[呼び出し元]Answer SDP受信

1.Answer SDPをRTCPeerConnectionに設定する。

        await _session?.pc?.setRemoteDescription(RTCSessionDescription(sdp, type));
    

[呼び出し元/先]ice candidateを受信して設定

  void setCandidate(String localId, String remoteId, String candidate,
      String sdpMid, int sdpMLineIndex) async {
    RTCIceCandidate iceCandidate =
        RTCIceCandidate(candidate, sdpMid, sdpMLineIndex);

    if (_session != null) {
      if (_session.pc != null) {
        await _session.pc.addCandidate(iceCandidate);
      } else {
        _session.remoteCandidates.add(iceCandidate);
      }
    } else {
      _session = Session(localId: localId, remoteId: remoteId)
        ..remoteCandidates.add(iceCandidate);
    }
  }

まとめ

今回のアプリは、まだ以下のような課題を抱えています。
  • ICE Serverが不安定
  • Googleが提供する無料のサーバを利用しているため、日中帯など接続に時間がかかる場合がある
  • シグナリングサーバをどこに置くか
  • 常時利用されるアプリでもないので、どこかのサーバにホストするのはコストが気になる。サーバレスを検討するべきか
  • 複数人でのビデオ会議
  • 現在のアプリは、1対1のみをサポートしているので、複数人でのコミュニケーションを可能としたい

公開リポジトリ

https://github.com/Yamamoto-Shohei/sample_flutter_webrtc

https://github.com/Yamamoto-Shohei/sample_webrtc_signaling

アプリケーションエンジニア 山本 祥平
アプリケーションエンジニア 山本 祥平

技術本部第二開発部に所属し、レンタルサーバー「CPI」のバックオフィスシステム再構築(マイグレーション・モダナイゼーション)PJに従事。 CI/CD、DevOps、チーム力/品質向上などに力を入れて取り組んでいる。

CTA サービス資料をご用意しました。

Share!!

この記事を読んだ人へのオススメ

  • お役立ち情報
  • イベント情報
  • 相談会申込
  • 導入事例