The horrors of Multipeer Connectivity and SwiftUI 4
Joe Diragi
Posted on July 31, 2022
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 StateObject
s and EnvironmentVariable
s 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()
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")
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") {
...
}
}
...
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
}
}
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()
}
}
}
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!
Posted on July 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.