It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

【SwifUI】Sign In With Appleの機能をSwiftUIで実装する

概要

アップル独自の認証方法「Sign in with Apple」について、SwiftUIを使って実装してみます。

環境

  • XCode: Version 11.3.1
  • Swift: Version 5.1

Sign in with appleのボタンの作成

まず、はじめにボタンコンポーネントの作成から実装します。

Sign in with appleに使用するボタンについては、Apple側で ASAuthorizationAppleIDButton という標準のボタンコンポーネントを用意してくれています。
これを使うとわざわざデザインしなくても公式のボタンデザインが使用できるので通常はこのコンポーネントを使うのがいいのではないかと思います。
(認証機能自体は別実装ですので独自デザインも可能です)

ただし、ASAuthorizationAppleIDButtonは、SwiftUIの標準コンポーネントとしては提供されていません。
そのため 、まずはこのボタンをSwiftUIのViewコンポーネントとして使えるようにします。

import SwiftUI
import AuthenticationServices

struct SignInWithAppleButton: UIViewRepresentable {
    func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
        let button = ASAuthorizationAppleIDButton()
        return button
    }
    
    func updateUIView(_: ASAuthorizationAppleIDButton, context _: Context) {}
}

UIKitで用意されているUIコンポーネントをSwiftUIのViewコンポーネントに橋渡しするためには、UIViewRepresentable を適用したstructを用意します。

今回は、UIViewRepresentableについての詳細は割愛しますが、このプロトコルmakeUIView というメソッドを実装し、表示したいUIコンポーネントを返すと、SwiftUI側でViewコンポーネントとして使用できるようになります。

またASAuthorizationAppleIDButton を使用するためには、 AuthenticationServices というパッケージが必要のためimportしています。

ボタンの準備はこれだけでOKです。あとはこのボタンをSwiftUIの画面に配置すればSign in With Appleのボタンを表示することができます。

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            SignInWithAppleButton()
                .frame(width: 200.0, height: 60.0)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

f:id:inon29:20200307170022p:plain

ボタンには、サイズ指定をしていないため frame Modifierを使ってサイズを指定しています。

ASAuthorizationAppleIDButton には、ボタンの種別(authorizationButtonType)とボタンのスタイル(authorizationButtonStyle)の2つのプロパティを設定することができます。

authorizationButtonTypeには、

  • continue
  • signUp
  • signIn

authorizationButtonStyleには、

  • black
  • whiteOutline
  • white

が指定でき、用途やアプリのデザイン別にボタンを出し分けることも可能です。

f:id:inon29:20200307170052p:plain

ASAuthorizationAppleIDButton(authorizationButtonType: .signUp, 
                            authorizationButtonStyle: .whiteOutline)

authorizationButtonTypeを signUp、authorizationButtonStyleを whiteOutlineに設定するとこのようになります。

認証処理の実装

ボタンの見た目を作ることができたので、ここからは認証処理を実装していきます。

Sign In with Appleの認証処理を行うにはASAuthorizationAppleIDProviderASAuthorizationControllerを使用します。

@objc func didTapButton() {
    let appleIDProvider = ASAuthorizationAppleIDProvider()
    let request = appleIDProvider.createRequest()
    request.requestedScopes = [.fullName, .email]

    let authorizationController = ASAuthorizationController(authorizationRequests: [request])
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self
    authorizationController.performRequests()
}

上記がリクエストの処理のサンプルです。
ASAuthorizationAppleIDProvider を使って認証リクエストを作成して ASAuthorizationController で認証を実行するという流れになります。

この処理を先程作成した「SignInWithAppleButton」に実装します。

プロジェクトの設定

実装に入る前にプロジェクトの設定をしておきましょう。

Sign In with Appleを利用するには「Target -> Signing&Capabilities -> + Capability」から Sign In with Apple を選択して追加しておく必要があります。

※ この設定を追加しておかないと、実行時にエラーが発生します。

f:id:inon29:20200307160354p:plain f:id:inon29:20200307160406p:plain

実装

次に、「SignInWithAppleButton」に認証処理を追加します。

UIViewRepresentableを適合したコンポーネントは、Coordinator というクラスを使ってコンポーネント上の処理を実装することができます。

今回は、このCoordinatorクラスを使って認証処理を実装します。

import SwiftUI
import AuthenticationServices

struct SignInWithAppleButton: UIViewRepresentable {
    func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
        let button = ASAuthorizationAppleIDButton()
        // NOTE: ボタン押下時のイベント処理を追加
        // CoordinatorクラスのdidTapButtonメソッドでイベントを受け取る
        button.addTarget(context.coordinator,
                         action: #selector(Coordinator.didTapButton),
                         for: .touchUpInside)
        return button
    }
    
    func updateUIView(_: ASAuthorizationAppleIDButton, context _: Context) {}
   
    func makeCoordinator() -> Coordinator {
        // NOTE: Coordinatorを作成する処理
        // 初期値にView自身を渡す
        Coordinator(self)
    }
}

final class Coordinator: NSObject {
    var parent: SignInWithAppleButton

    init(_ parent: SignInWithAppleButton) {
        self.parent = parent
        super.init()
    }
   
    // ボタンコンポーネントをタップされたときの処理
    @objc func didTapButton() {
        // NOTE: リクエストの作成
        let appleIDProvider = ASAuthorizationAppleIDProvider()
        let request = appleIDProvider.createRequest()
        // NOTE: 認証情報として、「ユーザ名」と「メールアドレス」を受け取る
        request.requestedScopes = [.fullName, .email]

        // NOTE: 認証リクエストの実行
        let authorizationController = ASAuthorizationController(authorizationRequests: [request])
        authorizationController.delegate = self
        authorizationController.presentationContextProvider = self
        authorizationController.performRequests()
    }
}

extension Coordinator: ASAuthorizationControllerDelegate {
    // 認証処理が完了した時のコールバック
    func authorizationController(controller _: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
            print("認証成功", appleIDCredential)
        }
    }
    
    // 認証処理が失敗したときのコールバック
    // NOTE: 認証画面でキャンセルボタンを押下されたときにも呼ばれる
    func authorizationController(controller _: ASAuthorizationController, didCompleteWithError error: Error) {
            print("認証エラー", error)
    }
}

