Multipeer ConnectivityによるiPad間通信で早押しボタンを作った

#概要
2チーム対抗クイズイベントのために、早押しボタンのiOSアプリを簡単に実装しました。
二台のiPadを用いて、各チームがiPadを早押しボタンとして使います。

iOS端末間のP2P通信を可能にするフレームワーク、Multipeer Connectivityを用いています。
回答ボタンを押した時の時間を端末間で通信し、正確なタイミング判定を実現しました。

画面に出ている数字はスコアカウンターです。

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://twitter.com/TOS?ref_src=twsrc%5Etfw"></a> <a href="https://t.co/GOW5ZC2zNw">pic.twitter.com/GOW5ZC2zNw</a></p>&mdash; Bluemo (@blu3mo) <a href="https://twitter.com/blu3mo/status/1234375477080420352?ref_src=twsrc%5Etfw">March 2, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

#仕組み
Multipeer ConnectivityはBluetooth等を用いて通信しています。
通信のメッセージが届くのにラグがあるため、二つの起こりうるパターンが考えられます。
それぞれのパターンで先に押した方を判定するために、以下のような通信が行われます。

####1. 一方がもう一方より圧倒的に早く押した場合
（以下の「勝ち」「負け」は、どちらが先に押したかの判定を意味します。）
早く押された方の端末をAとした場合、以下の図のようになります。
1. Aは、Bにボタンが押された時の時間を伝えます。
2. Bの方はボタンがまだ押されていないので、Bは負けを画面上に表示します。
3. しかし、この時点ではA端末側は自分が勝ったかどうかは分かりません。そのため、Bは負けを表示した後に、Aに「Bが負けたこと」を伝えます。
4. Aは、そのメッセージを受け取ると自身が勝ったことが分かり、画面上に勝ちを表示します。
<img width="704" alt="スクリーンショット 2020-03-02 14.00.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318210/6eae89d2-fd6e-8049-fc14-252253a91b90.png">

####2. 二チームのタイミングが僅差だった場合
Aのメッセージが発信されてから届くまでの間に、Bが押された場合は、以下のようになります。
1. Aが押されると、先ほどと同様にBに押された時の時間を送信します。
2. それをBが受け取る前にBも押され、Bが押された時の時間をAに送信します。
3. その後、BにAからの通信が届きます。Bは、自身が押された時間とAが押された時間を比較します。すると、Bは自身が負けたことが分かり、画面上に負けを表示します。
4. 同様に、AにBからの通信が届くと、時間を比較することで自身が勝ったことが分かり、画面上に勝ちを表示します。
<img width="700" alt="スクリーンショット 2020-03-02 14.27.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318210/5520bde3-e1c2-f2a2-d30f-3b68bbb0085f.png">

#実装
全体はGithubに置いておきます。
https://github.com/blu3mo/QuizButton
以下は、`MCService.swift`の一部です。

早押しボタンが押された時の送信/受信まわりの実装は以下のようになっています。
メッセージはData型にエンコードしてやりとりしています。

```swift:MCService.swift
extension MCService: MCSessionDelegate {
    
    func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        // Debug用
        switch state {
        case .connected:
            print("connected: \(peerID.displayName)")
        case .connecting:
            print("connecting: \(peerID.displayName)")
        case .notConnected:
            print("not connected: \(peerID.displayName)")
            //self.state = .open
        }
    }
    
    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        let decodedString = String(data: data, encoding: .utf8)
        
        var message: Message
        switch decodedString {
        case "youwin":
            message = .youWin
        default: //date
            let date = dateFromString(string: decodedString!, format: dateStringFormat)
            message = .triedDate(date)
        }
        
        model.reactMessage(message: message)
    }
    
    func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
        
    }
    
    func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
        
    }
    
    func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
        
    }
    
}

extension MCService: JudgeModelConnectionOutput {
    
    var isConnected: Bool {
        get {
            return (mcSession.connectedPeers.count == 0)
        }
    }
    
    func sendMessage(message: Message) {
        var sendingString: String = ""
        
        switch message {
        case .youWin:
            sendingString = "youwin"
        case .triedDate(let date):
            sendingString = stringFromDate(date: date, format: dateStringFormat)
        }
            
        do {
            try mcSession.send(sendingString.data(using: .utf8)!, toPeers: mcSession.connectedPeers, with: .reliable)
        } catch {
            print("sending error") //TODO
        }
    }
    
    func advertise() {
        let mcAdvertiserAssistant = MCAdvertiserAssistant(serviceType: serviceTypeId, discoveryInfo: nil, session: self.mcSession)
        mcAdvertiserAssistant.start()
    }
    
    func getBrowser() -> UIViewController {
        let mcBrowser = MCBrowserViewController(serviceType: serviceTypeId, session: mcSession)
        mcBrowser.delegate = self
        mcBrowser.maximumNumberOfPeers = 1
        mcBrowser.minimumNumberOfPeers = 1
        
        return mcBrowser
    }

}
```

詳しい仕様については、[Multipeer Connectivityの公式Documentation](https://developer.apple.com/documentation/multipeerconnectivity)を確認してください。


#運用
実際のイベントでは2台のiPadを観客に向けて設置し、マウスをそれぞれに接続しました。
（最近iPadでも[マウスが使えるように](https://bunshun.jp/articles/-/15608)なりました）
回答者がマウスをクリックすると、ボタンが押された判定となります。



#備考
・Qiita初投稿&素人なので、改善点等あったら教えてください🙏
・今回は二端末間の通信となっていますが、接続数の制限を解除すれば同
じ仕組みで三台以上も問題ないはずです。
・Multipeer Connectivityについては、不安定であるという意見も存在します。
　[MultipeerConnectivityって使えるの？ by @YearCentury](https://qiita.com/YearCentury/items/6753d82bf665f936f77e)


#参考
iOS Swift Tutorial: Transfer Data with the Multipeer Connectivity Framework 
https://www.youtube.com/watch?v=H5c4vo6p5Fg

ActionSheet Popover on iPad in Swift
https://medium.com/@nickmeehan/actionsheet-popover-on-ipad-in-swift-5768dfa82094