Collusion: rete di dispositivi nelle vicinanze con MultipeerConnectivity in iOS

Pubblicato: 2022-03-11

Tradizionalmente, il collegamento di dispositivi per le comunicazioni peer-to-peer è stato un po' un lavoro pesante. Un'applicazione deve scoprire cosa c'è intorno, aprire connessioni su entrambi i lati e quindi mantenerle come infrastruttura di rete, connessioni, distanze e così via, tutto cambia. Rendendosi conto delle difficoltà inerenti a queste attività, in iOS 7 e macOS 10.10 Apple ha introdotto il suo framework MultipeerConnectivity (d'ora in poi MPC), progettato per consentire alle app di eseguire queste attività con uno sforzo relativamente basso.

MPC si occupa di gran parte dell'infrastruttura richiesta sottostante qui:

  • Supporto per più interfacce di rete (Bluetooth, WiFi ed Ethernet)
  • Rilevamento del dispositivo
  • Sicurezza tramite crittografia
  • Piccolo messaggio di passaggio
  • Trasferimento di file

In questo articolo tratteremo principalmente l'implementazione di iOS, ma la maggior parte, se non tutto, è applicabile a macOS e tvOS.

Ciclo di vita della sessione MultipeerConnectivity

Ciclo di vita della sessione multi-peer:

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

Fare riferimento a questa immagine di tanto in tanto

Esistono numerosi tutorial ed esempi di MultipeerConnectivity che pretendono di guidare gli sviluppatori iOS attraverso l'implementazione di un'applicazione basata su MPC. Tuttavia, nella mia esperienza, di solito sono incompleti e tendono a sorvolare su alcuni importanti potenziali ostacoli con MPC. In questo articolo, spero di guidare il lettore attraverso un'implementazione rudimentale di un'app del genere e di richiamare le aree in cui ho trovato facile rimanere bloccato.

Concetti e classi

MPC si basa su una manciata di classi. Esaminiamo l'elenco di quelli comuni e rafforziamo la nostra comprensione del framework.

  • MCSession : una sessione gestisce tutte le comunicazioni tra i peer associati. Puoi inviare messaggi, file e flussi tramite una sessione e il suo delegato riceverà una notifica quando uno di questi viene ricevuto da un peer connesso.
  • MCPeerID : un ID peer consente di identificare i singoli dispositivi peer all'interno di una sessione. Ha un nome associato, ma fai attenzione: gli ID peer con lo stesso nome non sono considerati identici (vedi Regole di base, di seguito).
  • MCNearbyServiceAdvertiser : un inserzionista ti consente di trasmettere il nome del tuo servizio ai dispositivi vicini. Ciò consente loro di connettersi a te.
  • MCNearbyServiceBrowser : un browser consente di cercare dispositivi utilizzando MCNearbyServiceAdvertiser . L'utilizzo di queste due classi insieme ti consente di scoprire i dispositivi nelle vicinanze e creare le tue connessioni peer-to-peer.
  • MCBrowserViewController : fornisce un'interfaccia utente molto semplice per la navigazione nei servizi del dispositivo nelle vicinanze (venduti tramite MCNearbyServiceAdvertiser ). Sebbene sia adatto per alcuni casi d'uso, non lo useremo, poiché, nella mia esperienza, uno degli aspetti migliori di MCP è la sua continuità.

Regole di base

Ci sono un paio di cose da tenere a mente quando si costruisce una rete MPC:

  • I dispositivi sono identificati da oggetti MCPeerID. Queste sono, superficialmente, stringhe avvolte e, in effetti, possono essere inizializzate con nomi semplici. Sebbene sia possibile creare due MCPeerID con la stessa stringa, non sono identici. Pertanto, gli MCPeerID non devono mai essere copiati o ricreati; dovrebbero essere passati all'interno dell'applicazione. Se necessario, possono essere archiviati utilizzando un NSArchiver.
  • Sebbene la documentazione su di esso sia carente, MCSession può essere utilizzato per comunicare tra più di due dispositivi. Tuttavia, secondo la mia esperienza, il modo più stabile per utilizzare questi oggetti è crearne uno per ogni peer con cui il tuo dispositivo sta interagendo.
  • MPC non funzionerà mentre l'applicazione è in background. Dovresti disconnetterti e smontare tutte le tue MCSessions quando la tua app è in background. Non provare a fare più di operazioni minime in qualsiasi attività in background.

Iniziare con MultipeerConnectivity

Prima di poter stabilire la nostra rete, dobbiamo fare un po' di pulizia, quindi impostare le classi inserzionista e browser per scoprire altri dispositivi con cui possiamo comunicare. Creeremo un singleton che useremo per contenere alcune variabili di stato (il nostro MCPeerID locale e tutti i dispositivi connessi), quindi creeremo MCNearbyServiceAdvertiser e MCNearbyServiceBrowser . Questi ultimi due oggetti necessitano di un tipo di servizio, che è solo una stringa che identifica la tua applicazione. Deve essere inferiore a 16 caratteri e deve essere il più unico possibile (ad es. "MyApp-MyCo", non "Multipeer"). Possiamo specificare un (piccolo) dizionario per il nostro inserzionista rispetto a quello che i browser possono leggere per fornire un po' più di informazioni quando guardiamo i dispositivi nelle vicinanze (forse un tipo di gioco o un ruolo del dispositivo).

Poiché MPC si basa su API fornite dal sistema e si correla con oggetti del mondo reale (altri dispositivi, nonché la "rete" condivisa tra di loro), si adatta bene al modello singleton. Sebbene spesso abusati, i singleton si adattano bene a risorse condivise come questa.

Ecco la definizione del nostro singleton:

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

