Conluio: rede de dispositivos próximos com conectividade multipeer no iOS

Publicados: 2022-03-11

Tradicionalmente, conectar dispositivos para comunicações ponto a ponto tem sido um trabalho pesado. Um aplicativo precisa descobrir o que está ao seu redor, abrir conexões em ambos os lados e mantê-las como infraestrutura de rede, conexões, distâncias etc., tudo muda. Percebendo as dificuldades inerentes a essas atividades, no iOS 7 e no macOS 10.10 a Apple introduziu sua estrutura MultipeerConnectivity (doravante MPC), projetada para permitir que os aplicativos executem essas tarefas com um esforço relativamente baixo.

O MPC cuida de grande parte da infraestrutura necessária subjacente aqui:

  • Suporte a várias interfaces de rede (Bluetooth, WiFi e ethernet)
  • Detecção de dispositivo
  • Segurança por meio de criptografia
  • Passagem de pequenas mensagens
  • Transferência de arquivo

Neste artigo, abordaremos principalmente a implementação do iOS, mas a maioria, se não tudo, é aplicável ao macOS e tvOS.

Ciclo de Vida da Sessão MultipeerConnectivity

Ciclo de Vida da Sessão Multipeer:

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

Consulte esta imagem de vez em quando

Existem vários tutoriais e exemplos de MultipeerConnectivity por aí que pretendem orientar os desenvolvedores iOS pela implementação de um aplicativo baseado em MPC. No entanto, na minha experiência, eles geralmente são incompletos e tendem a encobrir alguns obstáculos potenciais importantes com o MPC. Neste artigo, espero guiar o leitor por uma implementação rudimentar de um aplicativo desse tipo e destacar as áreas em que achei fácil ficar preso.

Conceitos e aulas

MPC é baseado em um punhado de classes. Vamos percorrer a lista dos mais comuns e construir nossa compreensão da estrutura.

  • MCSession – Uma sessão gerencia todas as comunicações entre seus pares associados. Você pode enviar mensagens, arquivos e fluxos por meio de uma sessão, e seu delegado será notificado quando um deles for recebido de um ponto conectado.
  • MCPeerID – Um ID de peer permite identificar dispositivos de peer individuais em uma sessão. Ele tem um nome associado a ele, mas tenha cuidado: IDs de pares com o mesmo nome não são considerados idênticos (consulte Regras Básicas, abaixo).
  • MCNearbyServiceAdvertiser – Um anunciante permite que você transmita o nome do seu serviço para dispositivos próximos. Isso permite que eles se conectem a você.
  • MCNearbyServiceBrowser – Um navegador permite pesquisar dispositivos usando MCNearbyServiceAdvertiser . O uso dessas duas classes juntas permite que você descubra dispositivos próximos e crie suas conexões ponto a ponto.
  • MCBrowserViewController – Isso fornece uma interface do usuário muito básica para navegar pelos serviços de dispositivos próximos (vendidos via MCNearbyServiceAdvertiser ). Embora seja adequado para alguns casos de uso, não usaremos isso, pois, na minha experiência, um dos melhores aspectos do MCP é sua fluidez.

Regras básicas

Há algumas coisas a serem lembradas ao construir uma rede MPC:

  • Os dispositivos são identificados por objetos MCPeerID. Essas são, superficialmente, strings encapsuladas e, de fato, podem ser inicializadas com nomes simples. Embora dois MCPeerIDs possam ser criados com a mesma string, eles não são idênticos. Assim, os MCPeerIDs nunca devem ser copiados ou recriados; eles devem ser repassados ​​dentro do aplicativo. Se necessário, eles podem ser armazenados usando um NSArchiver.
  • Embora a documentação sobre ele esteja faltando, o MCSession pode ser usado para comunicação entre mais de dois dispositivos. No entanto, na minha experiência, a maneira mais estável de utilizar esses objetos é criar um para cada peer com o qual seu dispositivo está interagindo.
  • O MPC não funcionará enquanto seu aplicativo estiver em segundo plano. Você deve desconectar e derrubar todas as suas MCSessions quando seu aplicativo estiver em segundo plano. Não tente fazer mais do que operações mínimas em nenhuma tarefa em segundo plano.

Introdução ao MultipeerConnectivity

Antes de podermos estabelecer nossa rede, precisamos fazer uma pequena limpeza e, em seguida, configurar as classes de anunciante e navegador para descobrir outros dispositivos com os quais podemos nos comunicar. Vamos criar um singleton que usaremos para armazenar algumas variáveis ​​de estado (nosso MCPeerID local e quaisquer dispositivos conectados), então criaremos MCNearbyServiceAdvertiser e MCNearbyServiceBrowser . Esses dois últimos objetos precisam de um tipo de serviço, que é apenas uma string identificando seu aplicativo. Ele precisa ter menos de 16 caracteres e deve ser o mais exclusivo possível (ou seja, “MyApp-MyCo”, não “Multipeer”). Podemos especificar um (pequeno) dicionário para nosso anunciante do que os navegadores podem ler para fornecer um pouco mais de informações ao olhar para dispositivos próximos (talvez um tipo de jogo ou função de dispositivo).

Como o MPC depende de APIs fornecidas pelo sistema e se correlaciona com objetos do mundo real (outros dispositivos, bem como a “rede” compartilhada entre eles), é um bom ajuste para o padrão singleton. Embora frequentemente usados ​​em excesso, os singletons são uma boa opção para recursos compartilhados como esse.