extension Coordinator: ASAuthorizationControllerPresentationContextProviding {
    // 認証プロセス(認証ダイアログ)を表示するためのUIWindowを返すためのコールバック
    func presentationAnchor(for _: ASAuthorizationController) -> ASPresentationAnchor {
        let vc = UIApplication.shared.windows.last?.rootViewController
        return (vc?.view.window!)!
    }
}

ボタンを押下されたときのイベントを Coodinator クラスで受け取って(didTapButton)認証リクエストを実行します。

今回は、認証情報として「ユーザ名(.fullName)」と「メールアドレス(.email)」を取得するようにScopeを設定しています。

Coordinator クラスでは、認証処理のdelegate(ASAuthorizationControllerDelegate)と認証プロセスを提供するためのUIWindowを指定するコールバック(ASAuthorizationControllerPresentationContextProviding)を実装する必要があります。

これで実装は完了です。

動作確認

f:id:inon29:20200307163854p:plain

アプリを起動して、ボタンを押下すると上記のようなダイアログが表示され認証処理が開始されます。
正しく認証が行われると結果をdelegateで受け取れます。

あとは、コールバック処理を「SignInWithAppleButton」に追加するなど対応すれば、SwiftUI上で認証結果を受け取れるようになります。

注意事項

個人的に気になったことがあったので記載しておきます。

① 認証ダイアログで「キャンセル」ボタンを押下されたときの挙動

認証ダイアログで「キャンセル」ボタンを押下されると、現状の挙動だと ASAuthorizationControllerDelegate のコールバックである authorizationController(controller _: ASAuthorizationController, didCompleteWithError error: Error) が呼ばれるようです。

個人的にはエラーとキャンセルで処理を分けたかったのですが、今はおそらく同じ箇所に入るためエラーコードで判断するなどの必要があるかもしれません。

② 認証の付加情報は2回目以降は、返ってこない(nilになる)

authorizationController(controller _: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) では認証成功の結果として credential情報が受け取れるのですが、requestScopesで指定した付加情報は2回目以降の認証では入っていません(nilになっています)。
(ID情報であるUserIdは常に返却されます)

そのため、この情報をサーバなどで処理する場合は、初回の認証時にどこかアプリ側で保存しておく必要があります。

この認証履歴は、アプリ内で保有されるものではないためアプリをアンインストールしてもリセットされないため注意が必要です。

(ユーザ自身が「設定 -> パスワードとセキュリティ -> Apple IDを使用中のApp」から対象のアプリの認証情報を削除するとリセットされます)

初回で取れた情報は、キーチェーンに保存するなどしてリカバリ方法を検討する必要があるかもしれません。

ソースコード

今回の実装のソースコードは、ここにおいてあります。

所感

実装は、思ったよりも大分シンブルでした。
他サービス認証(Facebook, Google)などと比較して、SDKを入れる必要もないことから実装コストは大分小さい印象です。
またユーザ観点からも、Apple認証を使うほうがセキュリティ的な安心感もあるため、今後は少なくともiOSアプリについてはSign In with Appleが主流になってくるのかなと思っています。

【WebRTC学習】③ シグナリングサーバを使ってP2Pを接続する

概要

inon29.hateblo.jp

前回は、WebRTCのAPIをつかったP2P接続の導入として、1台のPC上(1画面上)で擬似的にRTCPeerConnectionによる接続を試してみました。

今回は、同じローカルネットワークで、別々の端末でP2Pの接続ができるような実装を試してみます。 (STUNサーバを建てないため、NATトラバーサルは行いません)

Peer同士の接続の開始には、WebSocketによるシグナリングサーバを建てることで2台の接続を確立します。

シグナリングの流れ

f:id:inon29:20200203210346p:plain

RTCPeerConnection APIを使用したシグナリングの流れは、上記になります。

前回のサンプルでは、シグナリングサーバを実装していなかったため、1画面での擬似的な接続を試しました。(本来シグナリングサーバ経由で渡すSDPやICE Candidateを直接渡していた)

今回は、ローカルでシグナリングサーバ(WebSocket)を立て、このシーケンス通りに通信してみます。

シグナリングサーバの実装

WebRTCにおいてシグナリングサーバの実装は規定されていないため、実装方法は自由に選択できますが、今回はWebSocketを使います。

  • signaling.ts
import WebSocket from 'ws'

const wss = new WebSocket.Server({ port: 5001 })

wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    console.log('received: %s', message)
    wss.clients.forEach(function(client) {
      // NOTE: 自分以外の接続にブロードキャスト
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message)
      }
    })
  })
})

Websocketの実装には、ws というシンプルなWebSocket実装のモジュールを使用しています。

今回のシグナリングサーバでは、ルームの管理などは行わず、ある端末から送られてきたメッセージをそのまま別の端末へ流すだけのシンプルな実装になっています。

クライアントの実装

今回の実装では、自動接続などは行わず、offerSDPを送るPeerを明示的に指定してP2Pの接続を開始します。簡単な流れは以下の通りです。

  • ① Peer1の接続を開始する(ブラウザで画面を開く)
  • ② Peer2の接続を開始する(ブラウザで画面を開く)
  • ③ Peer1からofferSDPを送信してP2P接続を開始する
  • ④ Peer2がofferSDPを受け取ったらanswerSDPをPeer1へ送信する
  • ⑤ ICE Candidateを交換する

HTML

<!doctype html>
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>WebRTC Study</title>
</head>

<body>
    <div>
        <!-- NOTE: 自分の端末のストリームを表示する -->
        <video id="local-video" autoplay muted style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <div>
        <!-- NOTE: 相手端末から送られてきたストリームを表示する -->
        <video id="remote-video" autoplay muted style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <button id="btn-setup">① setup</button>
    <button id="btn-send-offer">② send offer</button>
    <button id="btn-hang-up">③ hangup</button>

    <script type='text/javascript' src='./index.js'></script>
</body>

</html>

