Lento con forza

大学生気分のIT系エンジニアが色々書いてく何か。ブログ名決めました。

iOSのネイティブアプリでWebRTCを使ったビデオチャットを実装する

こんばんは! id:kouki_dan です!株式会社はてなのネイティブアプリエンジニアです! この記事は、はてなエンジニア Advent Calendar 2020の8日目の記事です。

昨日は id:shallow1729 さんのAtcoder初心者が成績アップに繋がったと感じた考え方の工夫でした。競技プログラミングの難しい問題は数学的な知識も必要になってきて慣れていないと難しいですよね。初心者が競技プログラミングに取り組む上で大切な考え方を知ることができてとても勉強になりました!

今年を振り返ってみるとリモートワークの年でしたね。今日はそんなリモートワークを支える技術について書いていこうと思います。

WebRTC

WebRTCはブラウザ上でボイスチャットやビデオチャットができるようになるフレームワークです。リアルタイム通信機能をアプリケーションに実装できます。

Webブラウザに実装されたWebRTCでしたが、iOSとAndroid用のネイティブクライアントも提供されており、相互にP2Pで通信することができます。

通信機能に加え、ビデオやマイクなどのデバイスサポートも充実しており、ブラウザ上でボイスチャットやビデオチャットを実装するためにはWebRTCを利用することが必要です。

WebRTCの動き方

WebRTCは主に3つの要素から構成されています。ブラウザのAPIの用語を使って説明します。

用語はこちらを参考にしています。

getUserMedia() でカメラやマイクを扱う

WebRTCはリアルタイム通信を行う汎用的なプロトコルですが、その目的の重要なユースケースとしてカメラやマイクなどを使ったビデオチャットがあります。

ブラウザからカメラやビデオを扱うためのAPIとして、getUserMedia()があります。このAPIを使うとJavaScriptからカメラやマイクにアクセスできます。カメラ入力はCanvasやビデオタグで描画したり、WebRTCの入力として使うことができます。

RTCPeerConnection でコネクションを確立する

RTCPeerConnectionはWebRTCのコネクションを管理するAPIです。P2Pで通信するためには様々なプロトコルを駆使して通信する必要がありますが、その全てを抽象化しています。

P2Pの接続先をWebRTCのAPIから知ることはできないため、RTCPeerConnectionは通信したい接続先の情報を含んだSDPとICE Candidateという文字列を発行します。これらの文字列をJavaScriptで取得し、自分のサーバーを通してWebSocketやHTTPなどで交換します。この接続先の情報の交換をシグナリングと呼び、WebRTCを実装するためにWebアプリケーションで実装しなければならない要素の一つです。

RTCDataChannel でデータの送受信を行う

RTCPeerConnectionでシグナリングを行い、コネクションが確立するとP2Pでデータの送受信が行えます。この時に使うのがRTCDataChannelです。任意のデータを送受信できます。

ビデオチャットを実装する場合はgetUserMedia()で取得したストリームをRTCDataChannelで送信すると思うかもしれませんが、カメラ情報などは専用のAPIが用意されていて、RTCDataChannelを使わずとも送受信することが可能です。(おそらく裏側ではRTCDataChannelを使用していると思います) 

WebRTCの学び方

ここまで、WebRTCについてザッと説明しました。この記事はiOSアプリでWebRTCを実装する方法を説明する記事なので、WebRTCやシグナリングについての詳しい説明は別の記事を参考にしていただきたいと思います。

実践的な学習は、WebRTCのコードラボがとても参考になります。

webrtc.org

その他の重要なトピックについてもこのwebrtc.orgで説明されているので、まずはこちらを参考にしつつ、実例などを検索していくと良いと思います。  

iOSネイティブアプリにおけるWebRTC

いよいよ本題のiOSネイティブアプリにおけるWebRTCです。WebRTCはブラウザのAPIですが、同じプロトコルを実装するライブラリがwebrtc.orgでGoogleから提供されています。ただし、ドキュメントが見つけられなく、*1手探りでコードを書いていくことになりました。ただ、大部分のAPIはWebと同じAPI名なので、ある程度は書き進めることができます。

iOSネイティブアプリでWebRTC通信する

さて、実際にWebRTCを使ったビデオチャットを実装してみましょう。シグナリングにはコードラボと同じ形式を利用します。コードラボはFirestoreを使用しているので、iOSアプリでもFirestoreを使用します。

webrtc.org

シグナリングの仕組みとしてコードラボと同じ仕組みを使っているため、完成したアプリではブラウザとiOS間のビデオチャットも行えるようになります。

コードラボから拝借してきたブラウザで動作するコードはこちらです

https://github.com/kouki-dan/WebRTC-sample/tree/main/FirebaseRTC

ビューの準備

getUserMedia()に相当する、カメラやマイクの処理からみていきましょう。

