Collusion: Local Device Networking using MultipeerConnectivity on iOS

Setting up peer-to-peer communication between devices has historically been a complex task for developers. Applications typically need to discover nearby devices, establish and maintain connections, and handle changes in network conditions. To address these challenges, Apple introduced its MultipeerConnectivity framework (referred to as MPC) in iOS 7 and macOS 10.10. This framework simplifies the process of implementing peer-to-peer connectivity in apps.

MPC provides several key features that streamline the process:

  • Support for various network interfaces like Bluetooth, WiFi, and Ethernet
  • Automatic device discovery
  • Secure communication through encryption
  • Mechanisms for sending small messages
  • Facilitating file transfers

This article primarily focuses on implementing MPC in iOS, though most concepts apply to macOS and tvOS as well.

MultipeerConnectivity Session LifeCycle

While numerous tutorials and examples aim to guide iOS developers in implementing MPC-based applications, they often overlook potential challenges and lack completeness. This article aims to provide a comprehensive walkthrough of a basic MPC app implementation, highlighting common pitfalls to help developers avoid them.

Key Concepts and Classes

MPC relies on several core classes:

  • MCSession: This class manages all communication between connected peers within a session. It enables sending messages, files, and streams, and its delegate handles incoming data from connected peers.
  • MCPeerID: Each peer device in a session is identified by a unique MCPeerID. Although it has an associated name, it’s crucial to note that peer IDs with the same name are not considered identical (refer to “Ground Rules” below).
  • MCNearbyServiceAdvertiser: This class allows a device to advertise its service to nearby devices, enabling them to initiate connections.
  • MCNearbyServiceBrowser: This class enables searching for devices advertising a specific service using MCNearbyServiceAdvertiser. Using these classes together allows for peer discovery and connection establishment.
  • MCBrowserViewController: While this class provides a basic UI for browsing nearby services (advertised via MCNearbyServiceAdvertiser), this article won’t utilize it to highlight MPC’s ability to facilitate seamless peer-to-peer experiences.

Ground Rules

When working with MPC, keep these essential considerations in mind:

  • MCPeerID objects uniquely identify devices. While they may appear as strings, two MCPeerID instances created with the same string are not identical. Therefore, never copy or recreate MCPeerID objects. Instead, pass them within the application or use NSArchiver for storage if needed.
  • Although documentation might suggest otherwise, MCSession can support communication between multiple devices. However, for optimal stability, creating a separate MCSession for each connected peer is recommended.
  • MPC functionality is limited when an application runs in the background. Disconnect and tear down all MCSession instances when your app enters the background. Minimize operations in background tasks to avoid issues.

Getting Started with MultipeerConnectivity

Before establishing a network, some initial setup is required. A singleton will store state variables like the local MCPeerID and connected devices. Then, MCNearbyServiceAdvertiser and MCNearbyServiceBrowser instances will be created. These require a unique service type string (less than 16 characters) to identify your application, such as “MyApp-MyCo”. An optional dictionary can be provided to the advertiser to offer additional information about the service to browsing devices, like game type or device role.

The singleton pattern is well-suited for managing MPC’s system-provided APIs and their interaction with real-world objects (devices and the network).

Below is the singleton definition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

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

The code stores the MCPeerID in user defaults (using NSKeyedArchiver) for reuse. This is crucial to prevent obscure bugs that might arise from recreating the MCPeerID.

A “Device” class will keep track of discovered devices and their states:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

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

}

Now, let’s examine how browsers and advertisers work together. In MPC, a device can advertise a service and browse for the same service on other devices. This approach eliminates the need for a traditional client/server model, allowing for flexible device-to-device communication.

A new method in MPCManager will create and track discovered devices:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

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
}

When a browsing device discovers an advertiser, it can attempt to connect. The MCSession delegate methods will handle incoming requests:

1
2
3
4
5
6
7
8

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

…a method on the Device class will create the MCSession:

1
2
3
4
5
6
7

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

…and finally, a method to trigger the invitation when a browser discovers an advertiser:

1
2
3
4
5
6

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

The withDiscoveryInfo argument, currently ignored, can be used to filter discovered devices based on the information they provide. This dictionary is the same one supplied to the discoveryInfo argument of MCNearbyServiceAdvertiser.

Connecting Devices

With the groundwork laid, it’s time to connect devices.

The MCSession’s init method sets up both the advertiser and its delegate. To start connecting, both need to be activated, ideally in the App delegate’s didFinishLaunching method or another suitable location. Here’s the start() method for the class:

1
2
3
4
5

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

These calls enable the app to broadcast its presence over WiFi. Note that a WiFi connection isn’t necessary; only having WiFi enabled is sufficient.

When a device accepts an invitation and initiates its MCSession, it receives delegate callbacks. These will be handled by the Device object:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

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

}

The session(_:peer:didChangeState:) callback is particularly important. It indicates changes in a device’s connection state (notConnected, connecting, and connected). Tracking these states is essential for maintaining a list of connected devices:

1
2
3
4
5
6

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

Sending Messages

With connected devices, it’s time to exchange messages. MPC offers three options:

  • Sending raw data (a Data object)
  • Sending a file
  • Opening a stream to the other device

For simplicity, this example focuses on sending simple messages without delving into complex message types or formatting. A Codable structure will encapsulate the messages:

1
2
3
4

struct Message: Codable {
  let body: String
}

An extension to the Device class will facilitate sending messages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

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

Conclusions

This article outlined the architecture and steps necessary to build the networking components of a MultipeerConnectivity-based iOS application. The full source code on Github provides a minimal user interface for viewing connected devices and exchanging messages.

MPC enables near-seamless connectivity between nearby devices, abstracting away complexities related to WiFi networks, Bluetooth, or traditional client/server setups. It exemplifies Apple’s design philosophy by enabling quick and straightforward connections between devices for activities like gaming sessions or file sharing.

The source code for this project can be found on Github at https://github.com/bengottlieb/MultipeerExample.

Interested in iOS development with AFNetworking? Explore the Model-View-Controller (MVC) pattern for maintaining a clean codebase. For scenarios requiring centralized networking, logging, or rate-limiting, a Singleton Class might be beneficial. Learn more about this approach in the article iOS Centralized and Decoupled Networking: AFNetworking Tutorial with a Singleton Class.

Licensed under CC BY-NC-SA 4.0