今回は、自分の端末のストリームと相手からのストリームを分けて表示するために videoタグ を2つ用意します。(#local-video、#remote-video)

お互いの接続準備をするための setup ボタン。接続を開始するための send offer ボタン。接続を切るための hangup ボタンの3つを用意しています。

f:id:inon29:20200209120411p:plain

① 接続準備

  • index.ts
// NOTE: 音声と動画を両方送る
const MEDIA_CONFIG = { video: true, audio: true }
// NOTE: 送信側のデバイスから受け取ったStreamを遅れるようにグローバルにセット
let localStream: MediaStream | null = null
let pc: RTCPeerConnection
// NOTE:ページを開いたタイミングでWebSocketは接続しておく
const socket = new WebSocket('ws://localhost:5001')
socket.addEventListener('open', function() {
  console.log('Websocket open success')
})

/**
 * UserMediaからストリームを取得する(Video&Audio)
 */
async function setupUserMedia(): Promise<MediaStream | undefined> {
  try {
    const stream = await navigator.mediaDevices.getUserMedia(MEDIA_CONFIG)
    return stream
  } catch (err) {
    console.log('An error occured! ' + err)
  }
}

/**
 * ローカルvideoタグとStreamの接続
 * @param stream MediaStream
 */
function showLocalVideo(stream: MediaStream) {
  const video = document.getElementById('local-video') as HTMLVideoElement
  video.srcObject = stream
}

/**
 * シグナリングサーバ(WebSocket)の初期化
 */
function initializeSignalingServer() {
  // NOTE: サーバーからデータを受け取るリスナーのセット
  socket.addEventListener('message', function(e) {
    console.log('on socket message.', e.data)
    onMessage(e)
  })
}

/**
 * Peerのセットアップ
 */
async function handleSetup() {
  // 1. UserMediaにアクセスして音声と画像ストリームを取得
  const stream = await setupUserMedia()
  if (!stream) {
    console.error('Get Stream fail.')
    return
  }
  localStream = stream
  // 2. LocalのVideoタグにStreamを渡して再生
  showLocalVideo(stream)
  // 3. PeerConnectionを作成
  const pcConfig = { iceServers: [] }
  pc = new RTCPeerConnection(pcConfig)
  // 4. videoとaudioのTrackをpeerにセット
  const videoTracks = localStream.getVideoTracks()
  const audioTracks = localStream.getAudioTracks()
  if (videoTracks.length > 0) {
    console.log(`Using video device: ${videoTracks[0].label}`)
  }
  if (audioTracks.length > 0) {
    console.log(`Using audio device: ${audioTracks[0].label}`)
  }
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream as MediaStream)
  })
  // 5. Signalingサーバを初期化し、接続を受けれるようにする
  initializeSignalingServer()
  // 6. P2P接続後に受け取るTrackのコールバックをセット
  pc.addEventListener('track', onTrackEvent)
}

まずは、P2Pの準備の処理(setupボタンを押下時の処理)です。

ここでは、

  • 端末からのストリームの取得
  • RTCPeerConnectionの生成
  • シグナリングサーバの初期化
  • P2Pトラックのコールバックの初期化

などを行います。

なお、この実装では予めページを開いたタイミングでWebSocketとのコネクションを作成していますが、このへんは特に自由で大丈夫だと思います。

② offer SDPの送信

/**
 * シグナリングサーバをへ Offer Descriptionを送信する
 * @param description RTCSessionDescriptionInit
 */
function sendOfferSDP(description: RTCSessionDescriptionInit) {
  const data = {
    type: 'offer',
    sdp: description.sdp,
  }
  console.log('sendOfferSDP: ', JSON.stringify(data))
  socket.send(JSON.stringify(data))
}

/**
 * OfferSDPをシグナリングサーバへ送信し接続を開始する
 */
async function handleSendOffer() {
  // 1. OfferSDPの作成
  const offerDescription = await pc.createOffer()
  // 2. ローカルDescriptionのセット
  // NOTE: setLocalDescriptionは接続元(local)の情報をセットするAPI
  // NOTE: addTrack,setLocalDescriptionを実行してはじめてicecandidateの受信が始まる
  await pc.setLocalDescription(offerDescription)
  // 3. シグナリングサーバへOfferを送信
  sendOfferSDP(offerDescription)
  // 4. ICE Candidateを取得
  pc.addEventListener('icecandidate', e => {
    if (e.candidate) {
      console.log('icecandidate: ', e.candidate)
      sendCandidate(e.candidate)
    }
  })
}

次に、通信を開始するPeerがofferSDPを送信する処理(send offerボタンを押下された時の処理)です。

offerSDPは、createOffer()で作成できます。作成したSDPは、setLocalDescription()で自分のconnectionにセット後、シグナリングサーバを使って相手Peerへ送信します。

 const data = {
    type: 'offer',
    sdp: description.sdp,
 }

この時、シグナリングサーバへは上記のフォーマットでjsonを送信しています。typeは、受信メッセージをハンドリングするために定義しているものなので何でも構いませんが、sdpについては受信側で RTCSessionDescription というクラスインスタンスに変換する必要があるため、sdpインスタンスのsdpプロパティ(description.sdp)を渡しています。

ICE candidateを受け取るためのリスナーもこのタイミングでセットしています。

③ Answer SDPの送信

/**
 * シグナリングサーバからの受信処理
 * @param event MessageEvent
 */
function onMessage(event: MessageEvent) {
  const data = JSON.parse(event.data)
  switch (data.type) {
    case 'candidate':
      onCandidate(
        new RTCIceCandidate({ candidate: data.candidate, sdpMLineIndex: data.sdpMLineIndex, sdpMid: data.sdpMid })
      )
      break
    case 'offer':
      onOfferSDP(new RTCSessionDescription({ sdp: data.sdp, type: 'offer' }))
      break
    case 'answer':
      onAnswerSDP(new RTCSessionDescription({ sdp: data.sdp, type: 'answer' }))
      break
  }
}


/**
 * シグナリングサーバをへ Answer Descriptionを送信する
 * @param description RTCSessionDescriptionInit
 */
function sendAnswerSDP(description: RTCSessionDescriptionInit) {
  const data = {
    type: 'answer',
    sdp: description.sdp,
  }
  console.log('sendAnswerSDP: ', JSON.stringify(data))
  socket.send(JSON.stringify(data))
}

/**
 * Answer SDPを生成し、LocalDescriptionにセット後、シグナリングサーバへ送信する
 */
