Collusion: Nearby Device Networking with MultipeerConnectivity in iOS

View all articles

Traditionally, connecting devices for peer-to-peer communications has been a bit of a heavy lift. An application needs to discover what’s around it, open connections on both sides, and then maintain them as network infrastructure, connections, distances, etc, all change. Realizing the difficulties inherent in these activities, in iOS 7 and macOS 10.10 Apple introduced its MultipeerConnectivity framework (henceforth MPC), designed to allow apps to perform these tasks with relatively low effort.

MPC takes care of much of the underlying required infrastructure here:

  • Multiple network interface support (Bluetooth, WiFi, and ethernet)
  • Device detection
  • Security via encryption
  • Small message passing
  • File transfer

In this article, we’ll mainly be addressing the iOS implementation, but most, if not all of this, is applicable to macOS and tvOS.

MultipeerConnectivity Session LifeCycle

Multipeer Session LifeCycle:

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

Refer back to this image from time-to-time

There are numerous MultipeerConnectivity tutorials and examples out there that purport to walk iOS developers through the implementation of an MPC-based application. However, in my experience, they’re usually incomplete and tend to gloss over some important potential stumbling blocks with MPC. In this article, I hope to both walk the reader through a rudimentary implementation of such an app and call out areas where I’ve found it easy to get stuck.

Concepts & Classes

MPC is based on a handful of classes. Let’s walk through the list of the common ones, and build up our understanding of the framework.

  • MCSession – A session manages all communications between its associated peers. You can send messages, files, and streams via a session, and its delegate will be notified when one of these is received from a connected peer.
  • MCPeerID – A peer ID lets you identify individual peer devices within a session. It’s got a name associated with it, but be careful: peer IDs with the same name are not considered identical (see Ground Rules, below).
  • MCNearbyServiceAdvertiser – An advertiser allows you to broadcast your service name to nearby devices. This lets them connect to you.
  • MCNearbyServiceBrowser – A browser lets you search for devices using MCNearbyServiceAdvertiser. Using these two classes together allows you to discover nearby devices and create your peer-to-peer connections.
  • MCBrowserViewController – This provides a very basic UI for browsing nearby device services (vended via MCNearbyServiceAdvertiser). While suitable for some use cases, we won’t be using this, as, in my experience, one of the best aspects of MCP is its seamlessness.

Ground Rules

There are a couple of things to keep in mind when constructing an MPC network:

  • Devices are identified by MCPeerID objects. These are, superficially, wrapped strings, and in fact, can be initialized with simple names. Though two MCPeerIDs may be created with the same string, they are not identical. Thus, MCPeerIDs should never be copied or re-created; they should be passed around within the application. If necessary, they can be stored using an NSArchiver.
  • While the documentation on it is lacking, MCSession can be used to communicate between more than two devices. However, in my experience, the stablest way to utilize these objects is to create one for each peer your device is interacting with.
  • MPC will not work while your application is in the background. You should disconnect and tear down all of your MCSessions when your app is backgrounded. Don’t try and do more than minimal operations in any background tasks.

Getting Started with MultipeerConnectivity

Before we can establish our network, we need to do a little housekeeping, and then set up the advertiser and browser classes to discover other devices that we can communicate with. We’re going to create a singleton that we’ll use to hold a few state variables (our local MCPeerID and any connected devices), then we’ll create MCNearbyServiceAdvertiser and MCNearbyServiceBrowser. These last two objects need a service type, which is just a string identifying your application. It needs to be less than 16 characters and should be as unique as possible (ie, “MyApp-MyCo”, not “Multipeer”). We can specify a (small) dictionary to our advertiser than browsers can read to give a bit more information when looking at nearby devices (perhaps a game type or device role).

Since MPC relies on system-provided APIs and correlates to real-world objects (other devices, as well as the shared “network” between them), it’s a good fit for the singleton pattern. While frequently overused, singletons are a good fit for shared resources such as this.

Here’s the definition of our 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
  }
}

Note that we’re storing our MCPeerID in user defaults (by way of NSKeyedArchiver), and re-using it. As mentioned above, this is important, and failure to cache it in some way can cause obscure bugs farther down the line.

Here’s our Device class, which we’ll use to keep track of what devices have been discovered, and what their state is:


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 that we’ve got our initial classes built out, it’s time to step back and think about the interplay between browsers and advertisers. In MPC, a device can advertise a service it offers, and it can browse for a service it’s interested in on other devices. Since we’re focused on device-to-device communication using just our app, we’ll both advertise and browse for the same service.

In a traditional client/server configuration, one device (the server) would advertise its services, and the client would browse for them. Since we’re egalitarian, we don’t want to have to specify roles for our devices; we’ll have every device both advertise and browse.

We need to add a method to our MPCManager to create devices as they’re discovered and track them in our devices array. Our method will take an MCPeerID, look for an existing device with that ID, and return it if found. If we don’t already have an existing device, we create a new one and add that to our device array.


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
}