Si noti che stiamo archiviando il nostro MCPeerID nelle impostazioni predefinite dell'utente (tramite NSKeyedArchiver ) e lo riutilizzeremo. Come accennato in precedenza, questo è importante e la mancata memorizzazione nella cache in qualche modo può causare bug oscuri più avanti.

Ecco la nostra classe Device, che useremo per tenere traccia di quali dispositivi sono stati scoperti e qual è il loro stato:

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

Ora che abbiamo creato le nostre classi iniziali, è tempo di fare un passo indietro e pensare all'interazione tra browser e inserzionisti. In MPC, un dispositivo può pubblicizzare un servizio che offre e può cercare un servizio a cui è interessato su altri dispositivi. Poiché ci concentriamo sulla comunicazione da dispositivo a dispositivo utilizzando solo la nostra app, pubblicizzeremo e cercheremo lo stesso servizio.

In una configurazione client/server tradizionale, un dispositivo (il server) pubblicizzerebbe i suoi servizi e il client li cercherebbe. Dal momento che siamo egualitari, non vogliamo dover specificare ruoli per i nostri dispositivi; avremo tutti i dispositivi sia per pubblicizzare che per navigare.

Dobbiamo aggiungere un metodo al nostro MPCManager per creare dispositivi non appena vengono scoperti e tracciarli nel nostro array di dispositivi. Il nostro metodo prenderà un MCPeerID , cercherà un dispositivo esistente con quell'ID e lo restituirà se trovato. Se non disponiamo già di un dispositivo esistente, ne creiamo uno nuovo e lo aggiungiamo al nostro array di dispositivi.

 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 }

Dopo che un dispositivo ha iniziato a fare pubblicità, un altro dispositivo di navigazione può tentare di collegarsi ad esso. Avremo bisogno di aggiungere metodi delegati alla nostra classe MPCSession per gestire le chiamate delegato in arrivo dal nostro inserzionista in questo caso:

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

…un metodo sul nostro dispositivo per creare la MCSession:

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

...e infine un metodo per attivare l'invito quando il nostro browser rileva un inserzionista:

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

In questo momento, stiamo ignorando l'argomento withDiscoveryInfo ; potremmo usarlo per filtrare dispositivi particolari in base a ciò che hanno reso disponibile (questo è lo stesso dizionario che abbiamo fornito nell'argomento discoveryInfo a MCNearbyServiceAdvertiser , sopra).

Collegamento di dispositivi

Ora che ci siamo occupati di tutte le nostre pulizie, possiamo iniziare l'attività vera e propria di connessione dei dispositivi.

Nel metodo init della nostra MPCSession abbiamo impostato sia il nostro inserzionista che il nostro delegato. Quando saremo pronti per iniziare a connetterci, dovremo avviarli entrambi. Questa operazione può essere eseguita nel metodo didFinishLaunching del delegato dell'app o quando è appropriato. Ecco il metodo start() che aggiungeremo alla nostra classe:

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

Queste chiamate significheranno che la tua app inizierà a trasmettere la sua presenza tramite Wi-Fi. Nota che non è necessario essere connessi a una rete Wi-Fi per farlo funzionare (ma devi averlo acceso).

Quando un dispositivo risponde a un invito e avvia la sua MCSession, inizierà a ricevere le richiamate dei delegati dalla sessione. Aggiungeremo gestori per quelli al nostro oggetto dispositivo; la maggior parte di loro la ignoreremo per il momento:

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

Per il momento, ci occupiamo principalmente del callback di session(_:peer:didChangeState:) . Verrà chiamato ogni volta che un dispositivo passa a un nuovo stato ( notConnected connecting connected e connected ). Vorremo tenerne traccia in modo da poter creare un elenco di tutti i dispositivi collegati:

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

Invio di messaggi

Ora che abbiamo collegato tutti i nostri dispositivi, è ora di iniziare a inviare messaggi avanti e indietro. MPC offre tre opzioni al riguardo:

  • Possiamo inviare un blocco di byte (un oggetto dati)
  • Possiamo inviare un file
  • Possiamo aprire un flusso sull'altro dispositivo

Per semplicità, esamineremo solo la prima di queste opzioni. Invieremo messaggi semplici avanti e indietro e non ci preoccuperemo troppo della complessità dei tipi di messaggio, della formattazione, ecc. Useremo una struttura Codable per incapsulare il nostro messaggio, che sarà simile a questo:

 struct Message: Codable { let body: String }

Aggiungeremo anche un'estensione al dispositivo per inviare uno di questi:

 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 }

Conclusioni

Questo articolo illustra l'architettura necessaria per creare i componenti di rete di un'applicazione basata su MultipeerConnectivity. Il codice sorgente completo (disponibile su Github) offre un wrapper dell'interfaccia utente minimo che consente di visualizzare i dispositivi connessi e inviare messaggi tra di loro.

MPC offre una connettività quasi perfetta tra i dispositivi vicini senza doversi preoccupare di reti Wi-Fi, Bluetooth o complesse attività di ginnastica client/server. La possibilità di accoppiare rapidamente alcuni telefoni per una breve sessione di gioco o di collegare due dispositivi per la condivisione avviene in tipico stile Apple.

Il codice sorgente per questo progetto è disponibile su Github all'indirizzo https://github.com/bengottlieb/MultipeerExample.

Stai progettando un iOS che utilizza AFNetworking? Il modello di progettazione Model-View-Controller (MVC) è ottimo per una base di codice manutenibile, ma a volte è necessaria una singola classe per gestire la rete a causa di problemi come il codice DRY, la registrazione centralizzata della rete e, soprattutto, la limitazione della velocità. Leggi tutto sulla gestione di questo con una classe Singleton in iOS Centralized e disaccoppiato Tutorial: AFNetworking Tutorial con una classe Singleton