Aqui está a definição do nosso 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 } }

Observe que estamos armazenando nosso MCPeerID nos padrões do usuário (por meio de NSKeyedArchiver ) e o reutilizando. Como mencionado acima, isso é importante, e a falha em armazená-lo de alguma forma pode causar bugs obscuros mais adiante.

Aqui está nossa classe Device, que usaremos para acompanhar quais dispositivos foram descobertos e qual é seu estado:

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

Agora que criamos nossas classes iniciais, é hora de voltar atrás e pensar na interação entre navegadores e anunciantes. No MPC, um dispositivo pode anunciar um serviço que oferece e pode procurar um serviço de seu interesse em outros dispositivos. Como estamos focados na comunicação dispositivo a dispositivo usando apenas nosso aplicativo, anunciaremos e procuraremos o mesmo serviço.

Em uma configuração cliente/servidor tradicional, um dispositivo (o servidor) anunciaria seus serviços e o cliente os procuraria. Como somos igualitários, não queremos especificar funções para nossos dispositivos; teremos todos os dispositivos anunciando e navegando.

Precisamos adicionar um método ao nosso MPCManager para criar dispositivos à medida que são descobertos e rastreá-los em nossa matriz de dispositivos. Nosso método pegará um MCPeerID , procurará um dispositivo existente com esse ID e o retornará se encontrado. Se ainda não tivermos um dispositivo existente, criamos um novo e o adicionamos ao nosso array de dispositivos.

 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 }

Depois que um dispositivo começa a anunciar, outro dispositivo de navegação pode tentar se conectar a ele. Precisaremos adicionar métodos delegados à nossa classe MPCSession para lidar com chamadas delegadas recebidas de nosso anunciante neste 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) } }

…um método em nosso dispositivo para criar a MCSession:

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

…e, finalmente, um método para acionar o convite quando nosso navegador descobre um anunciante:

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

No momento, estamos ignorando o argumento withDiscoveryInfo ; poderíamos usar isso para filtrar dispositivos específicos com base no que eles disponibilizaram (este é o mesmo dicionário que fornecemos no argumento discoveryInfo para MCNearbyServiceAdvertiser , acima).

Dispositivos de conexão

Agora que cuidamos de todas as nossas tarefas domésticas, podemos iniciar o negócio real de conectar dispositivos.

No método init de nossa MPCSession, configuramos nosso anunciante e nosso representante. Quando estivermos prontos para iniciar a conexão, precisaremos iniciar os dois. Isso pode ser feito no método didFinishLaunching do delegado do aplicativo ou sempre que for apropriado. Aqui está o método start() que adicionaremos à nossa classe:

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

Essas chamadas significam que seu aplicativo começará a transmitir sua presença por Wi-Fi. Observe que você não precisa estar conectado a uma rede WiFi para que isso funcione (mas você precisa ligá-lo).

Quando um dispositivo responde a um convite e inicia sua MCSession, ele começará a receber retornos de chamada delegados da sessão. Adicionaremos manipuladores para eles ao nosso objeto de dispositivo; a maioria deles vamos ignorar por enquanto:

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

Por enquanto, estamos preocupados principalmente com o retorno de chamada session(_:peer:didChangeState:) . Isso será chamado sempre que um dispositivo passar para um novo estado ( notConnected , connect connecting connected ). Vamos querer acompanhar isso para que possamos criar uma lista de todos os dispositivos conectados:

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

Enviando mensagens

Agora que temos todos os nossos dispositivos conectados, é hora de começar a enviar mensagens de um lado para o outro. A MPC oferece três opções a este respeito:

  • Podemos enviar um bloco de bytes (um objeto de dados)
  • Podemos enviar um arquivo
  • Podemos abrir um fluxo para o outro dispositivo

Por uma questão de simplicidade, veremos apenas a primeira dessas opções. Enviaremos mensagens simples e não nos preocuparemos muito com as complexidades dos tipos de mensagens, formatação etc. Usaremos uma estrutura Codable para encapsular nossa mensagem, que ficará assim:

 struct Message: Codable { let body: String }

Também adicionaremos uma extensão ao dispositivo para enviar um destes:

 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 }

Conclusões

Este artigo aborda a arquitetura necessária para construir os componentes de rede de um aplicativo baseado em MultipeerConnectivity. O código-fonte completo (disponível no Github) oferece um wrapper mínimo de interface de usuário que permite visualizar dispositivos conectados e enviar mensagens entre eles.

O MPC oferece conectividade quase perfeita entre dispositivos próximos sem a necessidade de se preocupar com redes WiFi, Bluetooth ou ginástica cliente/servidor complexa. Ser capaz de emparelhar rapidamente alguns telefones para uma curta sessão de jogo ou conectar dois dispositivos para compartilhamento é feito da maneira típica da Apple.

O código-fonte para este projeto está disponível no Github em https://github.com/bengottlieb/MultipeerExample.

Projetando um iOS que usa AFNetworking? O padrão de design Model-View-Controller (MVC) é ótimo para uma base de código de manutenção, mas às vezes você precisa de uma única classe para lidar com sua rede devido a preocupações como código DRY, registro de rede centralizado e, especialmente, limitação de taxa. Leia tudo sobre como lidar com isso com uma classe Singleton na rede centralizada e desacoplada do iOS: Tutorial AFNetworking com uma classe Singleton