After a device has started advertising, another browsing device can attempt to attach to it. We’ll need to add delegate methods to our MPCSession class to handle incoming delegate calls from our advertiser in this case:


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 our Device to create the MCSession:


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 our browser discovers an advertiser:


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

Right now, we’re ignoring the withDiscoveryInfo argument; we could use this to filter out particular devices based on what they’ve made available (this is the same dictionary we supplied in the discoveryInfo argument to MCNearbyServiceAdvertiser, above).

Connecting Devices

Now that we’ve got all of our housekeeping taken care of, we can start the actual business of connecting devices.

In our MPCSession’s init method we set up both our advertiser and our delegate. When we’re ready to start connecting, we’ll need to start both of them up. This can be done in the App delegate’s didFinishLaunching method, or whenever is appropriate. Here’s the start() method we’ll add to our class:


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

These calls will mean your app will begin broadcasting its presence over WiFi. Note that you don’t need to be connected to a WiFi network for this to work (but you do have to have it turned on).

When a device responds to an invitation and starts its MCSession, it will start receiving delegate callbacks from the session. We’ll add handlers for those to our device object; most of them we’ll ignore for the time being:


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

}

For the time being, we’re mainly concerned with the session(_:peer:didChangeState:) callback. This will be called whenever a device transitions to a new state (notConnected, connecting, and connected). We’ll want to keep track of this so that we can build a list of all connected devices:


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

Sending Messages

Now that we’ve got all of our devices connected, it’s time to actually start sending messages back and forth. MPC offers three options in this regard:

  • We can send a block of bytes (a data object)
  • We can send a file
  • We can open a stream to the other device

For the sake of simplicity, we’ll only be looking at the first of these options. We’ll send simple messages back and forth, and not worry too much about the complexities of message types, formatting, etc. We’ll use a Codable structure to encapsulate our message, which will look like this:


struct Message: Codable {
  let body: String
}

We’ll also add an extension to Device to send one of these:


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
}

Conclusions

This article covers the architecture required to build out the networking components of a MultipeerConnectivity based application. The full source code (available on Github) offers a minimal user-interface wrapper that allows you to view connected devices, and send messages between them.

MPC offers near-seamless connectivity between nearby devices without needing to worry about WiFi networks, Bluetooth, or complex client/server gymnastics. Being able to quickly pair up a few phones for a short gaming session, or connect two devices for sharing, is done in typical Apple fashion.

The source code for this project is available on Github at https://github.com/bengottlieb/MultipeerExample.

Designing an iOS that uses AFNetworking? The Model-View-Controller (MVC) design pattern is great for a maintainabe codebase, but sometimes you need a single class to handle your networking due to concerns like DRY code, centralized networking-logging and, especially, rate-limiting. Read all about handling this with a Singleton Class in iOS Centralized and Decoupled Networking: AFNetworking Tutorial with a Singleton Class

Understanding the Basics

What is a peer-to-peer application?

A networked app that is able to connect to other instances (peers) without needing a server or other intermediary.

About the author

Ben Gottlieb, United States
member since March 22, 2018
Ben has been an iOS developer for the entire life of the platform and has worked on mobile devices for more than 20 years. He designed, built, and has maintained the most popular Crosswords app in Apple's AppStore for the last 10 years. He's done work on Apple's foundation OS code, designed Salesforce's internal REST framework, and written large-scale deployed apps for companies like Home Depot and Zimmer. [click to continue...]
Hiring? Meet the Top 10 Freelance iOS Developers for Hire in November 2018

Comments

Vadim Dagman
Great article Ben! I've used MC a lot and had a lot of trouble with it in the early days. Caching MCPeerID is in interesting idea and, now when I think of it, should probably address the problem of rouge, unmanaged peers floating around from previous sessions and causing havoc. I've also used the "symmetrical" approach you are suggesting where all peers are advertisers and browsers at the same time and there is one and only one active session between every two peers. But have you run into a problem when they race to connect to each other both ways? In my experience that was an issue and I had to keep track if peers are already connected to reject the request to connect in the opposite direction.
Ben Gottlieb
In the early days of MPC, there was definitely a race condition problem where devices would try to invite each other. I believe we would do stuff like compare hashes of the two devices, and have the greater one do the actual invite. However, this seems to no longer be an issue (not sure when it was fixed). I call browser.invitePeer() without concern for who's doing the inviting vs. who's being invited.
Vadim Dagman
That's great to know (and yes, I did exactly that - compared hashes to determine the roles :) ). Thank you!
comments powered by Disqus
Subscribe
Free email updates
Get the latest content first.
No spam. Just great articles & insights.
Free email updates
Get the latest content first.
Thank you for subscribing!
Check your inbox to confirm subscription. You'll start receiving posts after you confirm.
Trending articles
Relevant Technologies
About the author
Ben Gottlieb
Swift Developer
Ben has been an iOS developer for the entire life of the platform and has worked on mobile devices for more than 20 years. He designed, built, and has maintained the most popular Crosswords app in Apple's AppStore for the last 10 years. He's done work on Apple's foundation OS code, designed Salesforce's internal REST framework, and written large-scale deployed apps for companies like Home Depot and Zimmer.