ローカル用のビューとリモート用のビューは以下のように生成できます。

#if arch(arm64)
    // Using metal (arm64 only)
    let localRenderer = RTCMTLVideoView(frame: self.localVideoView?.frame ?? CGRect.zero)
    let remoteRenderer = RTCMTLVideoView(frame: self.view.frame)
    localRenderer.videoContentMode = .scaleAspectFill
    remoteRenderer.videoContentMode = .scaleAspectFill
#else
    // Using OpenGLES for the rest
    let localRenderer = RTCEAGLVideoView(frame: self.localVideoView?.frame ?? CGRect.zero)
    let remoteRenderer = RTCEAGLVideoView(frame: self.view.frame)
#endif

これらのビューをaddSubviewするとビューの準備が完了です。続いてこれらのビューとWebRTCのAPIを繋ぎます。

自分のカメラ映像を配信する

自分のカメラ映像を取得し、ビューと紐付けるコードは以下のようになります。

func startCaptureLocalVideo(renderer: RTCVideoRenderer) {
    videoCapturer = RTCCameraVideoCapturer(delegate: videoSource)
    guard let capturer = videoCapturer as? RTCCameraVideoCapturer else {
        print("Error: Camera not found")
        return
    }

    guard let frontCamera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == .front }),
          let format = RTCCameraVideoCapturer.supportedFormats(for: frontCamera).max(by: {
            CMVideoFormatDescriptionGetDimensions($0.formatDescription).width < CMVideoFormatDescriptionGetDimensions($1.formatDescription).width
          }),
          let fps = format.videoSupportedFrameRateRanges.max(by: {
            $0.maxFrameRate < $1.maxFrameRate
          }) else {
        return
    }

    capturer.startCapture(with: frontCamera,
                          format: format,
                          fps: Int(fps.maxFrameRate))

    localVideoTrack.add(renderer)
}

引数として与えるrendererは先ほど生成した RTCMTLVideoViewのインスタンスです。 RTCCameraVideoCapturerでカメラ映像を取得します。様々な種類の解像度やfpsを選択することができるため、ひとまず一番良いものを選んでおきます。

localVideoTrackはまだ登場していませんが、後ほど登場します。

相手のカメラ映像を表示する

相手から送られてきた画像と先ほど作ったビューを紐づけるコードは以下です。

func renderRemoteVideo(to renderer: RTCVideoRenderer) {
    remoteVideoTrack?.add(renderer)
}

送られてきた映像を表示するだけなので、自分のカメラの設定よりもシンプルです。remoteVideoTrackについてもまだ登場していませんが、後ほど登場します。

シグナリングの準備

シグナリングの実装をしていきます。シグナリングは以下のように行われます。

  1. RTCPeerConnectionを生成
  2. 送信側がofferを作成 & 受信側に送信(SDP)
  3. 受信側がofferを受け取りanswerを作成 & 送信側に送信(SDP)
  4. 送信側がanswerを受け取る(SDP)
  5. 必要に応じて常に送信側と受信側でICE Candidateのやりとりを行う

これらを行うために、WebRTCClientクラスを作りました。先ほどのstartCaptureLocalVideorenderRemoteVideoメソッドもWebRTCClientのメソッドとして実装しています。

1. RTCPeerConnectionを生成

以下のコードでRTCPeerConnectionのインスタンスと、ビデオを処理するためのインスタンスを生成します。

class WebRTCClient: NSObject {
    private let configuration: RTCConfiguration = {
        let configuration = RTCConfiguration()
        configuration.iceServers = [
            RTCIceServer(urlStrings: [
                "stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302"
            ])
        ]
        configuration.iceCandidatePoolSize = 10
        configuration.sdpSemantics = .unifiedPlan
        return configuration
    }()

    private let factory: RTCPeerConnectionFactory = {
        let videoEncoderFactory = RTCDefaultVideoEncoderFactory()
        let videoDecoderFactory = RTCDefaultVideoDecoderFactory()
        return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory)
    }()

    private let videoSource: RTCVideoSource
    private let localVideoTrack: RTCVideoTrack

    private var peerConnection: RTCPeerConnection?

    private var videoCapturer: RTCVideoCapturer?
    private var remoteVideoTrack: RTCVideoTrack?

    public var gotCandidate: ((RTCIceCandidate) -> Void)?

    override init() {
        videoSource = factory.videoSource()
        localVideoTrack = factory.videoTrack(with: videoSource, trackId: "video1")
        super.init()
        let constraints = RTCMediaConstraints(mandatoryConstraints: nil,
                                              optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue])
        peerConnection = factory.peerConnection(with: configuration, constraints: constraints, delegate: self)
        peerConnection?.add(localVideoTrack, streamIds: ["stream"])
        remoteVideoTrack = peerConnection?.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack
    }
// ......
}

