47
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Multipeer ConnectivityでのP2P通信(Swift)

Last updated at Posted at 2021-02-21

Xcode-12 Swift-5.3 iOS-14

はじめに

iOS の端末間で通信とかしたいなと思い Multipeer Connectivity について調べました。

ソース :point_right: GitHub

Multipeer Connectivityとは

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

接続の概要

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

  1. MCPeerID を生成する。
    端末を識別する一意のもの
  2. MCSession を生成する。
    接続を管理するやつ
  3. MCNearbyServiceAdvertiser もしくは MCAdvertiserAssistant でアドバタイズする。
    他の端末から検知できるようにする
  4. MCNearbyServiceBrowser もしくは MCBrowserViewController で接続できる端末を探す。
  5. 発見した端末に対して招待を送る。
  6. 招待を受けた端末側で招待を許可すると接続が確立する。
  7. MCSession でデータの送受信を行う。
    データ受信は MCSessionDelegate を使う

各クラスの使い方

MCPeerID

MCPeerID

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

init(displayName: String)

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

MCSession

MCSession

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

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

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

encryptionPreference は下記がある。

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

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

// 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

接続関連。

// 接続済み端末一覧
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)。

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

// 接続状態変更通知(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

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

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

アドバタイズ。

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

デリゲート(MCNearbyServiceAdvertiserDelegate)。

// 招待を受けたときに呼ばれる(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

原因はよくわかりませんが許可アラートが表示されず使えないようですとりあえず MCNearbyServiceAdvertiser を使えば接続できました。

MCAdvertiserAssistant

下記で生成する。

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

アドバタイズ。

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

デリゲート(MCAdvertiserAssistantDelegate)。

// optional
func advertiserAssistantDidDismissInvitation(_ advertiserAssistant: MCAdvertiserAssistant)

// optional
func advertiserAssistantWillPresentInvitation(_ advertiserAssistant: MCAdvertiserAssistant)

MCNearbyServiceBrowser

MCNearbyServiceBrowser

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

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

init(peer: MCPeerID, serviceType: String)

検索。

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

招待。

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

デリゲート(MCNearbyServiceBrowserDelegate)。

// 検索開始失敗に呼ばれる(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

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

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

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

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

デリゲート(MCBrowserViewControllerDelegate)。

// 発見した端末を表示するかどうか設定する(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

簡易実装

iOS14 からは Info.plist に追記が必要です!
参考:iOS 14からMultipeer ConnectivityがErrorCode -72008で繋がらなくなったときの解決方法

実機で動かしてなかったんで気づかなかったのですが 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

こんな感じです。

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) {
    }
}

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

おまけ

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

詳細はソースをどうぞ :point_right: GitHub

その他気になった点

アプリがバックグラウンドにいったとき

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

接続時のエラー

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

[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)。

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?

iOS14以降でローカルネットワークの許可アラートが出ない

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

これかな:eyes:
NSLocalNetworkUsageDescription not displayed if provided via InfoPlist.string

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

おわりに

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

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

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

参考

47
33
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?