async function handleSendAwnser() {
  const answerDescription = await pc.createAnswer()
  await pc.setLocalDescription(answerDescription)
  sendAnswerSDP(answerDescription)
}

/**
 * シグナリングサーバから ICE Candidateを受信
 * @param candidate RTCIceCandidate
 */
async function onOfferSDP(offer: RTCSessionDescription) {
  console.log('onOfferSDP: ', offer)
  // 1. Remote Descriptionのセット
  pc.setRemoteDescription(offer)
  // 2. ICE Candidate の取得開始
  // NOTE offerをsetRemoteDescriptionするまでICE CandidateをAddできないためこのタイミングで取得する
  pc.addEventListener('icecandidate', e => {
    if (e.candidate) {
      console.log('icecandidate: ', e.candidate)
      sendCandidate(e.candidate)
    }
  })
  // 3. Anser SDP を送り返す
  await handleSendAwnser()
}

Peer2側でPeer1から送られてきたofferSDPを受け取ったときの処理(onOfferSDP)です。

送られたSDPは、setRemoteDescription()でconnectionにセットし、answer SDPをPeer1へ送り返します。

この時、Peer1と同じようにICE candidateを受け取るリスナーをセットするのですが、Peer2がsetRemoteDescription()を実行する前にICE candidateを受け取って追加していまうとエラーが発生するため、setRemoteDescription()の後でリスナーをセットしています。

④ Answer SDPの受信

/**
 * シグナリングサーバからAnswer SDPを受信
 * @param answer RTCSessionDescription
 */
async function onAnswerSDP(answer: RTCSessionDescription) {
  console.log('onAnswerSDP: ', answer)
  pc.setRemoteDescription(answer)
}

Peer1は送り返されたanswer SDPを setRemoteDescription() でセットします。

⑤ ICE candidate の交換/追加

/**
 * シグナリングサーバから ICE Candidateを受信
 * @param candidate RTCIceCandidate
 */
function onCandidate(candidate: RTCIceCandidate) {
  console.log('onCandidate: ', candidate)
  pc.addIceCandidate(candidate)
}

ICE candidateは各Peerで受信したタイミングでSDPと同様にシグナリングサーバ経由で相手のPeerへ送信します。

受信したICE candidateは、 addIceCandidate() でセットします。

動作確認

このサンプルのコードはここに置いてあります。

シグナリングサーバとクライアントを起動し、次の手順を実行します

  • 1.https://localhost:8080をブラウザで開く(peer1)
  • 2.① setup ボタンを押下
  • 3.https://localhost:8080をブラウザの別ダブで開く(peer2)
  • 4.① setup ボタンを押下
  • 5.どちらかの画面で ② send offer ボタンを押下

2つのvideoタグに動画が表示されていれば、正しく通信が行われています。

まとめ

今回は、WebSocketを使ったシグナリングサーバの最低限の実装を試してみました。これで基本的な接続の実装はできたのでSTUNサーバを使えば、別のネットワークでも同じように接続できるようになります。次回はそのへんを実装してみたいと思います。

関連記事

inon29.hateblo.jp

inon29.hateblo.jp

【WebRTC学習】② ローカルPCだけでP2Pの接続を実装する

概要

inon29.hateblo.jp

前回は、WebRTCの導入してブラウザからPCのカメラとマイクを起動するサンプルを実装してみました。

今回は、1台のローカルPCでP2Pの通信をつなげるサンプルを実装してみます。

この記事の目標

  • 1つの画面上で、P2Pのコネクションを繋げて片方のpeerから送信した動画と音声をもう片方のpeerで受け取り、htmlの <video> タグに表示する

WebRTCにおけるシグナリング

WebRTCで双方の端末をつなげるためには、RTCPeerConnection APIを使用します。

RTCPeerConnection APIは、簡単に記載すると双方の端末の接続の準備や管理を行うためのAPIです。

RTCPeerConnectionでは、双方の接続にSDP(Session Description Protocol)を使用します。これはお互いにやり取りするメディアのタイプやプロトコルの情報を扱うテキストベースのプロトコルです。

このSDPを使って、お互いに通信をするための準備を行います。

このSDPの記述をお互いに送り合い通信を開始するために、シグナリングサーバ を用意する必要があります。

WebRTCにおいてはこのシグナリングサーバの実装は意図的に提供していないため、独自で用意する必要があります。(今回のサンプルでは、ローカルPC1台で擬似的に接続を行うため、シグナリングサーバは使いません。)

次の図は、シグナリングサーバを使った、シグナリングのシーケンスです。

f:id:inon29:20200203210346p:plain

  • 1.まず接続するPeerが、createOffer() を使ってSDPを作成します。
  • 2.作成したSDPは setLocalDescription() で自分のPeerにセットしておきます。
  • 3.これをシグナリングサーバを経由して、Peer2へ渡します。
  • 4.offer SDPを受け取ったPeer2は、setRemoteDescription() で自分のPeerに送信元の情報をセットします
  • 5.送信先のPeerは、送信元へ createAnswer() を使ってSDPを作成します。
    • offer SDP同様Peerに setLocalDescription()でセットする
  • 6.これをシグナリングサーバを経由して、Peer1へ渡します。
  • 7.offer SDPを受け取ったPeer2は、setRemoteDescription() で自分のPeerに送信元の情報をセットします

ここまでで、お互いのSDPの交換が完了します。

しかし、これだけではP2Pの通信は開始しません。P2Pの接続を開始するためには、お互いのネットワーク上にパケットを送信できる必要があります。これにはネットワーク上のファイアウォールやNATを超える必要があり色々と大変です。

この辺を上手く超えるために ICE (Interactive Connectivity Establishment) という仕組みがあります。

これは、簡単に説明すると相手のピアと接続できる可能性がある接続経路(IPアドレスとポート)情報を自動で収集してくれる機能です。

ここで、取得した接続先候補が、上記のシーケンスにある ICE Candidate です。

この ICE Candidate をSDPと同じくシグナリングサーバ経由で相手Peerに送り、addIceCandidate() でお互いに追加することで、P2Pによる通信が開始されます。

※ ICE Candidateを受け取るためのコールバック icecandidate は、setRemoteDescription()で相手のSDPを受け取ると収集が開始されるようです。

実行環境

用意するコード

