Multipeer Connectivity Tutorial

by @ralfebert · updated November 03, 2021
Xcode 13 & iOS 15
Advanced iOS Developers
English

This tutorial shows how to use the Multipeer Connectivity framework to communicate between iOS devices. The example shows how to implement the ConnectedColors example app, which synchronizes a color value between multiple devices.

The Multipeer Connectivity framework provides a layer on top of the Bonjour protocol. Under the hood, the framework automatically chooses a suitable networking technology - Wi-Fi if both devices are on the same Wi-Fi network, Peer-to-peer Wi-Fi or Bluetooth.

This is a tutorial for advanced iOS developers. It requires practical Swift programming skills and good iOS development knowledge.

Project setup

  1. Use the latest Xcode (this tutorial was last tested with Xcode 13 on October 31, 2021).

  2. Create a new iOS App ConnectedColors based on SwiftUI.

Advertising and browsing for the Service

  1. Create a new class ColorMultipeerSession for all the connectivity code.

  2. Use the following code as a starting point - it's just all the boilerplate code for the delegate handling needed - it does nothing but advertise the service and log all the events.

    The serviceType identifies the service (it must be a unique string, at most 15 characters long and can contain only ASCII lowercase letters, numbers and hyphens)

    The ↗ MCPeerID will uniquely identify the device; the displayName will be visible to other devices.

    The ↗ MCNearbyServiceAdvertiser advertises the service.

    The ↗ MCNearbyServiceBrowser looks for the service on the network.

    The ↗ MCSession manages all the connected devices (peers) and allows sending and receiving messages.

    import MultipeerConnectivity
    import os
    
    class ColorMultipeerSession: NSObject, ObservableObject {
        private let serviceType = "example-color"
    private let myPeerId = MCPeerID(displayName: UIDevice.current.name)
    private let serviceAdvertiser: MCNearbyServiceAdvertiser
    private let serviceBrowser: MCNearbyServiceBrowser
    private let session: MCSession
        private let log = Logger()
    
        override init() {
            session = MCSession(peer: myPeerId, securityIdentity: nil, encryptionPreference: .none)
            serviceAdvertiser = MCNearbyServiceAdvertiser(peer: myPeerId, discoveryInfo: nil, serviceType: serviceType)
            serviceBrowser = MCNearbyServiceBrowser(peer: myPeerId, serviceType: serviceType)
    
            super.init()
    
            session.delegate = self
            serviceAdvertiser.delegate = self
            serviceBrowser.delegate = self
    
            serviceAdvertiser.startAdvertisingPeer()
            serviceBrowser.startBrowsingForPeers()
        }
    
        deinit {
            serviceAdvertiser.stopAdvertisingPeer()
            serviceBrowser.stopBrowsingForPeers()
        }
    }
    
    extension ColorMultipeerSession: MCNearbyServiceAdvertiserDelegate {
        func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) {
            log.error("ServiceAdvertiser didNotStartAdvertisingPeer: \(String(describing: error))")
        }
    
        func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
            log.info("didReceiveInvitationFromPeer \(peerID)")
        }
    }
    
    extension ColorMultipeerSession: MCNearbyServiceBrowserDelegate {
        func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) {
            log.error("ServiceBrowser didNotStartBrowsingForPeers: \(String(describing: error))")
        }
    
        func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
            log.info("ServiceBrowser found peer: \(peerID)")
        }
    
        func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
            log.info("ServiceBrowser lost peer: \(peerID)")
        }
    }
    
    extension ColorMultipeerSession: MCSessionDelegate {
        func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
            log.info("peer \(peerID) didChangeState: \(state.rawValue)")
        }
    
        func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
            log.info("didReceive bytes \(data.count) bytes")
        }
    
        public func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
            log.error("Receiving streams is not supported")
        }
    
        public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
            log.error("Receiving resources is not supported")
        }
    
        public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
            log.error("Receiving resources is not supported")
        }
    }
    
  3. Create a ColorMultipeerSession instance as @StateObject in the ContentView:

    class ContentView: View {
        @StateObject var colorSession = ColorMultipeerSession()
    
        // ...
    
    }
    
  4. In the Info.plist, add the Bonjour services key with the following two values (the service type name needs to match!)

  5. Run the app (simulator or device).

  6. Optionally, check that the service is correctly advertised on the local network: → Listing all Bonjour services on the local network

