Multipeer ConnectivityでのP2P通信（Swift）

![Xcode-12](https://img.shields.io/badge/Xcode-12-brightgreen) ![Swift-5.3](https://img.shields.io/badge/Swift-5.3-brightgreen) ![iOS-14](https://img.shields.io/badge/iOS-14-brightgreen)

## はじめに
iOS の端末間で通信とかしたいなと思い [Multipeer Connectivity](https://developer.apple.com/documentation/multipeerconnectivity) について調べました。

ソース :point_right: [GitHub](https://github.com/adventam10/MultipeerConnectivitySample)

## Multipeer Connectivityとは
Multipeer Connectivity は Wi-Fi と Bluetooth を使って iOS 端末間の近距離通信（ピア・ツー・ピア）ができるライブラリです（最大８個まで接続できる）。 
Wi-Fi が OFF でも Bluetooth で接続できるのかと思いましたが、OFF だとアドバタイザーが見えないらしく ON にする必要があるようです（同一 Wi-Fi でなくても OK）。

## 接続の概要
接続の流れは下記のようになっています。

1. [MCPeerID](https://developer.apple.com/documentation/multipeerconnectivity/mcpeerid) を生成する。  
端末を識別する一意のもの
1. [MCSession](https://developer.apple.com/documentation/multipeerconnectivity/mcsession) を生成する。  
接続を管理するやつ
1. [MCNearbyServiceAdvertiser](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyserviceadvertiser) もしくは [MCAdvertiserAssistant](https://developer.apple.com/documentation/multipeerconnectivity/mcadvertiserassistant) でアドバタイズする。  
他の端末から検知できるようにする
1. [MCNearbyServiceBrowser](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyservicebrowser) もしくは [MCBrowserViewController](https://developer.apple.com/documentation/multipeerconnectivity/mcbrowserviewcontroller) で接続できる端末を探す。  
1. 発見した端末に対して招待を送る。
1. 招待を受けた端末側で招待を許可すると接続が確立する。
1. [MCSession](https://developer.apple.com/documentation/multipeerconnectivity/mcsession) でデータの送受信を行う。  
データ受信は [MCSessionDelegate](https://developer.apple.com/documentation/multipeerconnectivity/mcsessiondelegate) を使う

## 各クラスの使い方
### MCPeerID
[MCPeerID](https://developer.apple.com/documentation/multipeerconnectivity/mcpeerid)

識別用の一意のもの。下記イニシャライザで `displyaName`（63 B 以下で UTF-8）を設定して生成する。
同名のものを設定しても別々の ID が生成される。空文字など異常値が設定されるとクラッシュする。

```swift
init(displayName: String)
```

アドバタイズや探索のたびに生成するのではなく同じものを利用するようにした方がよい（仮に advertiser と borowser に別の ID を設定すると自身が検知されてしまう）。

### MCSession
[MCSession](https://developer.apple.com/documentation/multipeerconnectivity/mcsession)

下記どちらかで生成する。

```swift
// identity: 証明書関連で使う（ちょっとわからない。。。）
// encryptionPreference: 通信を暗号化するかどうか
init(peer myPeerID: MCPeerID, securityIdentity identity: [Any]?, encryptionPreference: MCEncryptionPreference)

// encryptionPreferenceはrequiredになる
init(peer myPeerID: MCPeerID)
```

`encryptionPreference` は下記がある。

* none: 暗号化しない
* required: 暗号化する
* optional: すべての端末が `required` か `optional` であれば暗号化する。  
`none` があれば暗号化しない

データ送信は下記を使う。

```swift
// Data型の送信
// mode: reliableなら送信順の保証、unreliableなら送信順は保証されないが即時送信
func send(_ data: Data, toPeers peerIDs: [MCPeerID], with mode: MCSessionSendDataMode) throws

// ファイル送信
// completionHandler: errorは成功時はnil
// 戻り値はキャンセルとかに使う
func sendResource(at resourceURL: URL, withName resourceName: String, toPeer peerID: MCPeerID, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) -> Progress?

// ストリーム？（ちょっとわからない。。。）
func startStream(withName streamName: String, toPeer peerID: MCPeerID) throws -> OutputStream
```

接続関連。

```swift
// 接続済み端末一覧
var connectedPeers: [MCPeerID]

// 接続を切断する
func disconnect()

// 手動接続の場合に利用する？（ちょっとわからない。。。）
func nearbyConnectionData(forPeer peerID: MCPeerID, withCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void)
func connectPeer(_ peerID: MCPeerID, withNearbyConnectionData data: Data)
func cancelConnectPeer(_ peerID: MCPeerID)
```

デリゲート（[MCSessionDelegate](https://developer.apple.com/documentation/multipeerconnectivity/mcsessiondelegate)）。

データ受信時などに UI を更新する場合はメインスレッドで処理するように注意。

```swift
// 接続状態変更通知（required）
// state: connected, notConnected, connectingがある
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState)

// データ受信（required）
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID)

// ファイル受信開始（required）
func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress)

// ファイル受信終了（required）
// localURL:一時ファイルのURLreturnするまでに保存とかしないと消える
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?)

// バイトストリーム接続（required）
func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID)

// 証明書関連のやつ（optional）
func session(MCSession, didReceiveCertificate: [Any]?, fromPeer: MCPeerID, certificateHandler: (Bool) -> Void)
```

### MCNearbyServiceAdvertiser
[MCNearbyServiceAdvertiser](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyserviceadvertiser)

下記で生成する。`serviceType` は 1〜15 文字で、ASCII 小文字、数字、ハイフンが使えます（--のように連続したハイフンは不可）。空文字など異常値が設定されるとクラッシュする。

```swift
// discoveryInfo: アドバタイズ時に付加する情報
init(peer: MCPeerID, discoveryInfo: [String : String]?, serviceType: String)
```

アドバタイズ。

```swift
/// アドバタイズ開始
func startAdvertisingPeer()
/// アドバタイズ停止
func stopAdvertisingPeer()
```

デリゲート（[MCNearbyServiceAdvertiserDelegate](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyserviceadvertiserdelegate)）。

```swift
// 招待を受けたときに呼ばれる（required）
// context: 招待相手の情報。ブラウザのinvitePeerで設定した値
// invitationHandler: 招待を許可するかどうか設定
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void)

// アドバタイズ失敗時に呼ばれる(optional)
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error)
```

### MCAdvertiserAssistant
[原因はよくわかりませんが許可アラートが表示されず使えないようです](https://www.hackingwithswift.com/forums/100-days-of-swift/project-25-multipeer-connectivity-not-working-due-to-new-security-setting/1800)とりあえず MCNearbyServiceAdvertiser を使えば接続できました。

[MCAdvertiserAssistant](https://developer.apple.com/documentation/multipeerconnectivity/mcadvertiserassistant)

下記で生成する。

```swift
// discoveryInfo: アドバタイズ時に付加する情報
init(serviceType: String, discoveryInfo: [String : String]?, session: MCSession)
```

アドバタイズ。

```swift
// アドバタイズ開始
func start()
// アドバタイズ停止
func stop()
```

デリゲート（[MCAdvertiserAssistantDelegate](https://developer.apple.com/documentation/multipeerconnectivity/mcadvertiserassistantdelegate)）。

```swift
// optional
func advertiserAssistantDidDismissInvitation(_ advertiserAssistant: MCAdvertiserAssistant)

// optional
func advertiserAssistantWillPresentInvitation(_ advertiserAssistant: MCAdvertiserAssistant)
```

### MCNearbyServiceBrowser
[MCNearbyServiceBrowser](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyservicebrowser)

細かい設定がいらない場合は MCBrowserViewController を使う方が楽。

下記で生成する。`serviceType` に設定できる値はアドバタイズで設定できるものと同様。

```swift
init(peer: MCPeerID, serviceType: String)
```

検索。

```swift
// 検索開始
func startBrowsingForPeers()
// 検索停止
func stopBrowsingForPeers()
```

招待。

```swift
// context: 招待相手に渡す情報。これで相手側で招待を受けるか判断したりする
// timeout: 秒単位で0以下を設定するとデフォルト値の30秒になる
func invitePeer(_ peerID: MCPeerID, to session: MCSession, withContext context: Data?, timeout: TimeInterval)
```

デリゲート（[MCNearbyServiceBrowserDelegate](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyservicebrowserdelegate)）。

```swift
// 検索開始失敗に呼ばれる（optional）
func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error)

// 端末を発見したときに呼ばれる（required）
// info: アドバタイズで設定されているのdiscoveryInfo
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?)

// 発見した端末ロスト時に呼ばれる（required）
// 対象の招待不可
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID)
```

### MCBrowserViewController
[MCBrowserViewController](https://developer.apple.com/documentation/multipeerconnectivity/mcbrowserviewcontroller)

簡易に検索と画面表示をおこなってくれる `ViewController`。表示するときは `push` ではなく `present` を使う（Cancel, Done のナビゲーションバーがあるので `push` するとバーの下にバーが表示される）。iPad の場合はフルスクリーンではなく `pageSheet`？表示になるようです。 

```swift
init(serviceType: String, session: MCSession)
init(browser: MCNearbyServiceBrowser, session: MCSession)

// 接続端末の最大値（デフォルト値と最大値は8）
var maximumNumberOfPeers: Int

// 接続端末の最小値（デフォルト値と最小値は2）
var minimumNumberOfPeers: Int
```

デリゲート（[MCBrowserViewControllerDelegate](https://developer.apple.com/documentation/multipeerconnectivity/mcbrowserviewcontrollerdelegate)）。

```swift
// 発見した端末を表示するかどうか設定する（optional）
// info: アドバタイズで設定されているのdiscoveryInfo
func browserViewController(_ browserViewController: MCBrowserViewController, shouldPresentNearbyPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) -> Bool

// Doneボタン押下時に呼ばれる（required）
func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController)

// Cancelボタン押下時に呼ばれる（required）
func browserViewControllerWasCancelled(_ browserViewController: MCBrowserViewController)
```

こんな感じです。発見したものが一覧表示されタップすると招待を送り相手側で許可されると Connected になります。

![browser](https://user-images.githubusercontent.com/34936885/108596398-181ae000-73c8-11eb-93bd-c9646d3ebbab.gif)

## 簡易実装
*iOS14 からは Info.plist に追記が必要です！* 
参考：[iOS 14からMultipeer ConnectivityがErrorCode -72008で繋がらなくなったときの解決方法](https://qiita.com/cokaholic/items/1d74e4441a7868c6abf6)

実機で動かしてなかったんで気づかなかったのですが iOS14 からは下記のように Info.plist に追記が必要です（シミュレータだと勝手にアラートが出て接続できてた。。。）。

```
   <key>NSLocalNetworkUsageDescription</key>
	<string>ローカルネットワークを使う理由</string>
    <key>NSBonjourServices</key>
    <array>
        <string>_sample-chat._tcp</string>
        <string>_sample-chat._udp</string>
    </array>
```

`NSBonjourServices` に設定する値はアドバタイズや検索のときに使う `serviceType` のことで下記のような形式です。

```
_[serviceType]._tcp
```

下記のようなチャットアプリを作成します。

![chat](https://user-images.githubusercontent.com/34936885/108596386-0a655a80-73c8-11eb-9912-c0544482db09.gif)

こんな感じです。

```swift
import UIKit
import MultipeerConnectivity

final class ChatTableViewController: UITableViewController {

    private var messages = [String]()

    private let serviceType = "sample-chat"
    private var session: MCSession!
    private var advertiser: MCNearbyServiceAdvertiser!
    private var browser: MCNearbyServiceBrowser!

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.rightBarButtonItem = .init(title: "送信", style: .done, target: self, action: #selector(sendMessage(_:)))
        navigationItem.rightBarButtonItem?.isEnabled = false

        let peerID = MCPeerID(displayName: UIDevice.current.name)
        session = MCSession(peer: peerID)
        session.delegate = self

        advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: nil, serviceType: serviceType)
        advertiser.delegate = self
        advertiser.startAdvertisingPeer()

        browser = MCNearbyServiceBrowser(peer: peerID, serviceType: serviceType)
        browser.delegate = self
        browser.startBrowsingForPeers()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        // たまに切れない時があるのでここで切断
        browser.stopBrowsingForPeers()
        advertiser.stopAdvertisingPeer()
        session.disconnect()
    }

    @objc private func sendMessage(_ sender: Any) {
        let message = "\(session.myPeerID.displayName)からのメッセージ"
        do {
            try session.send(message.data(using: .utf8)!, toPeers: session.connectedPeers, with: .reliable)
            addMessage(message)
        } catch let error {
            print(error.localizedDescription)
        }
    }

    private func addMessage(_ message: String) {
        messages.append(message)
        tableView.beginUpdates()
        let indexPath = IndexPath(row: messages.count - 1, section: 0)
        tableView.insertRows(at: [indexPath], with: .automatic)
        tableView.endUpdates()
        tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
    }
}

extension ChatTableViewController {

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = messages[indexPath.row]
        return cell
    }
}

extension ChatTableViewController: MCSessionDelegate {

    func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        let message: String
        switch state {
        case .connected:
            message = "\(peerID.displayName)が接続されました"
        case .connecting:
            message = "\(peerID.displayName)が接続中です"
        case .notConnected:
            message = "\(peerID.displayName)が切断されました"
        @unknown default:
            message = "\(peerID.displayName)が想定外の状態です"
        }
        DispatchQueue.main.async {
            self.addMessage(message)
            self.navigationItem.rightBarButtonItem?.isEnabled = !self.session.connectedPeers.isEmpty
        }
    }

    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        guard let message = String(data: data, encoding: .utf8) else {
            return
        }
        DispatchQueue.main.async {
            self.addMessage(message)
        }
    }

    func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
        assertionFailure("非対応")
    }

    func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
        assertionFailure("非対応")
    }
d
    func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
        assertionFailure("非対応")
    }
}

extension ChatTableViewController: MCNearbyServiceAdvertiserDelegate {

    func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        invitationHandler(true, session)
    }
}

extension ChatTableViewController: MCNearbyServiceBrowserDelegate {

    func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
        guard let session = session else {
            return
        }
        browser.invitePeer(peerID, to: session, withContext: nil, timeout: 0)
    }

    func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
    }
}
```

１対１でやるだけなら接続した時点でアドバタイズと探索を停止させるべきかもしれない:thinking:

### おまけ
`sendResource` を使えば下記のような画像の送受信もできます:v:

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">画像の送受信できた🙌 <a href="https://t.co/aTIdNVzQU7">pic.twitter.com/aTIdNVzQU7</a></p>&mdash; am10 (@am103141592) <a href="https://twitter.com/am103141592/status/1363079591196127233?ref_src=twsrc%5Etfw">February 20, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

詳細はソースをどうぞ :point_right: [GitHub](https://github.com/adventam10/MultipeerConnectivitySample)

## その他気になった点
### アプリがバックグラウンドにいったとき
アプリがバックグラウンドになると、アドバタイズと検索を停止してセッションをすべて切断する模様。フォアグラウンドに復帰すると、自動的にアドバタイズと検索を再開するが、セッションの復帰はこちらでやる必要があるようです。

### 接続時のエラー
接続時に下記のようなログが表示されるが接続はできてる模様。

> [GCKSession] Not in connected state, so giving up for participant [7AE9D215] on channel [0].

### Wi-Fi必須？
Bluetooth で接続できるから端末の Wi-Fi 設定を OFF にしても接続できるのかと思いましたが  Wi-Fi は ON にする必要があるようです（ソースは見つかってないです）。Wi-Fi の接続先は同一でなくてもいいようです。

### Codableによるデータのやりとり
iOS12 と iOS13, 14 間で `Codable` のデータのやりとりができませんでした:frowning2: 
下記のようにやってみると受信のところでエラーになりました（iOS13, 14 の場合は OK）。

```swift
enum Hoge: Int, Codable {
  case piyo
}

// 送信
func sendHoge(_ hoge: Hoge) {
    do {
        let data = try JSONEncoder().encode(hoge)
        try session.send(data, toPeers: session.connectedPeers, with: .reliable)
    } catch let error {
        print(error.localizedDescription)
    }
}

// 受信
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
    do {
        let hoge = try JSONDecoder().decode(Hoge.self, from: data)
    } catch let error {
        // ここにはいる
        // The given data was not valid JSON.
        print(error.localizedDescription)
    }
}
```

### TCP/UDP
iOS14 以降は Info.plist に追記が必要で `_[serviceType]._tcp` だけ追記していたんですが `_[serviceType]._udp` も必要かも？なくても接続できましたがいるのかも？？（ちょっとよくわかってない。。。）

### iOS14以降でたまに接続できない
iOS14 以降でたまに `MCBrowserViewController` で「接続中」の後「辞退」になることがありました。手元で再現せずストアのレビューで発生しリジェクトされました:frowning2:ちょっと原因はわからないです。。。

同じような人いました :eyes:
[MultipeerConnectivity in SwiftUI?](https://developer.apple.com/forums/thread/654812) 

### iOS14以降でローカルネットワークの許可アラートが出ない
iOS14以降で `NSLocalNetworkUsageDescription` を追加していても許可アラートが出ないことがありました。でもなぜか接続はできました:see_no_evil:原因不明。。。

これかな:eyes: 
[NSLocalNetworkUsageDescription not displayed if provided via InfoPlist.string](https://developer.apple.com/forums/thread/655375)

手元では iOS14.1 は表示できて iOS14.4 は表示されませんでした。

## おわりに
さくっと端末間の接続ができるのはすばらしい:clap:

ちょっと調べてると過去 OS バージョンではバグが多そうでしたが最近は安定してるのかどうなんだろう:innocent: 
iOS 12.4.9（iPod touch 第６世代）、iOS 13.7（iPad 第５世代）、iOS 14.0（シミュレータ）で接続できるのは確認しました。

手動接続とストリームに関してはよくわかりませんでした！

## 参考
* [Multipeer Connectivityドキュメント](https://developer.apple.com/documentation/multipeerconnectivity)
* [[iOS 7] P2P 通信を手軽に実現する Multipeer Connectivity Framework を使ってみる](https://dev.classmethod.jp/references/ios-multipeer-apiusage/)
* [MultipeerConnectivityでP2P通信](https://mike-neko.github.io/blog/multipeer/)
* [MultipeerConnectivityって使えるかも？](https://qiita.com/YearCentury/items/6753d82bf665f936f77e)
* [Project 25 - Multipeer Connectivity not working due to new security setting??](https://www.hackingwithswift.com/forums/100-days-of-swift/project-25-multipeer-connectivity-not-working-due-to-new-security-setting/1800)
* [iOS 14からMultipeer ConnectivityがErrorCode -72008で繋がらなくなったときの解決方法](https://qiita.com/cokaholic/items/1d74e4441a7868c6abf6)