前回同様、index.htmlとindex.js(ts)を使って動かしてみます。

index.html

<!doctype html>
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>WebRTC Study</title>
</head>

<body>
    <div>
        <!-- 接続元の動画と音声を表示するためのVideoタグ -->
        <video id="local-video" autoplay muted style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <div>
        <!-- 接続先の動画と音声を表示するためのVideoタグ -->
        <video id="remote-video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <!-- 接続を開始するためのボタン -->
    <button id="btn-connect">connect</button>

    <script type='text/javascript' src='./index.js'></script>
</body>

</html>

今回は一つの画面で、接続元と接続先の2つの動画を再生するため、videoタグを2つ用意します。

index.ts

// NOTE: 音声と動画を両方送る
const MEDIA_CONFIG = { video: true, audio: true }

/**
 * UserMediaからストリームを取得する(Video&Audio)
 */
async function setupUserMedia(): Promise<MediaStream | undefined> {
  try {
    const stream = await navigator.mediaDevices.getUserMedia(MEDIA_CONFIG)
    return stream
  } catch (err) {
    console.log('An error occured! ' + err)
  }
}

/**
 * videoタグにStreamを流し表示する
 * @param stream MediaStream
 */
function showVideo(stream: MediaStream) {
  const video = document.getElementById('local-video') as HTMLVideoElement
  video.srcObject = stream
}

/**
 * 受信側がTrackを受け取ったときのコールバック
 * @param e RTCTrackEvent
 */
function onTrackEventForPc2(e: RTCTrackEvent) {
  // NOTE: このサンプルでは受信側の videoタグのstreamと接続して送信側の映像を表示する
  const remoteVideo = document.getElementById('remote-video') as HTMLVideoElement
  if (remoteVideo && remoteVideo.srcObject !== e.streams[0]) {
    remoteVideo.srcObject = e.streams[0]
    console.log('pc2 received remote stream', e.streams[0])
  }
}

/**
 * 取得したメディアデータをPC1からPC2に接続して流す処理
 * 流したデータはリモート用のvideoタグに出力する
 */
async function connectPeer() {
  // 1. Peer1からPeer2へ流すためのStreamを取得カメラ、マイクから取得
  const stream = await setupUserMedia()
  if (stream) {
    showVideo(stream)
  }

  // 2. peer1とpeer2の初期化
  // NOTE: このサンプルではpc1を `送信側` pc2を `受信側` として扱う
  const pcConfig = { iceServers: [] }
  const pc1 = new RTCPeerConnection(pcConfig)
  const pc2 = new RTCPeerConnection(pcConfig)
  // 3. pc2は受信側なので、Streamを受け取るコールバックする
  pc2.addEventListener('track', onTrackEventForPc2)

  // 4. pc1にMediaStreamを接続する
  if (stream) {
    const videoTracks = stream.getVideoTracks()
    const audioTracks = stream.getAudioTracks()
    // NOTE: 接続デバイスを表示する
    if (videoTracks.length > 0) {
      console.log(`Using video device: ${videoTracks[0].label}`)
    }
    if (audioTracks.length > 0) {
      console.log(`Using audio device: ${audioTracks[0].label}`)
    }
    stream.getTracks().forEach(track => {
      pc1.addTrack(track, stream as MediaStream)
    })
  }

  // 5. 接続元のSDPを作成
  const offerDescription = await pc1.createOffer()
  // 6. pc1に offerSDPをセット
  // NOTE: setLocalDescriptionは接続元情報をセットする
  await pc1.setLocalDescription(offerDescription)
  console.log('pc1: setLocalDescription')

  /* NOTE: 本来はofferSDPをシグナリングサーバ経由で pc2 へ送る */

  // 7. pc2に offerSDP をセットする
  await pc2.setRemoteDescription(offerDescription)
  console.log('pc2: setRemoteDescription')

  // 8. pc2は、ICE candidateの取得を開始
  // NOTE: setRemoteDescription が完了すると ICE Candidateを取得できる
  pc2.addEventListener('icecandidate', e => {
    console.log('pc2: icecandidate')
    if (e.candidate) {
      /* NOTE: 本来はIceCandidateをシグナリングサーバ経由で pc1 へ送る */
      // pc2のIceCandidateをセット
      pc1.addIceCandidate(e.candidate)
    }
  })
  console.log(`Answer from pc2:\n${offerDescription.sdp}`)
  // 8. 受信側から送信側へanserSDPを作成する
  const answerDescription = await pc2.createAnswer()
  // 9. pc2にanser SDPをセット
  await pc2.setLocalDescription(answerDescription)
  console.log('pc2: setLocalDescription')

  /* 本来はこの間にanserSDPをシグナリングサーバ経由で PC2->PC1 へ送る処理が入る */

  // 10. 送信側のリモート情報にanser SDPをセット
  await pc1.setRemoteDescription(answerDescription)
  console.log('pc1: setRemoteDescription')
  // 3. ICE Candidateの追加
  // NOTE: このサンプルでは、シグナリングサーバを利用しないため ICE Candidateを自前で追加する
  // 追加しないとお互いの通信先が見つからないためP2Pが開始されない
  // PC1の接続情報をPC2についか(逆も追加)
  pc1.addEventListener('icecandidate', e => {
    console.log('pc1: icecandidate')
    if (e.candidate) {
      pc2.addIceCandidate(e.candidate)
    }
  })

  /* これで双方のP2Pのシグナリング処理が完了し通信が始まる */
}

/* ============= Button Event Handler ============ */
const btnConnect = document.getElementById('btn-connect')
if (btnConnect) {
  btnConnect.addEventListener(
    'click',
    async function(e) {
      connectPeer()
      e.preventDefault()
    },
    false
  )
}

動作確認

f:id:inon29:20200203220239p:plain

connect ボタンを押下後に2つの画面に動画が再生されれば正しく接続出来ています。

今回は、ローカルPCでHTTPSでの接続を確認するために、mkcertというツールを使っています。

READMEにも簡単に記載してありますが、詳しくは当該ツールのgithubを参照ください。

実装コード

ここに置いてあります。

まとめ