細かい設定が多いため記述量が多くなってしまいましたが、重要なのは以下の行です。

peerConnection = factory.peerConnection(with: configuration, constraints: constraints, delegate: self)

peerConnectionがブラウザAPIののRTCPeerConnectionに相当するクラスで、シグナリングを行います。 peerConnectionを生成した後は、ビデオ映像とpeerConnectionの紐付け処理を行います。

peerConnection?.add(localVideoTrack, streamIds: ["stream"])
remoteVideoTrack = peerConnection?.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack

ビューの設定で使用したlocalVideoTrackremoteVideoTrackはこのように生成していました。

2. 送信側がofferを作成

offerの作成はpeerConnection.offer()で行えます。WebRTCの処理をラップしているWebRTCClientクラスでは以下のメソッドを定義しました。

func offer(completion: @escaping (RTCSessionDescription) -> Void) {
    let constrains = RTCMediaConstraints(mandatoryConstraints: [
        kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue
    ],
                                         optionalConstraints: nil)
    peerConnection?.offer(for: constrains, completionHandler: { [weak self] (offer, error) in
        guard let offer = offer else {
            return
        }

        self?.peerConnection?.setLocalDescription(offer, completionHandler: { (error) in
            completion(offer)
        })
    })
}

3. 受信側がofferを受け取りanswerを作成

answerの作成もpeerConnectionで行います。offerを受け取り、peerConnection.answer()でanswerを作成します。

func answer(offer: RTCSessionDescription, completion: @escaping (RTCSessionDescription) -> Void) {
    peerConnection?.setRemoteDescription(offer, completionHandler: { [weak self] (error) in
        let constrains = RTCMediaConstraints(mandatoryConstraints: [
            kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue
        ],
                                             optionalConstraints: nil)
        self?.peerConnection?.answer(for: constrains, completionHandler: { [weak self] (answer, error) in
            guard let answer = answer else { return }
            self?.peerConnection?.setLocalDescription(answer, completionHandler: { (error) in
                completion(answer)
            })
        })
    })
}

4. 送信側がanswerを受け取る

送られてきたanswerを受け取ります。

func gotAnswer(answer: RTCSessionDescription) {
    peerConnection?.setRemoteDescription(answer, completionHandler: { (error) in })
}

ここまでで、送信側、受信側の両方で setRemoteDescription()setLocalDescription()の呼び出しが完了したことがわかると思います。計4回これらのメソッドを呼び終わるとSDPの交換が完了します。

5. 必要に応じて常に送信側と受信側でICE Candidateのやりとりを行う

ICE Candidateは送信側、受信側のどちらからも発生し、送受信します。

ICE Candidateが作られたことはデリゲートメソッドで検出します。

extension WebRTCClient: RTCPeerConnectionDelegate {
    func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
        print(candidate)
        gotCandidate?(candidate)
    }
// ......
}

リモートから受け取ったIceCandidateはpeerConnection.add()で受け取ります。

func gotIceCandidate(candidate: RTCIceCandidate) {
    peerConnection?.add(candidate)
}

ここで定義したメソッドを順番に呼び出すことで通信の確立が行えます。

f:id:kouki_dan:20201207184015p:plain

シグナリングの実装

先ほど作ったWebRTCClientのメソッドを上記図の通りに呼び出していくことでシグナリングが完了します。Firestoreを使ったシグナリングをみていきましょう。

1. 送信側: offerを作成して送信

送信側は、Roomとして扱うDocumentをFirebaseに作成します。作成したドキュメントのIDはRoomのIDとなるので表示しておきます。ユーザーはこのIDを使ってRoomを特定します。SDPの情報は文字列として表現可能なため、Firestoreの文字列データとして格納します。

let db = Firestore.firestore()
let roomRef = db.collection("rooms").document()
webRTCClient.offer() { [weak self] offer in
    print("Created offer: \(offer)")
    roomRef.setData([
        "offer": [
            "type": "offer",
            "sdp": offer.sdp,
        ]
    ])

    let roomId = roomRef.documentID;
    DispatchQueue.main.sync {
        let alert = UIAlertController(title: "Room ID", message: roomId, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Copy to Clipboard", style: .default, handler: { _ in
            UIPasteboard.general.string = roomId
        }))
        self?.present(alert, animated: true, completion: nil)
    }
}

2. 受信側: offerを受け取りanswerを返す

受信側では、なんらかの手段でRoom IDを受け取ります。/rooms/{Room ID}のDocumentにofferの情報が入っています。これをRTCSessionDescription.from(offer: roomSnapshot.data()) でRTCSessionDescriptionに復元します。このfromメソッドはextensionで作成した自作メソッドです。

WebRTCClient.answer()を呼び出し、answerのSDPを/rooms/{Room ID}に追加します。

