Be Careful When You Initialize a State Object

I’m going to share some best practices when using @StateObject property wrappers, things learned the hard way, via some bugs that were difficult to diagnose and nearly impossible to notice during code review—unless one knows what to look for.

The short version is this: if you have to explicitly initialize a @StateObject, pay close attention to the fact that the property wrapper’s initialization parameter is an escaping closure called thunk, not an object called wrappedValue. Do all the wrapped object initialization and prep inside the closure, or else you’ll undermine the performance benefits that likely motivated you to use @StateObject in the first place.

Several years ago, before the @StateObject property wrapper was introduced, if your SwiftUI view needed to create and own an object to perform view-specific duties that can only be performed by a reference type (say, to coordinate some Combine publishers), the only option was an @ObservedObject:

struct MyView: View {
    @ObservedObject private var someObject = SomeObject()
}

A chief problem with this API is that the wrapped object’s initializer (in this example the = SomeObject()) would be run every time MyView, the struct, was initialized. Since this view is just a child of some other ancestor view, any time the ancestor’s body property gets accessed, MyView will be initialized anew, causing SomeObject() to be initialized again and again:

struct SomeAncestor: View {
    var body: some View {
        MyView() <-- gets invoked anytime SomeAncestor.body is read
    }
}

Remember that a SwiftUI View is not the view object you see on screen, but rather just a template describing the view object that will be created for you at a later time. Since the body property of a view returns merely a template, the guts of the SwiftUI framework operate under the assumption that a body can be accessed as many times as needed to recompute these templates.

To prevent unwanted successive initialization of wrapped objects, the @StateObject property wrapper was introduced. It is often a simple drop-in replacement for an @ObservedObject:

struct MyView: View {
    @StateObject private var someObject = SomeObject()
}

With this change, anytime SwiftUI traverses the view hierarchy, recursively calling into body property after body property, if possible, the storage mechanism within the @StateObject property wrapper will be carried forward to the new view struct without causing the wrapped object to be initialized again. It’s a bit magical, but honestly a bit too magical, since what I’ve just described contains two hidden details that need close attention.

First, when I wrote “…if possible…” in the previous paragraph, I was referring to the fact that SwiftUI needs to be able to make the determination that two instances of a given view struct should be interpreted as being templates for the exact same on-screen view object. The term for this concept is “identity”. Two View structs are understood to have the same identity if they share the same identifier. This identifier can be either explicit, or implied.

Explicit identification looks like this:

struct SomeAncestor: View {
    var body: some View {
        MyView()
            .id("the-one-true-view")
    }
}

Implicit identification is harder to grok. Sometimes it can be inferred from the combination of an Identifiable model in conjunction with a ForEach:

struct Thing: Identifiable {
    let id: String <--- required
    let name: String
}
struct SomeAncestor: View {
    let stuff: [Thing]
    var body: some View {
        ForEach(stuff) { thing in
            MyView(thing)
        }
    }
}

In the above example, the particular init method of the ForEach accepts a collection of Identifiable model values, which allows the guts of the ForEach body to assign identifiers to each MyView, automatically on your behalf, using the id properties of the model values. Here’s that initializer from SwiftUI’s public interface:

extension ForEach 
where ID == Data.Element.ID, 
Content : AccessibilityRotorContent,
 Data.Element : Identifiable 
 {
    init(
        _ data: Data, 
        @AccessibilityRotorContentBuilder content: @escaping (Data.Element) -> Content
    )
}

SwiftUI has other mechanisms to try to infer identity, but if you’re not explicitly providing identity for a view that owns a @StateObject, it’s possible that your wrapped object is getting intialized more often than you desire. Setting breakpoints at smart places (like the init() method of your wrapped object) is a helpful place to look.

I wrote that there were two hidden details hiding in the magic of @StateObject. I just described the first one, it’s hidden reliance on view identity, but there is another issue that’s particularly subtle, and that’s the mechanism by which it’s possible for a @StateObject to avoid duplicate initializations of its wrapped object. The best way to see it is by looking at the public interface:

@propertyWrapper 
struct StateObject<ObjectType> : DynamicProperty 
where ObjectType : ObservableObject 
{
    init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
}

Look closely: the initialization parameter is an escaping closure called thunk, marked with the @autoclosure label. The parameter is not the object type itself. You might have assumed, like I did at first, that the initalizer looked like this:

@propertyWrapper 
struct StateObject<ObjectType> : DynamicProperty 
where ObjectType : ObservableObject 
{
    init(wrappedValue: ObjectType)
}

This might seem like an academic distinction, until you run into a situation where your view needs to explicitly initialize the @StateObject property wrapper. If you aren’t careful, it’s possible to completely undermine the benefits of StateObject.

Consider this example, which is similar to something that I actually had to do in some production code. Let’s say that I have a podcast app with a view that displays an episode’s download progress:

struct DownloadProgressView: View {
    ...
}

My app uses CoreData, so I have a class Episode that’s a managed object. It has some properties, among others, that track download progress for that episode:

class Episode: NSManagedObject {
    @NSManaged var bytesDownloaded: Int
    @NSManaged var bytesExpected: Int
    ...
}

I need my view to update in response to changes in those properties (and let’s say, for reasons outside the scope of this post, it isn’t possible to migrate this app to SwiftData, because it’s not ready for the limelight), which means I need to use KVO to observe the bytesDownloaded and bytesExpected properties. Since I can’t do that observation from my DownloadProgressView directly, I’ll need an intermediate object that sits between the managed object and the view:

class DownloadProgressObserver: NSObject, ObservableObject {
    @Published private(set) var progress = 0
    init(episode: Episode) {
        super.init()
        startObserving(episode)
    }
}

The only thing left is to update my view to use this new class. Since nothing else in my app needs this intermediate object, it’s sensible for my view itself to be what creates and owns it, just for the lifetime of my view being on screen. Sounds like a @StateObject is a good fit:

struct DownloadProgressView: View {
    @StateObject private var observer = DownloadProgressObserver(episode: WHAT_GOES_HERE)
    ...
}

OK, so I cannot use a default value to populate the observer, because my observer has a required initialization argument that cannot be obtained until runtime. So I need to provide an explicit initializer for my view:

struct DownloadProgressView: View {
    @StateObject private var observer: DownloadProgressObserver
    
    init(_ episode: Episode) {
        let observer = DownloadProgressObserver(episode: episode)
        _observer = StateObject(wrappedValue: observer)
    }
}

Looks great, right? Actually, it’s really bad. Because I initialize the observer object as a separate statement, and pass a local variable as the wrappedValue, it is (sort-of, in a pseudocode-y way) equivalent to the following code:

let observer = DownloadProgressObserver(episode: episode)
_observer = StateObject(thunk: { observer })

Remember that the initialization parameter is an escaping closure called thunk. This closure is only ever run once for the lifetime that my DownloadProgressView’s associated UI object is displayed on screen. The instance returned from the one-time execution of thunk() is what gets supplied to the SwiftUI views. But my DownloadProgressView’s initializer will be run many, many times, as often as SwiftUI needs. Each time, except for the very first initialization, all those DownloadProgressObserver objects that my init body is creating end up getting discarded as soon as they’re created. If anything expensive happens inside of DownloadProgressObserver.init(episode:), that’s a ton of needless work that could degrade performance at best, or at worst, could introduce unwanted side effects (like mutating some state somewhere else).

The only safe and correct way to explicitly initialize a @StateObject is to place your wrapped object’s initialization inside the autoclosure:

struct DownloadProgressView: View {
    @StateObject private var observer: DownloadProgressObserver
    
    init(_ episode: Episode) {
        _observer = StateObject(wrappedValue:
            DownloadProgressObserver(episode: episode)
        )
    }
}

That ensures that the object is initialized exactly once. This is particularly important to remember if preparing your wrapped object requires several statements:

struct MyView: View {
    @StateObject private var tricky: SomethingTricky
    
    init(_ model: Model, _ baz: Baz) {
        _tricky = StateObject(wrappedValue: {
            let foo = Foo(model: model)
            let bar = Bar(model: model)
            let tricky = SomethingTricky(
                foo: foo,
                bar: bar,
                baz: baz
            )
            return tricky
        }())
    }
}