今回は、1台のPC(一つのローカルネットワーク)でP2Pの接続を実装してみました。SDPやICE Candidateの交換にネットワークを介していないため問題なく接続できますが、本来であればここには別のネットワークを返すための仕組み(NATトラバーサル)等が必要です。次回はその辺りを触ってみようと思います。

参考記事

今までの関連リンク

inon29.hateblo.jp

【WebRTC学習】① ブラウザから動画/音声を取得する

概要

WebRTCについて調べることがあり、面白そうだなぁと思ったので勉強することにしました。

段階的に少しずつ理解を深めていきたいと思います。

この記事の目標

  • ブラウザからPCのカメラとマイクを起動して、htmlの <video> タグに表示する

WebRTCとは(超ざっくり)

WebRTC(Web Real-Time Communication)は、Web上でリアルタイム通信をするためのプロジェクトのこと。

様々なプロトコルを組み合わせた集合で、簡単にリアルタイム通信ができるようにAPIが提供されている。

僕が安易な言葉で説明するより、MDN株式会社時雨堂さんの記事を見るのが正確です。(とくに時雨堂さんの記事は日本語ですしめちゃくちゃ詳しいです。神。)

PCから動画音声を取得する

WebRTCのAPIは大きく分けて次の3つに分類されます。

  • MediaStream: 音声や動画をストリームで取得するためのAPI
  • RTCPeerConnection: 他デバイスとの通信を行うためのAPI
  • RTCDataChannel: 音声や動画以外のデータを扱うためのAPI

今回はこのMediaStreamのAPIを使って、ブラウザ経由でPCのカメラとマイクを起動し、HTMLの <video> タグにストリームを接続して動画を再生します。

実行環境

用意するコード

HTML

  • index.js
<!doctype html>
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>WebRTC Study</title>
</head>

<body>
    <div>
        <!-- 1. カメラとマイクから取得した動画と音声を流すためのvideoタグ -->
        <video id="local-video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <!-- 2. videoタグの再生を始めるためのボタン(jsで制御) -->
    <button id="btn-show-video">show video</button>

    <!-- 3. JSファイルの読み込み -->
    <script type='text/javascript' src='./index.js'></script>
</body>

</html>

まずは、動画と音声を再生するためのVideoタグを用意します。

Javascritp

  • index.ts
/**
 * 3. UserMediaからストリームを取得する(Video&Audio)
 */
async function setupUserMedia(): Promise<MediaStream | undefined> {
  try {
    // NOTE: Streamから取得できる情報を設定で指定できます(今回は動画と音声)
    const mediaConfig = { video: true, audio: true }
    const stream = await navigator.mediaDevices.getUserMedia(mediaConfig)
    // NOTE: videoTracksから端末がどのデバイスと接続しているかの情報が取得できます
    const videoTracks = stream.getVideoTracks()
    console.log(`Using video device: ${videoTracks[0].label}`)
    return stream
  } catch (err) {
    console.log('[error: setupUserMedia] ', err)
  }
}

/**
 * 4. videoタグにStreamを流し表示する
 * @param stream MediaStream
 */
function showVideo(stream: MediaStream) {
  const video = document.getElementById('local-video') as HTMLVideoElement
  // NOTE: videoElementのsrcObjectにstreamを接続すると動画が再生できるようになる
  video.srcObject = stream
}

/**
 * 2. ストリームを取得して、Videoタグを再生する
 */
async function handleShowVideo() {
  // start video capture
  const stream = await setupUserMedia()
  if (stream) {
    showVideo(stream)
  }
}

/**
 * 1.ボタンが押された時のハンドラー
 */
const btnShowVideo = document.getElementById('btn-show-video')
if (btnShowVideo) {
  btnShowVideo.addEventListener(
    'click',
    async function(e) {
      handleShowVideo()
      e.preventDefault()
    },
    false
  )
}

TypeScriptの処理は、以上で const stream = await navigator.mediaDevices.getUserMedia(mediaConfig) がWebRTCのMediaStreamを取得する処理です。

取得した、MediaStreamは、videoタグのsrcObject にセットすることで接続できます。

動作確認

f:id:inon29:20200128180435p:plain - show videoボタンを押下することでカメラからの動画とマイクの音声が再生されます

実装コード

ここに置いてあります。

参考記事

【雑記】【メモ】ARCore(google)のCloudAnchorについて調べてみた

はじめに

最近ARについて、軽く調べることがあり Googleが提供する CloudAnchorというARの位置情報を他端末と同期する機能についてサンプルコードをつくって動かしたときのメモです。 基本、公式ページに記載してある内容であり、実装コードなども特に載せていないので開発の参考にしようとされている方は期待しないでいただけると幸いです。

CloudAnchorとは

Googleが提供するARライブラリ(ARCore)で第三者とARの空間情報を共有するための仕組み。 ARの空間情報をGoogleのARCoreサーバへ送ることでGoogleが空間情報のIDを返してくれる。 デベロッパはそのIDをサーバ側で補完しておき、他端末と同期する。

主な流れ

(アップロード側)

  • ARCore(クライアント)が予め空間情報(カメラのテクスチャや方角、特徴点など)を監視している
  • なにかのトリガーでARアンカーのホスト命令を投げると、その30秒前からキャプチャしていた空間座標をARCoreのサーバへアップロードする
  • アップロードが正しくできた場合は、cloudAnchorId という識別IDをARCoreのサーバが返してくれる
  • サービス側は、この cloudAnchorId を自分たちのサーバで管理する

(復元側) - アップロード時と同様にARCoreが予め空間情報(カメラのテクスチャや方角、特徴点など)を監視している - 復元時は、復元要求前の30秒前の空間情報と cloudAnchorIdをARCoreサーバーへ問い合わせ、一致するアンカー情報があればそのアンカーの情報をクライアントに返す - クライアントはアンカー情報をもとにAR空間にアンカーを復元する

備考

  • アップロードされたアンカーは24時間しか保存されない
    • そのうち永続的に持てる仕組みを用意したいと書いてある。興味があればエンジニア募集中らしい。
  • ARCoreへアップロードしたビジュアルデータ自体は開発側から確認することはできない
  • なるべく広範囲からビジュアルデータを取得する
    • 直前30秒は、送信したいアンカーを中心に広範囲(いろんな角度)から画像をキャプチャすると精度が上がるらしい
  • 白い壁や暗い部屋は避ける