Inviting and receiving invitations between peers

  1. When a peer is discovered, invite it. When you receive an invitation, accept it. Implement the ObservableObject protocol and keep a @Published property with a list of the connected peers. Update it when the state of a peer changes:

    class ColorMultipeerSession: NSObject, ObservableObject {
        // ...
    
        @Published var connectedPeers: [MCPeerID] = []
    }
    
    extension ColorMultipeerSession: MCNearbyServiceBrowserDelegate {
    
        func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
            log.info("ServiceBrowser found peer: \(peerID)")
            browser.invitePeer(peerID, to: session, withContext: nil, timeout: 10)
        }
    
        // ...
    
    }
    
    extension ColorMultipeerSession: MCNearbyServiceAdvertiserDelegate {
    
        func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
            log.info("didReceiveInvitationFromPeer \(peerID)")
            invitationHandler(true, session)
        }
    
        // ...
    
    }
    
    extension ColorMultipeerSession: MCSessionDelegate {
    
        func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
            log.info("peer \(peerID) didChangeState: \(state.rawValue)")
            DispatchQueue.main.async {
        connectedPeers = session.connectedPeers
    }
        }
    
        // ...
    }
    

    Note: This code invites any peer automatically. The MCBrowserViewController class could be used to scan for peers and invite them manually.

    Also, this code accepts all incoming connections automatically. This would be like a public chat and you need to be very careful to check and sanitize any data you receive over the network as you cannot trust the peers. To keep sessions private the user should be notified and asked to confirm incoming connections. This can be implemented using the MCAdvertiserAssistant class.

Sending and receiving color values

  1. Add a property currentColor and update it when a message is received. Offer a method to send a new color value. When you receive a value from the MCSessionDelegate, decode it and update the currentColor property. The following example uses an enum to encode and decode the values:

    enum NamedColor: String, CaseIterable {
        case red, green, yellow
    }
    
    class ColorMultipeerSession: NSObject, ObservableObject {
    
        // ...
    
        @Published var currentColor: NamedColor? = nil
    
        /// ...
    
        func send(color: NamedColor) {
        log.info("sendColor: \(String(describing: color)) to \(self.session.connectedPeers.count) peers")
        self.currentColor = color
    
        if !session.connectedPeers.isEmpty {
            do {
                try session.send(color.rawValue.data(using: .utf8)!, toPeers: session.connectedPeers, with: .reliable)
            } catch {
                log.error("Error for sending: \(String(describing: error))")
            }
        }
    }
    
    }
    
    extension ColorMultipeerSession: MCSessionDelegate {
    
        // ...
    
        func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        if let string = String(data: data, encoding: .utf8), let color = NamedColor(rawValue: string) {
            log.info("didReceive color \(string)")
            DispatchQueue.main.async {
                self.currentColor = color
            }
        } else {
            log.info("didReceive invalid value \(data.count) bytes")
        }
    }
    }
    

Updating the UI

  1. Implement a View that displays the names of currently connected devices, uses the current color as background and allows to send values:

    struct ContentView: View {
        @StateObject var colorSession = ColorMultipeerSession()
    
        var body: some View {
            VStack(alignment: .leading) {
                Text("Connected Devices:")
                Text(String(describing: colorSession.connectedPeers.map(\.displayName)))
    
                Divider()
    
                HStack {
                    ForEach(NamedColor.allCases, id: \.self) { color in
                        Button(color.rawValue) {
                            colorSession.send(color: color)
                        }
                        .padding()
                    }
                }
                Spacer()
            }
            .padding()
            .background((colorSession.currentColor.map(\.color) ?? .clear).ignoresSafeArea())
        }
    }
    
    extension NamedColor {
        var color: Color {
            switch self {
            case .red:
                return .red
            case .green:
                return .green
            case .yellow:
                return .yellow
            }
        }
    }
    
  2. Run the updated app on two devices and test the connection.

More information