合謀:iOS 中具有 MultipeerConnectivity 的附近設備網絡

已發表: 2022-03-11

傳統上,用於點對點通信的連接設備有點繁重。 應用程序需要發現它周圍的東西,打開雙方的連接,然後在網絡基礎設施、連接、距離等都發生變化時維護它們。 意識到這些活動固有的困難,Apple 在 iOS 7 和 macOS 10.10 中引入了 MultipeerConnectivity 框架(以下簡稱 MPC),旨在讓應用程序以相對較低的工作量執行這些任務。

MPC 在這里處理了許多底層所需的基礎設施:

  • 多種網絡接口支持(藍牙、WiFi 和以太網)
  • 設備檢測
  • 通過加密確保安全
  • 小消息傳遞
  • 文件傳輸

在本文中,我們將主要討論 iOS 實現,但大多數(如果不是全部)適用於 macOS 和 tvOS。

MultipeerConnectivity 會話生命週期

多點會話生命週期:

  1. MCNearbyServiceAdvertiser.startAdvertisingForPeers()
  2. MCNearbyServiceBrowser.startBrowsingForPeers()
  3. MCNearbyServiceBrowserDelegate.browser(:foundPeer:withDiscoveryInfo:)
  4. MCNearbyServiceBrowser.invitePeer(...)
  5. MCNearbyServiceAdvertiserDelegate.didReceiveInvitationFromPeer(...)
  6. didReceiveInvitation中調用invitationHandler
  7. Create the MCSession
  8. MCSession.send(...)
  9. MCSessionDelegate.session(_:didReceive:data:peerID)
  10. MCSession.disconnect()

時不時回看這張圖

有許多 MultipeerConnectivity 教程和示例,旨在引導 iOS 開發人員完成基於 MPC 的應用程序的實現。 但是,根據我的經驗,它們通常是不完整的,並且往往會掩蓋 MPC 的一些重要潛在絆腳石。 在本文中,我希望引導讀者了解此類應用程序的基本實現,並指出我發現容易卡住的地方。

概念與課程

MPC 基於少數幾個類。 讓我們瀏覽一下常見的列表,並建立我們對框架的理解。

  • MCSession – 會話管理其關聯對等方之間的所有通信。 您可以通過會話發送消息、文件和流,當從連接的對等方接收到其中之一時,將通知其代表。
  • MCPeerID – 對等 ID 可讓您識別會話中的各個對等設備。 它有一個與之關聯的名稱,但要小心:具有相同名稱的對等 ID 不被視為相同(請參閱下面的基本規則)。
  • MCNearbyServiceAdvertiser – 廣告商允許您向附近的設備廣播您的服務名稱。 這讓他們可以連接到您。
  • MCNearbyServiceBrowser – 瀏覽器可讓您使用MCNearbyServiceAdvertiser搜索設備。 一起使用這兩個類可以讓您發現附近的設備並創建對等連接。
  • MCBrowserViewController – 這為瀏覽附近的設備服務提供了一個非常基本的 UI(通過MCNearbyServiceAdvertiser出售)。 雖然適用於某些用例,但我們不會使用它,因為根據我的經驗,MCP 的最佳方面之一是它的無縫性。

基本規則

構建 MPC 網絡時需要牢記以下幾點:

  • 設備由 MCPeerID 對象標識。 從表面上看,這些是封裝的字符串,實際上可以使用簡單的名稱進行初始化。 儘管可以使用相同的字符串創建兩個 MCPeerID,但它們並不相同。 因此,不得複製或重新創建 MCPeerID; 它們應該在應用程序中傳遞。 如有必要,可以使用 NSArchiver 存儲它們。
  • 雖然缺少相關文檔,但 MCSession 可用於在兩個以上設備之間進行通信。 但是,根據我的經驗,使用這些對象的最穩定方法是為您的設備與之交互的每個對等方創建一個。
  • 當您的應用程序在後台時,MPC 將無法工作。 當您的應用程序處於後台時,您應該斷開連接並拆除所有 MCSession。 不要嘗試在任何後台任務中執行超出最小操作的操作。

MultipeerConnectivity 入門

在我們建立我們的網絡之前,我們需要做一些整理工作,然後設置廣告商和瀏覽器類以發現我們可以與之通信的其他設備。 我們將創建一個單例,我們將使用它來保存一些狀態變量(我們的本地 MCPeerID 和任何連接的設備),然後我們將創建MCNearbyServiceAdvertiserMCNearbyServiceBrowser 。 最後兩個對象需要一個服務類型,它只是一個標識您的應用程序的字符串。 它需要少於 16 個字符並且應該盡可能唯一(即“MyApp-MyCo”,而不是“Multipeer”)。 我們可以為我們的廣告商指定一個(小)字典,以便在查看附近的設備(可能是遊戲類型或設備角色)時提供更多信息。