It would be natural to want to write all those statements in the main init body, and then pass the tricky instance as the wrappedValue: tricky parameter, but that would be wrong.

Hopefully I’ve just saved you an hour (or more) of fretful debugging. Apple, to their credit, did include warnings and gotchas in their documentation, which I could have read before hand, but didn’t think to read it:

If the initial state of a state object depends on external data, you can call this initializer directly. However, use caution when doing this, because SwiftUI only initializes the object once during the lifetime of the view — even if you call the state object initializer more than once — which might result in unexpected behavior. For more information and an example, see StateObject.

Side note: this whole debacle I created for myself points out the risks of using @autoclosure parameters. Unless one pauses on a code completion and jumps into the definition, it’s very, very easy to mistake an autoclosure for a plain-old parameter. An end user like me is not entirely to blame for my mistake, given how most (all?) other property wrappers in SwiftUI do not use autoclosures.

|  14 Mar 2024




Scaled Metric Surprises on iOS & iPadOS

UIKit’s UIFontMetrics.scaledValue(for:) and SwiftUI’s @ScaledMetric property wrapper offer third-party developers a public means to scale arbitrary design reference values up and down relative to dynamic type size changes. Please be aware, however, that scaled values are not scaled proportionally with the default point sizes of the related text style. They scale according to some other scaling function that differs considerably from the related text style. An example will help illustrate this. Consider the following code:

let metrics = UIFontMetrics(forTextStyle: .body)
let size = metrics.scaledValue(for: 17.0)

If the device is set to the .large dynamic type setting, the identity value is returned (size == 17.0). If you then downscale the device’s dynamic type size setting to .medium, one might expect the returned value to be 16.0. After all, the default system .body font size at .large is exactly 17.0, and the default .body font size at .medium is exactly 16.0. But instead, this is what happens:

// current dynamic type size setting is .medium...
let metrics = UIFontMetrics(forTextStyle: .body)
let size = metrics.scaledValue(for: 17.0)
// size == 16.333...

The divergence is even more pronounced the further up/down the dynamic type size range one goes:

The red text in each pairing the above is the system default .body font, and the black text is a system body font obtained using a ScaledMetric:

@ScaledMetric(relativeTo: .body) var scaled = 17.0
var body: some View {
    Text("Quick, brown fox.")
        .foregroundStyle(.red)
        .font(.body)
        .overlay {
            Text("Quick, brown fox.")
                .foregroundStyle(.black)
                .font(.system(size: scaled))
        }
}

So if you need to scale a bit of UI in exact proportion to the .body font size, avoid using ScaledMetric/UIFontMetrics scaling APIs and instead directly obtain the pointSize from a system body font and use that value instead:

UIFont.preferredFont(
    forTextStyle: .body,
    compatibleWith: traitCollection
).pointSize

(EDITED: An earlier version of this post included an erroneous assumption that has been living rent-free in my brain for years. Thanks to Matthias for the clarification.)

|  2 Mar 2024




Dagmar Chili Pitas & Doxowox: Now With Slightly-Less Unofficial Mirrors

It is with great pleasure that I share that the following two Internet classics have been brought back to life at their own blessed domain names, with TLS and everything, honest to God 21st century websites:

Reader please note: these are not my writings, but I am honored to steward their continued presence on the Internet. They are works of art and deserve grace and aesthetic elbow room.

{}oe|e|ep[]

|  29 Oct 2023




Deep Fake

(The following is an excerpt from a short story. Read the whole thing here.) I have reposted this November 2022 since our dear dear Elon Musk has nudged this work of fiction one more notch closer to reality.

On weekday mornings the Safespace product leadership gathers in an airy conference room for triage. Usually it’s forgettable stuff, scaling issues with a server cluster or the homepage stumbling over a program error, but today’s different. Ralgo, the founder and CEO of Safespace, mounts the dais and says there’s good news and bad news. The bad news is Vencent, our senior Distressing Content Moderator, jumped off a balcony in the rotunda and burst open on the foosball table. The good news is Vencent’s replacement is already lined up so none of our deadlines will slip.

