A Practical, Concurrency‑Safe Tutorial (UIKit, Actors, and Real Devices)
Audience: iOS developers who already know UIKit & basic MultipeerConnectivity and want a correct, Swift 6–compliant architecture that survives real devices, real timing, and real bugs.
Why this tutorial exists
Most MultipeerConnectivity (MPC) examples:
- ignore concurrency
- hide delegate chaos behind singletons
- work in the simulator, then fail on devices
- break completely under Swift 6
This tutorial documents a production‑grade approach using:
- Swift 6 actors
- explicit isolation boundaries
- UIKit (not SwiftUI)
- real devices
- deterministic peer identity and state handling
Everything here was built, debugged, and fixed the hard way.
Architectural overview
We separate responsibilities strictly:
UIKit (ViewController)
↓
PeerSessionManager (@MainActor)
↓
MPCActor (actor)
↓
MCSession / Browser / Advertiser (delegates)
Why this matters
- UIKit must stay on the main thread
- MultipeerConnectivity delegates are not main‑safe
- Swift 6 enforces rules older code only implied
Actors are not optional — they are the design.
Core types
PeerSnapshot (immutable, UI‑safe)
struct PeerSnapshot: Identifiable, Equatable {
let id: UUID
let displayName: String
}
This is the only peer object the UI ever sees.
PeerConnectionState
enum PeerConnectionState {
case notConnected
case connecting
case connected
}
Never expose MCSessionState to the UI.
MPCActor (the authority)
The actor owns all mutable MPC state:
- MCPeerID ↔ UUID mapping
- peer records
- discovery lifecycle
- session callbacks
actor MPCActor {
private var uuidByPeerID: [MCPeerID: UUID] = [:]
private var peerIDByUUID: [UUID: MCPeerID] = [:]
private var peersByID: [UUID: PeerRecord] = [:]
func didDiscoverPeer(_ peerID: MCPeerID) -> PeerSnapshot {
if let uuid = uuidByPeerID[peerID],
let snapshot = peersByID[uuid]?.snapshot {
return snapshot
}
let uuid = UUID()
let snapshot = PeerSnapshot(id: uuid, displayName: peerID.displayName)
uuidByPeerID[peerID] = uuid
peerIDByUUID[uuid] = peerID
peersByID[uuid] = PeerRecord(id: uuid, peerID: peerID, snapshot: snapshot)
return snapshot
}
}
Key rule:
UUIDs are created once, inside the actor, and never guessed elsewhere.
PeerSessionManager (@MainActor)
This is the bridge between concurrency and UIKit.
Responsibilities:
- owns UI‑safe arrays
- exposes callbacks
- translates actor events → UI updates
@MainActor
final class PeerSessionManager {
static let shared = PeerSessionManager()
private let mpc = MPCActor()
private(set) var peers: [PeerSnapshot] = []
private(set) var peerStates: [UUID: PeerConnectionState] = [:]
var onPeersUpdated: (() -> Void)?
var onPeerStateChanged: (() -> Void)?
}
Nothing async leaks into UIKit.
Delegate bridging (the critical pattern)
MultipeerConnectivity delegates cannot be actors.
So we bridge safely:
nonisolated func session(
_ session: MCSession,
peer peerID: MCPeerID,
didChange state: MCSessionState
) {
actor.peerStateChangedFromDelegate(peerID, state: state)
}
Inside the actor:
nonisolated func peerStateChangedFromDelegate(
_ peerID: MCPeerID,
state: MCSessionState
) {
Task {
await self.handlePeerStateChange(peerID: peerID, state: state)
}
}
Then:
private func handlePeerStateChange(
peerID: MCPeerID,
state: MCSessionState
) {
guard let uuid = uuidByPeerID[peerID] else { return }
let mapped: PeerConnectionState =
state == .connected ? .connected :
state == .connecting ? .connecting :
.notConnected
emit(.stateChanged(uuid, mapped))
}
This is the Swift 6‑correct flow.
Invitation handling (the subtle bug)
Wrong approach:
Calling invitationHandler(true, session) immediately.
Why it breaks:
- the sender flips to
.connectedtoo early - UI state desyncs
- debugging becomes impossible
Correct approach:
func advertiser(
_ advertiser: MCNearbyServiceAdvertiser,
didReceiveInvitationFromPeer peerID: MCPeerID,
withContext context: Data?,
invitationHandler: @escaping (Bool, MCSession?) -> Void
) {
guard let manager = self.manager,
let session = manager.session else {
invitationHandler(false, nil)
return
}
Task { @MainActor in
if let snapshot = await manager.mpcActorSnapshot(for: peerID) {
manager.invitationReceived(from: snapshot) { accept in
invitationHandler(accept, accept ? session : nil)
}
}
}
}
UI decides. Transport waits.
UITableView state rendering
let peerSnapshot = ps.peers[indexPath.row]
let state = ps.peerStates[peerSnapshot.id] ?? .notConnected
If this returns .notConnected incorrectly, you have:
- duplicate UUIDs
- mismatched snapshot sources
- actor bypassing
Never patch this in the UI.
Common MPC + Swift 6 mistakes (Appendix)
1. Creating UUIDs outside the actor
Symptom: duplicate peers, state never updates
Fix: one source of truth
2. Calling async APIs from delegate synchronously
Symptom: Swift warnings, race conditions
Fix: nonisolated → Task → await
3. Updating UI from the actor
Symptom: random crashes, undefined behavior
Fix: actor emits → manager updates → UI reloads
4. Treating @MainActor as a thread
It is not a queue.
It is a semantic boundary.
5. Fighting Swift 6 diagnostics
The compiler is not being pedantic.
It is preventing bugs you will hit.
Final thoughts
MultipeerConnectivity is not hard.
State ownership is.
Swift 6 didn’t make this worse — it made the bugs visible.
If you respect isolation, identity, and flow direction:
- MPC works
- UI stays correct
- debugging becomes sane
This architecture scales.
Built on real devices. Debugged the hard way.
Appendix: Common MultipeerConnectivity + Swift 6 Mistakes
This appendix exists because MultipeerConnectivity (MPC) predates Swift Concurrency. Swift 6 enforces rules that MPC was never designed for. The result is a long list of compiler errors that sound scary but are actually pointing at very specific architectural problems.
This section translates those errors into plain English, shows why they happen, and explains the correct fix.
⸻
- ❌ “Sending invitationHandler risks causing data races”
What you probably wrote
Task { @MainActor in
invitationHandler(true, session)
}
Why Swift 6 complains
invitationHandler is a non-Sendable escaping closure provided by Apple. It must be called synchronously, exactly once, from the delegate callback.
When you capture it inside a Task, Swift can no longer guarantee:
• when it runs
• which thread runs it
• whether it runs twice
From Swift’s point of view, this is a data race risk.
✅ Correct pattern
Call the handler immediately, then do async work afterwards:
guard let session = manager.session else {
invitationHandler(false, nil)
return
}
invitationHandler(true, session) // synchronous, safe
Task { @MainActor in
// UI updates here
}
Rule: MPC invitation handlers are not async-aware. Treat them like C callbacks.
⸻
- ❌ “Main actor-isolated property cannot be referenced from a nonisolated context”
Where this appears
Delegate methods such as:
• MCSessionDelegate
• MCNearbyServiceAdvertiserDelegate
• MCNearbyServiceBrowserDelegate
Why this happens
MPC delegate methods are:
• not actor-isolated
• called on arbitrary system threads
Swift 6 correctly forbids this:
nonisolated func advertiser(...) {
manager.session // ❌ illegal
}
Because manager.session lives on the @MainActor.
✅ Correct mental model
Think of MPC delegates as interrupt handlers.
They must:
1. Capture data synchronously
2. Forward events elsewhere
3. Do no UI, no actor state access directly
Correct pattern
nonisolated func advertiser(...) {
Task { @MainActor in
manager.handleInvite(...)
}
}
Rule: Delegates → hop → actor / main
⸻
- ❌ Calling async code from sync flow (the “backwards async” bug)
Classic example
func didReceiveInvitation(...) {
let snapshot = await mpc.didDiscoverPeer(peerID) // ❌ impossible
}
Why this is wrong
Delegate callbacks are synchronous entry points. You cannot block them waiting for async work.
Trying to do so:
• breaks MPC timing guarantees
• causes invitations to be rejected
• creates subtle deadlocks
✅ Correct approach
Invert control flow:
nonisolated func advertiser(...) {
invitationHandler(true, session)
Task {
await actor.recordPeer(peerID)
}
}
Rule: Sync system APIs must emit events, not wait for answers.
⸻
- ❌ Creating multiple UUIDs for the same peer
Symptom
• peerStates[peerSnapshot.id] is always nil
• UI never updates
• Multiple UUIDs for the same peer appear in logs
Root cause
You generated PeerSnapshot in multiple layers:
• Browser delegate
• Advertiser delegate
• Session delegate
Each one did:
let snapshot = PeerSnapshot(id: UUID(), ...)
So the same peer had different identities everywhere.
✅ Correct fix
One source of truth: the actor
uuidByPeerID: [MCPeerID: UUID]
peerIDByUUID: [UUID: MCPeerID]
All snapshots come from MPCActor.didDiscoverPeer().
Rule: IDs are born once, owned once.
⸻
- ❌ Letting the actor create MCSession / Advertiser / Browser
Why this fails
These objects:
• have delegates
• expect synchronous callbacks
• are not Sendable
Putting them in an actor causes:
• reentrancy issues
• dropped delegate calls
• connection failures
✅ Correct ownership
Object Owner
MCSession PeerSessionManager
Advertiser PeerSessionManager
Browser PeerSessionManager
State + mapping MPCActor
Rule: Actors own logic, not system objects.
⸻
- ❌ Updating UI from actors or delegates
Wrong
actor.emit { tableView.reloadData() }
Why
UI must:
• run on main thread
• be isolated from concurrency
✅ Correct pipeline
System → Delegate → Actor → Manager → UI
Only the manager talks to UIKit.
⸻
- ❌ Misunderstanding nonisolated
What nonisolated actually means
“This method can be called without actor protection.”
It does not mean:
• thread-safe
• async-safe
• free to access actor state
When to use it
Only for:
• delegate entry points
• forwarding events
And immediately hop away.
⸻
- ❌ Trusting MPC error logs too much
Messages like:
Not in connected state, so giving up...
Are symptoms, not causes.
They usually mean:
• invitation handler called too late
• wrong session passed
• peer identity mismatch
Fix architecture first — logs will disappear.
⸻
MultipeerConnectivity (Apple)
│
├─ MCSession / Advertiser / Browser
│ (Apple-owned, ObjC, delegate-based, non-Sendable)
│
├─ Delegate Bridges (nonisolated)
│ • MCSessionDelegateBridge
│ • MPCAdvertiserDelegateBridge
│ • MPCBrowserDelegateBridge
│
├─ MPCActor (Swift 6 actor)
│ • Owns peer identity
│ • Owns UUID ↔ MCPeerID mapping
│ • Owns peer lifecycle truth
│ • Emits events
│
├─ PeerSessionManager (@MainActor)
│ • Owns MCSession lifetime
│ • Owns advertiser/browser lifetime
│ • Owns UI-facing state (arrays, dictionaries)
│ • Translates actor events → UI callbacks
│
└─ ViewController (UIKit)
• Displays state
• Sends intents (invite, send, etc.)
Final Rule of Thumb
Swift 6 doesn’t make MPC harder — it makes mistakes visible.
If Swift complains:
• it’s usually right
• but the fix is architectural, not syntactic
⸻
https://github.com/swainwri/MPCAndSwift6 for project.
Top comments (0)