由於 MPC 依賴於系統提供的 API 並與現實世界的對象(其他設備以及它們之間的共享“網絡”)相關聯,因此它非常適合單例模式。 雖然經常被過度使用,但單例非常適合像這樣的共享資源。

這是我們單例的定義:

 class MPCManager: NSObject { var advertiser: MCNearbyServiceAdvertiser! var browser: MCNearbyServiceBrowser! static let instance = MPCManager() let localPeerID: MCPeerID let serviceType = "MPC-Testing" var devices: [Device] = [] override init() { if let data = UserDefaults.standard.data(forKey: "peerID"), let id = NSKeyedUnarchiver.unarchiveObject(with: data) as? MCPeerID { self.localPeerID = id } else { let peerID = MCPeerID(displayName: UIDevice.current.name) let data = try? NSKeyedArchiver.archivedData(withRootObject: peerID) UserDefaults.standard.set(data, forKey: "peerID") self.localPeerID = peerID } super.init() self.advertiser = MCNearbyServiceAdvertiser(peer: localPeerID, discoveryInfo: nil, serviceType: self.serviceType) self.advertiser.delegate = self self.browser = MCNearbyServiceBrowser(peer: localPeerID, serviceType: self.serviceType) self.browser.delegate = self } }

請注意,我們將MCPeerID存儲在用戶默認值中(通過NSKeyedArchiver ),並重新使用它。 如上所述,這很重要,並且未能以某種方式緩存它可能會導致更深層次的模糊錯誤。

這是我們的 Device 類,我們將使用它來跟踪已發現的設備以及它們的狀態:

 class Device: NSObject { let peerID: MCPeerID var session: MCSession? var name: String var state = MCSessionState.notConnected init(peerID: MCPeerID) { self.name = peerID.displayName self.peerID = peerID super.init() } func invite() { browser.invitePeer(self.peerID, to: self.session!, withContext: nil, timeout: 10) } }

現在我們已經構建了我們的初始類,是時候退一步思考瀏覽器和廣告商之間的相互作用了。 在 MPC 中,設備可以宣傳它提供的服務,並且可以在其他設備上瀏覽它感興趣的服務。 由於我們只使用我們的應用程序專注於設備到設備的通信,因此我們將同時宣傳和瀏覽相同的服務。

在傳統的客戶端/服務器配置中,一個設備(服務器)會宣傳其服務,客戶端會瀏覽它們。 由於我們是平等主義者,我們不想為我們的設備指定角色; 我們將讓每台設備都做廣告和瀏覽。

我們需要向MPCManager添加一個方法,以便在發現設備時創建設備並在我們的設備數組中跟踪它們。 我們的方法將採用MCPeerID ,查找具有該 ID 的現有設備,如果找到則返回它。 如果我們還沒有現有設備,我們創建一個新設備並將其添加到我們的設備陣列中。

 func device(for id: MCPeerID) -> Device { for device in self.devices { if device.peerID == id { return device } } let device = Device(peerID: id) self.devices.append(device) return device }

設備開始廣告後,另一個瀏覽設備可以嘗試連接到它。 在這種情況下,我們需要向MPCSession類添加委託方法來處理來自廣告商的傳入委託調用:

 extension MPCManager: MCNearbyServiceAdvertiserDelegate { func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { let device = MPCManager.instance.device(for: peerID) device.connect() invitationHandler(true, device.session) } }

…在我們的設備上創建 MCSession 的方法:

 func connect() { if self.session != nil { return } self.session = MCSession(peer: MPCManager.instance.localPeerID, securityIdentity: nil, encryptionPreference: .required) self.session?.delegate = self }