The news of Vencent’s suicide lands on the leadership team in uneven ways. Glarry is sobbing with Aimie in the Quality Assurance pit, grieving the loss of their Borg LARPing comrade. Stavros in Image Analysis says nothing, dons headphones and resumes tweaking the scrotal recognition model. As for me, well, I suspect I’m in for a reaming when Ralgo tells me to meet him in the Privacy Cylinder.

Vencent’s death marks the third time I’ve lost a Distressing Content Moderator in the last eighteen months. The previous one flung himself in front of the Palo Alto BART, and the one before that self-immolated at the annual company acid trip, taking an investor’s dog with her. I thought I’d never stop being the butt of the What’s your burn rate? wisecracks. Content moderation is a rough job, don’t get me wrong. A moderator has to sit in front of a grid of displays all day, double-checking the media that our A.I. flags as too disgusting, incendiary, or violent for human consumption. There’s gonna be some turnover. But the spate of suicides is a big problem for me personally because I’m the corporate Vice President of Spiritual Health and, at least on paper, I’m supposed to be the load-bearing wall that keeps morale from caving so badly.

Ralgo seals the red anodized walls of the Privacy Cylinder around the two of us and tears into me. He says it would be cheaper to install jumper nets in the rotunda than it would be to keep me on staff another six months. He wants to see Vencent’s spiritual performance review notes. I tell him I don’t have any. I’ve stopped taking notes because the distraction interferes with intersoul harmonics. Oh boy, does he ever not like hearing that.

“W. T. Fuck?” he says. “I’ve got senators crawling up my ass over these suicides, and the investors are spooked another public incident will tank the valuation. If they hear about Vencent and come snooping and we don’t have a liability paper trail, we’re one-hundred percent fucked. Do you hear me? Eternally. By thorny cocks.”

I vow that I will recommit myself to diligent notekeeping and he says he’ll believe it when he sees it. I ask him what he meant during standup RE: the good news about Vencent’s replacement.

“About that,” he says. “I’ve hired a Deep Phakes consultant to replace Vencent. And don’t get pissy that I didn’t loop you into the candidate selection process, there wasn’t one. Their salespeople won’t allow it.”

The instant I hear Ralgo mention Deep Phakes I realize why we’re having this conversation in the Privacy Cylinder and not in the breezeway he uses for public humiliation. Deep Phakes is one of the darkest secrets in the Valley. You’ll never hear anyone admit publicly they’re working with Deep Phakes. What they do isn’t entirely legal, and for my money it’s morally fuzzy, but their product is legendary. A Deep Phake shows up at your office a nameless nonentity of programmable meatware, unencumbered by personality or desire. They’ll become anyone you want, utterly and wholly. I’ve heard they’ll even swap genders if you spring for the surcharge.

Ralgo says we should count our blessings that Vencent offed himself on company property, out of the reach of journalists, and that all the witnesses are on the payroll.

“It needs to be like Vencent never jumped off that balcony,” Ralgo says.

“I’m not sure I get it,” I say.

“I’m saying the Deep Phake is New Vencent. We dump him in the same cubicle, have him pick up wherever Old Vencent left off. He’s got to become Vencent, sans, obviously, the suicidal tendencies.”

“Obviously,” I echo, and he gives me a look that says don’t be cute, fuckwad.

“Against my better judgement,” he says, “I’m putting you in charge of New Vencent’s onboarding. Don’t make me regret it.”

We emerge from the Privacy Cylinder and Ralgo grabs a compostable dry erase marker and scribbles NEW VENCENT on the Kanban board over the same column of post-its that Old Vencent had been assigned.

Read the whole thing here.

|  11 Nov 2022




The Destructors, or, Yet Another Rant About That Basecamp Post

First, here’s a link to the lazy Googlers among you who don’t know what I’m referring to.