private func joinRoom(byId id: String) {
    let db = Firestore.firestore()
    let roomRef = db.collection("rooms").document(id)
    roomRef.getDocument { [weak self] (roomSnapshot, _) in
        guard let roomSnapshot = roomSnapshot else { return }

        if roomSnapshot.exists {
            let calleeCandidatesCollection = roomRef.collection("calleeCandidates");
            self?.webRTCClient.gotCandidate = { candidate in
                calleeCandidatesCollection.addDocument(data: candidate.data)
            }

            guard let offer = RTCSessionDescription.from(offer: roomSnapshot.data()) else { return }
            print("Got offer: \(offer)")
            self?.webRTCClient.answer(offer: offer, completion: { answer in
                roomRef.updateData([
                    "answer": [
                        "type": answer.type.firebaseType,
                        "sdp": answer.sdp
                    ]
                ])
            }
        }
    }
}

3. 送信側: answerを受け取る

送信側では、offer送信後にanswerを受け取る準備をしておきます。addSnapshotListenerでドキュメントに変更があったことを検出し、answerが追加された時にWebRTCClient.gotAnswer()を呼び出すようにしておきます。

roomRef.addSnapshotListener { [weak self] (snapshot, error) in
    guard let answer = RTCSessionDescription.from(answer: snapshot?.data()) else { return }
    self?.webRTCClient.gotAnswer(answer: answer)
}

4. 送信、受信両方: ICE Candidateをやりとりする

3まででSDPの交換が終わりました。ICE Candidateは通信環境により複数回、連続的に発生します。それぞれサブコレクションのドキュメントとして送受信を行います。

送信側

let callerCandidatesCollection = roomRef.collection("callerCandidates");
webRTCClient.gotCandidate = { candidate in
    callerCandidatesCollection.addDocument(data: candidate.data)
}

roomRef.collection("calleeCandidates").addSnapshotListener { [weak self] (snapshot, _) in
    if let snapshot = snapshot {
        for change in snapshot.documentChanges {
            if change.type == .added {
                let data = change.document.data()

                print("Got new remote ICE candidate: \(data)")
                guard let candidate = RTCIceCandidate.from(data: data) else { return }
                self?.webRTCClient.gotIceCandidate(candidate: candidate)
            }
        }
    }
}

受信側

let calleeCandidatesCollection = roomRef.collection("calleeCandidates")
webRTCClient.gotCandidate = { candidate in
    callerCandidatesCollection.addDocument(data: candidate.data)
}

roomRef.collection("callerCandidates").addSnapshotListener { [weak self] (snapshot, _) in
    if let snapshot = snapshot {
        for change in snapshot.documentChanges {
            if change.type == .added {
                let data = change.document.data()

                print("Got new remote ICE candidate: \(data)")
                guard let candidate = RTCIceCandidate.from(data: data) else { return }
                self?.webRTCClient.gotIceCandidate(candidate: candidate)
            }
        }
    }
}

送信側はcallerCandidatesに、受信側はcalleeCandidatesにICE Candidateの情報を追加します。 追加したICE Candidateを受け取り、WebRTCClient.gotIceCandidate()を呼び出します。

ここまでの処理を実装したファイルはこちらです。

WebRTC-sample/ViewController.swift at main · kouki-dan/WebRTC-sample · GitHub

WebRTCでビデオチャットを行う

ここまでの実装で基本的なWebRTCの実装とシグナリングを行ってきました。実際にブラウザとアプリで通信を行ってみます。アプリの実行には実機が必要です。

同じネットワークにあるMacとiPhoneで実際に通信を行っている様子がこちらです。

f:id:kouki_dan:20201207201221p:plain
ブラウザ

f:id:kouki_dan:20201207201259p:plain
iPhone

このようにWebRTCを使って簡単なビデオチャットを実装できました!

ここまでのコードはこちらのリポジトリで公開しています。

github.com

まとめ

WebRTCを使った簡易的なビデオチャットをiOSのネイティブアプリで実装してみました。これからリモートが普及するにつれてビデオチャットの重要性は増してきて、WebRTCを使った実装も重要になってくることが予想されます。 今回は最低限のシグナリングのみの実装を行いましたが、安定的にWebRTCの通信を行うためにはSTUN, TURN, SFUなど様々な仕組みを活用する必要があります。また、シグナリングも今回は最低限のものしか行わなかったため改善の余地があります。 WebRTCの内部では様々な方法でP2P通信を実現しようとしてくれます。利用者側はそれらを全く感知することなく、SDPとICE Candidateの文字列だけを交換すれば良いのでとても簡単ですね。

明日は id:koudenpa さんです!よろしくお願いします!

*1:これがドキュメントらしいけど、利用者向けのドキュメントではなさそう・・・。WebRTC iOS development 誰かドキュメントの位置を知っている人がいたら教えて欲しいです