…最後是當我們的瀏覽器發現廣告商時觸發邀請的方法:

 extension MPCManager: MCNearbyServiceBrowserDelegate { func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { let device = MPCManager.instance.device(for: peerID) device.invite(with: self.browser) }

現在,我們忽略了withDiscoveryInfo參數; 我們可以使用它來根據它們提供的內容過濾掉特定的設備(這與我們在上面的MCNearbyServiceAdvertiserdiscoveryInfo參數中提供的字典相同)。

連接設備

現在我們已經處理了所有的家務,我們可以開始連接設備的實際業務。

在 MPCSession 的 init 方法中,我們設置了廣告商和委託。 當我們準備好開始連接時,我們需要同時啟動它們。 這可以在 App 委託的 didFinishLaunching 方法中完成,或者在適當的時候完成。 這是我們將添加到類中的start()方法:

 func start() { self.advertiser.startAdvertisingPeer() self.browser.startBrowsingForPeers() }

這些調用將意味著您的應用將開始通過 WiFi 廣播它的存在。 請注意,您無需連接到 WiFi 網絡即可使用(但您必須將其打開)。

當設備響應邀請並啟動其 MCSession 時,它將開始接收來自會話的委託回調。 我們將為我們的設備對象添加處理程序; 其中大部分我們將暫時忽略:

 extension Device: MCSessionDelegate { public func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { self.state = state NotificationCenter.default.post(name: Multipeer.Notifications.deviceDidChangeState, object: self) } public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { } public func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { } public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { } public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { } }

目前,我們主要關注session(_:peer:didChangeState:)回調。 每當設備轉換到新狀態( notConnectedconnectingconnected )時,都會調用此方法。 我們將要跟踪這一點,以便我們可以構建所有連接設備的列表:

 extension MPCManager { var connectedDevices: [Device] { return self.devices.filter { $0.state == .connected } } }

發送消息

現在我們已經連接了所有設備,是時候真正開始來回發送消息了。 MPC 在這方面提供了三種選擇:

  • 我們可以發送一個字節塊(一個數據對象)
  • 我們可以發送文件
  • 我們可以打開一個流到另一個設備

為簡單起見,我們將只查看這些選項中的第一個。 我們將來回發送簡單的消息,而不必太擔心消息類型、格式等的複雜性。我們將使用 Codable 結構來封裝我們的消息,如下所示:

 struct Message: Codable { let body: String }

我們還將向 Device 添加一個擴展,以發送以下內容之一:

 extension Device { func send(text: String) throws { let message = Message(body: text) let payload = try JSONEncoder().encode(message) try self.session?.send(payload, toPeers: [self.peerID], with: .reliable) } } ~~~swift Finally, we'll need to modify our `Device.session(_:didReceive:fromPeer)` code to receive the message, parse it, and notify any interested objects about it:

static let messageReceivedNotification = Notification.Name(“DeviceDidReceiveMessage”) public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { if let message = try? JSONDecoder().decode(Message.self, from: data) { NotificationCenter.default.post(name: Device.messageReceivedNotification, object: message, userInfo: [“from”: self]) } }

 ## Disconnections Now that we've got a connection created between multiple devices, we have to be able to both disconnect on demand and also handle system interruptions. One of the undocumented weaknesses of MPC is that it doesn't function in the background. We need to observe the `UIApplication.didEnterBackgroundNotification` notification, and make sure that we shut down all our sessions. Failure to do this will lead to undefined states in the sessions and devices and can cause lots of confusing, hard-to-track-down errors. There is a temptation to use a background task to keep your sessions around, in case the user jumps back into your app. However, this is a bad idea, as MPC will usually fail within the first second of being backgrounded. When your app returns to the foreground, you can rely on MPC's delegate methods to rebuild your connections. In our MPCSession's `start()` method, we'll want to observe this notification and add code to handle it and shut down all our sessions. ~~~swift func start() { self.advertiser.startAdvertisingPeer() self.browser.startBrowsingForPeers() NotificationCenter.default.addObserver(self, selector: #selector(enteredBackground), name: Notification.Name.UIApplicationDidEnterBackground, object: nil) } @objc func enteredBackground() { for device in self.devices { device.disconnect() } } func disconnect() { self.session?.disconnect() self.session = nil }

結論

本文介紹了構建基於 MultipeerConnectivity 的應用程序的網絡組件所需的體系結構。 完整的源代碼(在 Github 上可用)提供了一個最小的用戶界麵包裝器,允許您查看連接的設備,並在它們之間發送消息。

MPC 在附近的設備之間提供近乎無縫的連接,無需擔心 WiFi 網絡、藍牙或複雜的客戶端/服務器操作。 能夠快速配對幾部手機進行短暫的遊戲會話,或連接兩台設備進行共享,都是典型的 Apple 方式。

該項目的源代碼可在 Github 上的 https://github.com/bengottlieb/MultipeerExample 獲得。

設計一個使用 AFNetworking 的 iOS? 模型-視圖-控制器 (MVC) 設計模式非常適合可維護的代碼庫,但有時由於 DRY 代碼、集中式網絡日誌記錄,尤其是速率限制等問題,您需要一個類來處理網絡。 閱讀有關在iOS 集中和解耦網絡中使用單例類處理此問題的所有內容:AFNetworking Tutorial with a Singleton Class