At a historical/philosophical level, the entirety of that Basecamp post is antithetical to the prevailing values of American workplace culture today, at least among white-collar workers who demand that their employers make deliberate, overt efforts to effect social and political change. Whether or not blue-collar workers wish to make those same demands of their employers is a moot point. The gutting of unions have left them without any bargaining power. White-collar workers — “skilled” labor, a perniciously false term — enjoy the privilege to bargain by virtue of being competed-for by multiple prospective employers.

All this is received wisdom by now, received gravely and with simmering, powerless anger. What is new and fascinating to me is not that Basecamp is experiencing such a backlash from the Twitteratti. From a superficial (and true) perspective the backlash is almost entirely deserved. Rather, the interesting thing is to understand why there is such a backlash. To understand that requires acknowledging the unusual role that the American workplace has taken in our lives, unusual relative to previous centuries of Western culture.

Neoliberalism, the prevailing ideology of our times, continues to eat the world. Under neoliberalism, “the market” and an illusory “freedom of choice” are the organizing principles governing human bodies. Employment/employer have seized the scepter that was once held by religion/church. “What do you do?” is the de rigeur ice-breaker question of our times. Whether we like it or not, the tides of Western culture, at least in the US, have plunged us into a worldview (usually unspoken and unexamined) that makes work the center of one’s life. It’s not a surprise that most workplaces are flowing along with that tide. It is in the nature of tides that few can resist them. At a large enough company, you can practically live your entire life on the company campus: eat, exercise, shower, get child care, sleep, play, relax, do yoga, get medical attention. There was a time when this kind of lifestyle was viewed as dystopic. It’s a relatively recent invention (the last century or so) that we expect the average person not only to work, but to have a vocation. For most of the previous millenia, it was viewed as a kind of doom or failure to be employed by an employer (serfdom). Attitudes have shifted over the past century, coinciding with the loss of influence from historically powerful religious and secular institutions. That power vacuum was filled by work. Work as the center of one’s life. Work as an identity. Work as the only place that people gather with folks outside their immediate circle of family and friends.

As I reread that Basecamp post, it strikes me as extremely at odds with the status quo of the culture and values of the American workplace. Each bulleted decision and change moves the company away from being central to employee’s lives and instead “back” to a more restrained, vintage view of the role of the workplace. I’m not arguing for or against their views here, I’m pointing out that if you can’t put a finger on why the post is bothersome to you, it is in large part due to the fact that the entire thing is countercultural.

In Graham Greene’s short story The Destructors, a group of idle teenage boys systematically dismantle a working man’s house that had barely survived the German bombings of London. They boys moved meticulously through the house, prying up every plank and tile, sawing down every interior wall and joist, until the only thing left standing was a wythe of brick along the perimeter of the house. A single wood pole remained outside propping up the house, a remnant from the war, which the boys tied to the bumper of a neighbors car.

At seven next morning the driver came to fetch his lorry. He climbed into the seat and tried to start the engine. He was vageuly aware of a voice shouting, but it didn’t concern him. At last the engine responded and he backed the lorry until it touched the great wooden shore that supported Mr Thomas’s house. That way he could drive right out and down the street without reversing. The lorry moved forward, was momentarily checked as though something were pulling it from behind, and then went on to the sound of a long rumbling crash. The driver was astonished to see bricks bouncing ahead of him, while stones hit the roof of his cab. He put on his brakes. When he climbed out the whole landscape had suddenly altered. There was no house beside the car-park, only a hill of rubble.

That house is Western social order, and those boys destroying it are the forces of neoliberalism and capital eroding every form of social belonging and power except for one’s employer. Your employer, if you’re a white-collar worker, is that lone wooden strut propping up the house. When a company like Basecamp announces that they’re trying to retreat to a yesteryear posture of detachment, I feel complex emotions. On the one hand, I bitterly despise living in a world that has been so thoroughly gutted that we have to go groveling to our employer to effect social change. Isn’t this supposed to be a goddamn democracy? Why should Basecamp employees be reliant on motherfucking Basecamp to exert sociopolitical influence? On the other hand, I recognize that this shitty world, in all it’s blistering shittiness, is the only world we have, the only world that actually exists. If all we have is that one rickety strut propping up the whole edifice, we had better guard it with our lives.

|  3 May 2021