検証してみた感想

これと同じような仕組みで、ARKit(iOS)のWorldMapというものがあるが、WorldMapよりも大分仕組みがわかりやすく精度もいい気がした。 (ただし、クラウドと通信している分タイムラグがある気がする)

iOS、Unity、UnrealSDK提供しているので検証はしやすい。

気になること

  • どのくらいの空間まで同期できるのか?
    • サンプルコードを見るとRoomという概念をつかっているが、どの程度の広さで区切ればいいのか
  • リアルタイムで動くものの同期方法

参考リンク

【ReactNative】【参考】ReactNativeのバージョンアップ手順

ReactNativeのライブラリのバージョンアップはなかなかつらい作業です。
現在のプロダクトは、0.54.3を使用していて、0.59.0まで上げることを目標に作業しています。

その時のバージョンアップ作業を行うにあたり参考にした資料や手順を記載しています。

前提として、作業手順についてはあくまで私が個人的に考えて行っているものであり正規の手順に従っているかは保証できませんので、あくまで参考程度として考えていただいてもらえると幸いです。

公式のガイドページ

https://facebook.github.io/react-native/docs/upgrading

react-native-git-upgrade

  • react-native upgradeを安定させたツールとして react-native-git-upgrade というものがあるらしいです
  • これを使って0.1ずつ上げていくのが、一番安全なバージョンアップ方法とのことです
  • 一応公式でもこれ使えという案内があるので、とりあえずこれ使えば良さそう?
    • 今回はこのコマンドを使っています
    • インストール方法「npm install -g react-native-git-upgrade」

バージョンアップ手順(暫定)

色々と模索した結果一番効率が良さそうで、影響範囲を少しずつ確認できる方法を記載しています。

プロジェクト全体のアップデート

  • 1.CHANGELOGを確認して、上げる対象のバージョンにどんな変更が入っているかを把握する
    • 確実に上げる必要があるバージョンやbreaking changeの変更なども記載されているため必ず確認した方がよい
  • 2.https://pvinis.github.io/purge-web/ でdiffをなんとなく確認
    • 必要に応じて確認する
  • 3.react-native-git-upgradeコマンドでバージョンを上げる
    • % react-native-git-upgrade <対象バージョン>
  • 4.git statusでどのファイルが変更されていて、どのファイルがコンフリクトしているか確認

iOSのアップデート

  • 1.関連ファイルのコンフリクトを修正(.mファイル、.hファイル、.pbxprojファイルなど)
  • 2.iosのpodを更新して依存ライブラリを修正する
    • まず pod update を実行する
      • pod installするとReactNativeの関連ライブラリ関係でエラーになることが多いため一度updateする
      • ※ pod updateするとその他の関係ないライブラリもアップデートされるので注意
    • Podfile.lockを編集し、関係のある変更(ReactNative、yogaなど関連ライブラリ)のみ差分を更新し、その他はリバートする
    • Podsディレクトリを削除し、pod installを実行
      • これで必要なライブラリのみ更新される
  • 3.react-native run-ios を実行し、アプリがビルドできるか確認
  • 4.(ビルド時にエラーが発生している場合は、内容確認して修正する)

Androidのアップデート

  • 1.関連ファイルのコンフリクトを修正(.javaファイル、build.gradleファイルなど)
  • 2.Android Studioを起動し、gradle syncを行う
  • 3.androidのgradleビルドを確認してエラーがあったら修正する
  • 4.react-native run-android を実行し、アプリがビルドできるか確認

その他、動作確認

  • 1.iOSAndroidの実機(シミュレータ)でアプリの機能が正しく動いているか確認
  • 2.code_pushが正しく動くか確認
  • 3.jestが正しく動くか確認

感想/注意点

  • .pbxprojファイルあたりがコンフリクトする場合は、特に慎重に内容を確認した方がいいです
  • マイナーアップデートでもカジュアルにビルドに失敗するので辛いです
    • ReactNative自体のコンポーネントにバグが合ったりして、それが外部モジュール内(npmパッケージ)で発生していたりするので問題の切り分けが難しいです
    • npmパッケージ側のissueにめぼしいものがなければ、ReactNative自体で問題を探してみるといいかもしれません。
  • 確認時に発生した問題はメモとして列挙しておくとあとで解析が捗るかもしれません。

参考リンク

【ReactNative】React Nativeで開発を始めるにあたって

最近趣味でReactNativeを触っていましたが、タイミングよく業務でもReactNativeのアプリに関わることになりました。 趣味の範囲では、適当に触って入ればよかったのですが、流石に業務で使うとなると少し真面目に勉強しなくてはいけないなと思い始めています。

ReactNativeはまだまだ日本語のドキュメントも少なく、アプリ開発をする上で決してメジャーな選択ではないと思いますが、効率的な開発を行なう上では有力な選択肢ではないかと思います。
色々とハマりどころはあると思いますが、少しずつ知見を溜めていけたら良いなと思います。

[ReactNativeを使った開発を行う上での選択肢]

1. Expo

Expoとは、ReactNativeでのアプリ開発を支援するためのプラットフォーム(SDK)です。
※ Expoの詳細については、色々な方が説明しているかと思うので割愛します。
特徴としてはExpoの機能で完結するアプリの場合、アプリの作成はJavascriptのみで完結します。
iOSAndroidでは、ビルドや開発にXCode(iOS)、AndroidStudio(Android)を使うのが一般的ですがExpoを使うとビルド作業自体もExpoサービス上で行ってくれます。
デメリットとしては、上記の通り基本的にiOSAndroidのネイティブコードを触ることができないためExpoSDKでできないことがやりたくなった場合は別途ejectを行い、ネイティブアプリの書き出しを行います。

プロジェクトの始め方は、ここを参照。

※ このすべてをExpoサービス上で行う開発をmanaged-workflowと呼ぶみたいです。

2. React Native

基本的なReactNativeでの開発で、iOSAndroidソースコード + ReactNativeライブラリを使って開発を行います。
一通りのセットアップは、react-native CLIツールが提供してくれており、その手順に従うとReactNativeでの開発が可能な状態でiOSAndroidのプロジェクトを作成することができます。
各アプリのビルドは、iOSAndroidの本来のビルドツールで行う必要があります。
Expoには提供されていないライブラリを使用したい場合や、Expoではできないようなネイティブコードに特化した機能をカスタマイズしたい場合はこの方法で開発することになります。

