The horrors of Multipeer Connectivity and SwiftUI 4

joe_diragi_3bb3b9c26bddca

Joe Diragi

Posted on July 31, 2022

The horrors of Multipeer Connectivity and SwiftUI 4

Multipeer Connectivity is a technology released by Apple at their 2014 (I think) WWDC. Multipeer Connectivity (MPC for short) is a part of Apple's Nearby Interaction framework that aims to make direct communication between devices as seamless sand secure as possible. When I set out to create a rock, paper, scissors game that utilized this framework I thought I'd be in for a mostly painless learning experience. To be fair, if this framework had been updated to support SwiftUI I really would not have had a single problem. From what I read on StackOverflow and the Apple developer forums, this framework is really pretty simple to use. That being said, it is clear the framework was designed to work with UIKit and hasn't been updated in a while.
You might be thinking to yourself, "What does UIKit/SwiftUI have to do with a peer-to-peer communication framework?", and you'd be correct in doing so. The thing is, SwiftUI is also a much different programming paradigm than the old way of doing things. SwiftUI's addition of things like StateObjects and EnvironmentVariables really muddy the water when compared to the way apps written with UIKit were built.
I started building the app with the intention of creating a YouTube tutorial, since I read doing so can be a great way to hone ones skills and potentially build a following and further ones career. I dove right into the documentation and things were going smooth. It turned out there are 2 main ways to go about creating an app that utilizes MPC: use an MCAdvertiserAssistant to handle all of the dirty work with pairing, or use the MCNearbyServiceAdvertiser to implement the logic yourself.
When I read through the docs I was confident my use case only required the use of the simplified MCAdvertiserAssistant, which can be used as simply as:

let peerID = MCPeerID(displayName: "Username")
let session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .none)
let assistant = MCAdvertiserAssistant(serviceType: "rps-service", discoveryInfo: nil, session: session)
assistant.start()
Enter fullscreen mode Exit fullscreen mode

Then, the MCAdvertiserAssistant would take care of the hard work for us. I implemented a nearby device browser like so:

let serviceBrowser = MCNearbyServiceBrowser(peer: peerID, serviceType: "rps-service")
Enter fullscreen mode Exit fullscreen mode

Once that was setup I assigned a delegate to the service browser to find available peers and add them to a @Published property that my view could use to allow the player to invite a peer to a game. Once the other player received the invitation, the MCAdvertiserAssistant would take care of the rest.
I deployed the app on my iPhone and my MacBook, created usernames for both and immediately I could see the other player on my device. It was working great! When I selected the MacBook player from my iPhone, a dialog was displayed almost instantly asking if I wanted to pair with the iPhone and start a game. When I selected yes I was brought into the game and everything was working perfectly.
That was all finished and working in a matter of hours, pairing, peer-to-peer messaging and gameplay done!
Or so I thought. I went back to test pairing in the opposite direction, selected the iPhone from my MacBook and....
Nothing!
Xcode spat out some crazy error regarding the alert that the MCAdvertiserAssistant was trying to display. I spent hours searching through any StackOverflow thread that mentioned MultiPeer Connectivity and SwiftUI. All of the accepted answers were fixes that only worked with UIKit applications, and half of the reason I was creating the tutorial in the first place was to show the basics of SwiftUI development.
Finally it occurred to me that I would have to ditch the MCAdvertiserAssistant and implement my own pairing logic using MCNearbyServiceAdvertiser (🤢).
The MCNearbyServiceAdvertiserDelegate has two functions, one gets called when the advertiser cannot start advertising and the other is called when it receives an invitation from a peer. The problem now was figuring out how to show an alert inside of my view from within my RPSMultipeerSession class.
In SwiftUI, alerts are essentially a property of a view. They are shown like so:

@State var showAlert: Bool = false
...
HStack {
    ...
}
.alert("Title", isPresented: $showAlert) {
    Button("Action 1") {
        ...
    }
}
...
Enter fullscreen mode Exit fullscreen mode

When showAlert in the above example is set to true, the alert is shown. I figured I could use a published variable inside of RPSMultipeerSession to show the alert when an invitation is received inside of the MCNearbyServiceAdvertiserDelegate. That worked just fine, however, in order to accept the invitation, one must call the invitationHandler passed into the function inside of the delegate. In other words, there was no obvious way to accept the invitation from inside of the view.

This is the ugly part. I essentially had to hack together a solution here by creating another published variable in the RPSMultipeerSession class that holds the invitationHandler from the MCNearbyServiceDelegate's didReceiveInvitationFromPeer method.

Here's some code ❤️

The fun part is of course in the MCNearbyServiceAdvertiserDelegate. Specifically this method:

func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void {
    DispatchQueue.main.async {
        self.recvdInvite = true
        self.invitationHandler = invitationHandler
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we update the published variable to alert the UI that we received an invite and it should show an alert. This is what the UI looks like (give or take):

struct PairView: View {
    @StateObject var rpsSession: RPSMultipeerSession

    var body: some View {
        if (!rpsSession.paired) {
            HStack {
                List(rpsSession.availablePeers, id: \.self) { peer in
                    Button(peer.displayName) {
                        rpsSession.serviceBrowser.invitePeer(peer, to: rpsSession.session, withContext: nil, timeout: 20)
                    }
                }
            }
            .alert("Received an invite", isPresented: $rpsSession.recvdInvite) {
                Button("Accept") {
                    if (rpsSession.invitationHandler != nil) {
                        rpsSession.invitationHandler!(true, rpsSession.session)
                    }
                }
            }
        } else {
            GameView()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With all of that in place, pairing finally works correctly both ways between iOS and macOS. If you'd like to take a look at the complete source code, it's on my GitHub. I'll be publishing a full tutorial on YouTube soon, so keep an eye on my page here for updates if you're interested!

💖 💪 🙅 🚩
joe_diragi_3bb3b9c26bddca
Joe Diragi

Posted on July 31, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

What was your win this week?
weeklyretro What was your win this week?

November 29, 2024

Where GitOps Meets ClickOps
devops Where GitOps Meets ClickOps

November 29, 2024