Сговор: сеть устройств поблизости с MultipeerConnectivity в iOS

Опубликовано: 2022-03-11

Традиционно подключение устройств для одноранговой связи было довольно сложной задачей. Приложение должно обнаруживать, что находится вокруг него, открывать соединения с обеих сторон, а затем поддерживать их по мере изменения сетевой инфраструктуры, соединений, расстояний и т. д. Понимая трудности, связанные с этими действиями, в iOS 7 и macOS 10.10 Apple представила свою платформу MultipeerConnectivity (далее MPC), предназначенную для того, чтобы приложения могли выполнять эти задачи с относительно небольшими усилиями.

MPC заботится о большей части базовой необходимой инфраструктуры:

  • Поддержка нескольких сетевых интерфейсов (Bluetooth, WiFi и Ethernet)
  • Обнаружение устройства
  • Безопасность через шифрование
  • Передача небольших сообщений
  • Передача файлов

В этой статье мы в основном будем рассматривать реализацию iOS, но большая часть, если не все, применима к macOS и tvOS.

Жизненный цикл сеанса MultipeerConnectivity

Жизненный цикл многоранговой сессии:

  1. MCNearbyServiceAdvertiser.startAdvertisingForPeers()
  2. MCNearbyServiceBrowser.startBrowsingForPeers()
  3. MCNearbyServiceBrowserDelegate.browser(:foundPeer:withDiscoveryInfo:)
  4. MCNearbyServiceBrowser.invitePeer(...)
  5. MCNearbyServiceAdvertiserDelegate.didReceiveInvitationFromPeer(...)
  6. Вызовите обработчик invitationHandler в didReceiveInvitation
  7. Create the MCSession
  8. MCSession.send(...)
  9. MCSessionDelegate.session(_:didReceive:data:peerID)
  10. MCSession.disconnect()

Возвращайтесь к этому изображению время от времени

Существует множество учебных пособий и примеров MultipeerConnectivity, которые предназначены для того, чтобы помочь разработчикам iOS реализовать приложение на основе MPC. Однако, по моему опыту, они, как правило, неполны и, как правило, замалчивают некоторые важные потенциальные камни преткновения в MPC. В этой статье я надеюсь провести читателя через рудиментарную реализацию такого приложения и указать области, в которых я легко застрял.

Концепции и классы

MPC основан на нескольких классах. Давайте пройдемся по списку наиболее распространенных и углубимся в наше понимание фреймворка.

  • MCSession — сеанс управляет всеми коммуникациями между связанными с ним одноранговыми узлами. Вы можете отправлять сообщения, файлы и потоки через сеанс, и его делегат будет уведомлен, когда одно из них будет получено от подключенного узла.
  • MCPeerID — одноранговый идентификатор позволяет идентифицировать отдельные одноранговые устройства в рамках сеанса. С ним связано имя, но будьте осторожны: идентификаторы одноранговых узлов с одинаковыми именами не считаются идентичными (см. Основные правила ниже).
  • MCNearbyServiceAdvertiser — рекламодатель позволяет вам транслировать название вашего сервиса на близлежащие устройства. Это позволяет им подключиться к вам.
  • MCNearbyServiceBrowser — браузер позволяет искать устройства с помощью MCNearbyServiceAdvertiser . Совместное использование этих двух классов позволяет обнаруживать близлежащие устройства и создавать одноранговые соединения.
  • MCBrowserViewController — предоставляет очень простой пользовательский интерфейс для просмотра служб ближайших устройств (предоставляется через 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 , искать существующее устройство с этим идентификатором и возвращать его, если он будет найден. Если у нас еще нет существующего устройства, мы создаем новое и добавляем его в наш массив устройств.

 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 ; мы могли бы использовать это, чтобы отфильтровать определенные устройства на основе того, что они сделали доступными (это тот же словарь, который мы предоставили в аргументе discoveryInfo для MCNearbyServiceAdvertiser выше).

Подключение устройств

Теперь, когда мы позаботились обо всех хозяйственных делах, мы можем приступить к фактическому подключению устройств.

В методе init нашего MPCSession мы настраиваем как нашего рекламодателя, так и нашего делегата. Когда мы будем готовы начать подключение, нам нужно будет запустить их обоих. Это можно сделать в методе didFinishLaunching делегата приложения или когда это уместно. Вот метод start() , который мы добавим в наш класс:

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

Эти вызовы будут означать, что ваше приложение начнет транслировать свое присутствие через WiFi. Обратите внимание, что вам не нужно подключаться к сети Wi-Fi, чтобы это работало (но вы должны включить его).

Когда устройство отвечает на приглашение и запускает сеанс 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 }

Мы также добавим расширение к устройству для отправки одного из них:

 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 обеспечивает почти беспрепятственное подключение между соседними устройствами, не беспокоясь о сетях Wi-Fi, Bluetooth или сложной гимнастике клиент/сервер. Возможность быстро соединить несколько телефонов для короткого игрового сеанса или подключить два устройства для совместного использования — это типично для Apple.

Исходный код этого проекта доступен на Github по адресу https://github.com/bengottlieb/MultipeerExample.

Разрабатываете iOS, использующую AFNetworking? Шаблон проектирования Model-View-Controller (MVC) отлично подходит для поддерживаемой кодовой базы, но иногда вам нужен один класс для управления вашей сетью из-за таких проблем, как код DRY, централизованное ведение журнала сети и, особенно, ограничение скорости. Прочтите все о том, как справиться с этим с помощью класса Singleton в централизованной и развязанной сети iOS: Учебное пособие по AFNetworking с классом Singleton