合谋:iOS 中具有 MultipeerConnectivity 的附近设备网络
已发表: 2022-03-11传统上,用于点对点通信的连接设备有点繁重。 应用程序需要发现它周围的东西,打开双方的连接,然后在网络基础设施、连接、距离等都发生变化时维护它们。 意识到这些活动固有的困难,Apple 在 iOS 7 和 macOS 10.10 中引入了 MultipeerConnectivity 框架(以下简称 MPC),旨在让应用程序以相对较低的工作量执行这些任务。
MPC 在这里处理了许多底层所需的基础设施:
- 多种网络接口支持(蓝牙、WiFi 和以太网)
- 设备检测
- 通过加密确保安全
- 小消息传递
- 文件传输
在本文中,我们将主要讨论 iOS 实现,但大多数(如果不是全部)适用于 macOS 和 tvOS。

多点会话生命周期:
-
MCNearbyServiceAdvertiser.startAdvertisingForPeers()
-
MCNearbyServiceBrowser.startBrowsingForPeers()
-
MCNearbyServiceBrowserDelegate.browser(:foundPeer:withDiscoveryInfo:)
-
MCNearbyServiceBrowser.invitePeer(...)
-
MCNearbyServiceAdvertiserDelegate.didReceiveInvitationFromPeer(...)
- 在
didReceiveInvitation
中调用invitationHandler
-
Create the MCSession
-
MCSession.send(...)
-
MCSessionDelegate.session(_:didReceive:data:peerID)
-
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 和任何连接的设备),然后我们将创建MCNearbyServiceAdvertiser
和MCNearbyServiceBrowser
。 最后两个对象需要一个服务类型,它只是一个标识您的应用程序的字符串。 它需要少于 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
参数; 我们可以使用它来根据它们提供的内容过滤掉特定的设备(这与我们在上面的MCNearbyServiceAdvertiser
的discoveryInfo
参数中提供的字典相同)。
连接设备
现在我们已经处理了所有的家务,我们可以开始连接设备的实际业务。
在 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:)
回调。 每当设备转换到新状态( notConnected
、 connecting
和connected
)时,都会调用此方法。 我们将要跟踪这一点,以便我们可以构建所有连接设备的列表:
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