Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

Protocols, Delegates, and the Weak Reference That Vanishes

~15 min · swift-for-apple, protocols, delegates, arc, objc-runtime

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Nothing arrived. No activation, no message, no error. The delegate had simply stopped existing."

The Delegate Pattern Is Everywhere

Apple frameworks talk back to your code through delegates: an object you hand to a framework object, conforming to a protocol, whose methods the framework calls when something happens. NSApplicationDelegate, NSWindowDelegate, URLSessionDataDelegate, WCSessionDelegate, UNUserNotificationCenterDelegate — every track after this one leans on the pattern. In Swift the contract is a protocol, and a class-bound protocol (: AnyObject) is what lets the framework hold it weakly.

Weak, by Design

Swift memory is reference counted. If a session held its delegate strongly and the delegate held the session, neither would ever be freed. So most Cocoa delegates are declared weak — WatchConnectivity's header says it plainly: @property (nonatomic, weak, nullable) id <WCSessionDelegate> delegate. URLSession is the well-known exception: it retains its delegate until the session is invalidated. A weak reference does not keep its object alive. When the last strong reference goes away, the weak one becomes nil.

That is exactly how a family watch app went deaf. It installed a small forwarding delegate in front of the kit's own delegate, created inside an install() function and never stored. The function returned, the relay deallocated, WCSession.default.delegate read nil from then on, and nothing arrived — no activation callback, no transfers, no messages, and no error anywhere. The fix was one line: keep a strong reference for the life of the process. The test that pins it now asserts the session's delegate is the relay at launch.

One Delegate Slot, Not a List

A delegate property holds one object. Assign another and the first is silently unseated. In the same watch pairing, the app later set itself as the session delegate to receive a small settings payload — and the kit's delegate, the only place that acknowledged sent captures, stopped receiving anything. The watch never let go of payloads the phone already had. When a shared component owns a delegate, read what it exposes instead of taking the slot.

The Objective-C Runtime Still Builds Some of Your Objects

When a framework instantiates your class from a type — SwiftUI's @UIApplicationDelegateAdaptor building your app delegate is the family's example — it goes through the Objective-C -init slot. A subclass of an NSObject class that declares its own designated initializer needs an @objc init() to fill that slot. A plain Swift init() compiles, never fills it, and the app traps at launch.

Code

A weak delegate installed from a function is gone before it is used·swift
protocol CaptureSinkDelegate: AnyObject {
    func sink(didStore id: String)
}

final class CaptureSink {
    weak var delegate: (any CaptureSinkDelegate)?      // weak: the sink does not own it
    func store(_ id: String) { delegate?.sink(didStore: id) }
}

final class ConsoleLogger: CaptureSinkDelegate, Sendable {   // no mutable state: safe to share
    func sink(didStore id: String) { print("stored", id) }
}

func installLogger(on sink: CaptureSink) {
    let logger = ConsoleLogger()
    sink.delegate = logger          // compiles without a warning
}                                   // logger deallocates here

let sink = CaptureSink()
installLogger(on: sink)
sink.store("a")                     // prints nothing, reports nothing
print(sink.delegate as Any)         // nil

// Fix: someone owns the delegate for as long as it must answer.
enum Relays { static let logger = ConsoleLogger() }
sink.delegate = Relays.logger
sink.store("b")                     // stored b

External links

Exercise

Add CaptureSink and ConsoleLogger to Spark and reproduce the vanishing delegate: install it from a function and show that nothing prints. Then fix it two ways — a static owner and an owning object that holds both the sink and its delegate. Finally, write a test that fails if the delegate is nil right after your app's launch path runs.
Hint
In a test you can assert sink.delegate != nil after calling the same install function the app calls. The point is to pin the ownership, because the failure itself produces no symptom to test.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.