プロジェクトの始め方は、ここを参照。

3. React Native + Expo SDK

1 と 2をあわせたような方法です。
Expoから作成したライブラリをejectするときの選択肢の一つで、ExpoSDKで使用できるツール郡はそのまま使い続けるが、ビルドなどのワークフローは独自に行うという方法になります。
Expoプロジェクトを expo eject するときにXXXを選択した場合、ExpoSDKを組み込んだ状態のiOSAndroidのプロジェクトが出力されます。

? How would you like to eject your app?
  Read more: https://docs.expo.io/versions/latest/expokit/eject/ (Use arrow keys)
❯ React Native: I'd like a regular React Native project.
  ExpoKit: I'll create or log in with an Expo account to use React Native and the Expo SDK.
  Cancel: I'll continue with my current project structure.
  • 上記の選択肢でExpoKitを選んだ状態

この方法の利点は、ExpoSDKの使える機能を利用しつつ、Expoだけではできない機能のライブラリなどを組み込むことができるようになることです。

※ このExpoの機能を利用するフローをbare-workflowと呼ぶみたいです。

[どの方法を選択するか?]

業務で関わっているアプリは、2のReactNativeのみで作成されており、個人の趣味で作成したアプリは3のExpoSDKを組み込んだ状態で作成しました。
個人的には、3のExpoSDKを活用した方法が良いかと思っていますが、色々と判断の上自分たちにあった方法を選択するのが良いと思います。
3を選んで感じたことをいくつか書いていきます。
※ あくまで個人的な感想です。
(目的が明確であり、シンプルさと速度を重視するのであれば、1のExpoのみで開発するのが一番簡単だと思います。)

[良かったこと] OTA Updates(code push)

ReactNativeで開発するメリットの一つにOTA Updates(code push)があります。
これは、アプリストア(AppStore、GooglePlayStore)を介さずに、直接アプリをアップデートできる仕組みです。
簡単に説明すると特定の契機(アプリ起動時など)に最新のJSファイルをサーバからダウンロードして、現在動いているものと差し替えるというものです。
ReactNativeでこれを実現するためには、Microsoftが開発しているcode pushライブラリを使うのが一般的ですが、Expoでは標準で組み込まれています。
使い方も非常にシンプルで、Expoに機能がまとめられていることはメリットの一つではないかと思います。

[良かったこと] シミュレーターでの開発

ReactNativeでシミュレーターを使った開発を行なうときは、 iOSであれば react-native run-iosAndroidreact-native run-android を実行しますがExpoは expo start のみで両方のOSが繋がります。
また、詳しく検証してないので実際のところわかりませんが、シミュレータ自体の動きもExpoのほうが安定しているように感じます。(個人的な感想です)
ベースの技術は同じような気がするのですが、後発のExpoのほうが色々とうまくできているというのはあるのかもしれません。

[悪かったこと] ExpoSDK内で使用されているライブラリと別ライブラリで競合が発生した

具体的には、AndroidのExpoSDK内で使用しているokhttpという通信ライブラリがあるバージョンからExpo独自パッケージに変わり、依存しているその他のライブラリと競合が発生してビルドで苦労した。という内容です。
Androidではあるあるといえばあるあるなのですが、ExpoSDK自体が決して小さいライブラリではないのでこういった依存関係での問題はそれなりにあるのかなと思います。

[React Nativeでアプリを作る上で注意すること]

ゼロからアプリを作る上でいくつかハマったり、感じたことがあったので記載します。

iOSAndroidは、必ず同時に動作確認をする

僕の場合、まずはiOSだけで動作確認して開発をしており、「ある程度動いたらAndroidも確認するか」ぐらいの気持ちでいました。
そしてある程度作り上げた時点で、Androidでビルドしてみたいのですが全く動きませんでした。(ビルドがまず全然通らなかった)
ReactNativeは、クロスプラットフォームツールのためよほどのことがない限りは、外部のライブラリを比較的多く利用すると思います。
そのため、ビルド時の依存関係で問題が発生することは多々あります。
これはReactNativeに限った話してはないのですが、依存関係の問題は複数が同時に発生しているほど読み解くのが困難になり非常に辛いです。
ですので、iOSAndroidの動作確認は常に同時に行っておくべきでした。(ネイティブライブラリを利用したタイミングでは特に)
また、iOSAndroidでは描画システムが異なるため、片方できれいに配置されていても、もう片方では位置がずれているようなこともしばしばあります。
そういった点も踏まえると、常に両プラットフォームを確認しておくのが結果的に効率的かと思いました。

やりたいことが実現できるライブラリがないと開発コストが上がる

ReactNativeは、基本的はJSを使った開発になるのですが、各OS固有の機能やモバイルデバイス固有の機能を使う際にネイティブ層を意識した実装が必要になります。
(例えばカメラやGPS、Push通知など)
これらの多くの機能は、ReactNative自身やExpoが提供してくれてはいますが、その中に使いたい機能が存在しなかった場合は以下の選択肢になります。

  • ①.ネイティブ層(iOS,Android)とのブリッジを自分で実装し、各ネイティブ言語で使いたい機能を実装する
  • ②.3rdパーティ製のライブラリを探して、組み込む

①を選択する場合は、各ネイティブOSの実装知識が必要となるため多くの場合は②をまずは選択するかと思います。

そのため、ReactNativeを使った開発を選択する場合、「ライブラリをさがす作業」に多くの時間を使いがちになるなと感じています。
(今どきのモバイル開発はかなりのエコシステム化が進んでいて各ネイティブで開発をしていても基本的に誰が便利なライブラリを公開してくれていますが、ReactNativeの場合はモバイルエンジニア以外の人が始めることもあるので特にだと思います。)

それでも、どうしても機能が実現できなかった場合は①を選択するしかありません。
僕の場合は、iOSAndroidも一応実装できるのですが上記のようなことが起きたときに「これiOS(Android)ならそんなに難しくないのにReactNative上で両OSを意識して実装するのめんどくさいなぁ」と思うときが時々あります。


引き続き何かあれば、追記していきます。