はじめに
iOS の端末間で通信とかしたいなと思い Multipeer Connectivity について調べました。
ソース GitHub
Multipeer Connectivityとは
Multipeer Connectivity は Wi-Fi と Bluetooth を使って iOS 端末間の近距離通信(ピア・ツー・ピア)ができるライブラリです(最大8個まで接続できる)。
Wi-Fi が OFF でも Bluetooth で接続できるのかと思いましたが、OFF だとアドバタイザーが見えないらしく ON にする必要があるようです(同一 Wi-Fi でなくても OK)。
接続の概要
接続の流れは下記のようになっています。
-
MCPeerID を生成する。
端末を識別する一意のもの -
MCSession を生成する。
接続を管理するやつ -
MCNearbyServiceAdvertiser もしくは MCAdvertiserAssistant でアドバタイズする。
他の端末から検知できるようにする -
MCNearbyServiceBrowser もしくは MCBrowserViewController で接続できる端末を探す。
- 発見した端末に対して招待を送る。
- 招待を受けた端末側で招待を許可すると接続が確立する。
-
MCSession でデータの送受信を行う。
データ受信は MCSessionDelegate を使う
各クラスの使い方
MCPeerID
識別用の一意のもの。下記イニシャライザで displyaName
(63 B 以下で UTF-8)を設定して生成する。
同名のものを設定しても別々の ID が生成される。空文字など異常値が設定されるとクラッシュする。
init(displayName: String)
アドバタイズや探索のたびに生成するのではなく同じものを利用するようにした方がよい(仮に advertiser と borowser に別の ID を設定すると自身が検知されてしまう)。
MCSession
下記どちらかで生成する。
// identity: 証明書関連で使う(ちょっとわからない。。。)
// encryptionPreference: 通信を暗号化するかどうか
init(peer myPeerID: MCPeerID, securityIdentity identity: [Any]?, encryptionPreference: MCEncryptionPreference)
// encryptionPreferenceはrequiredになる
init(peer myPeerID: MCPeerID)
encryptionPreference
は下記がある。
- none: 暗号化しない
- required: 暗号化する
- optional: すべての端末が
required
かoptional
であれば暗号化する。
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
下記で生成する。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 を使えば接続できました。
下記で生成する。
// 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
細かい設定がいらない場合は 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
簡易に検索と画面表示をおこなってくれる 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 になります。
簡易実装
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
下記のようなチャットアプリを作成します。
こんな感じです。
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でやるだけなら接続した時点でアドバタイズと探索を停止させるべきかもしれない
おまけ
sendResource
を使えば下記のような画像の送受信もできます
画像の送受信できた🙌 pic.twitter.com/aTIdNVzQU7
— am10 (@am103141592) February 20, 2021
詳細はソースをどうぞ 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
のデータのやりとりができませんでした
下記のようにやってみると受信のところでエラーになりました(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
で「接続中」の後「辞退」になることがありました。手元で再現せずストアのレビューで発生しリジェクトされましたちょっと原因はわからないです。。。
同じような人いました
MultipeerConnectivity in SwiftUI?
iOS14以降でローカルネットワークの許可アラートが出ない
iOS14以降で NSLocalNetworkUsageDescription
を追加していても許可アラートが出ないことがありました。でもなぜか接続はできました原因不明。。。
これかな
NSLocalNetworkUsageDescription not displayed if provided via InfoPlist.string
手元では iOS14.1 は表示できて iOS14.4 は表示されませんでした。
おわりに
さくっと端末間の接続ができるのはすばらしい
ちょっと調べてると過去 OS バージョンではバグが多そうでしたが最近は安定してるのかどうなんだろう
iOS 12.4.9(iPod touch 第6世代)、iOS 13.7(iPad 第5世代)、iOS 14.0(シミュレータ)で接続できるのは確認しました。
手動接続とストリームに関してはよくわかりませんでした!