A UI test went red in the same run that adopted three new APIs. The one touching that screen was reverted; the test still failed. A modifier order was corrected; still failed. Simulator data was wiped; still failed. Running it against a clean checkout — which should have been the first move, not the fourth — it failed there too, with none of the work present. The scrolling entry had already been rewritten to explain the fault in confident detail. That write-up was fiction, so it now says what actually happened: the API was convicted on circumstance and remains unproven here, and the one claim that survives is about its two-way binding costing invalidations. xcode-practice gets the general lesson, since it will outlive this particular test: stash and rerun before reasoning about which edit did it, and a note recording a false cause is worse than no note, because the next person believes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.9 KiB
name, description
| name | description |
|---|---|
| xcode-practice | Practices for SwiftUI, Core Data, XcodeGen and Swift Testing that come from shipped apps rather than documentation — work that must not happen in a view body, blobs that must stay off the main thread, derived collections computed once, Keychain access outside view init, multi-target project hygiene, and reading test output correctly. Use when writing or reviewing SwiftUI views, adding files to an XcodeGen project, or interpreting a test run. |
Practices for Apple platform apps
These are not restatements of Apple's guidance. Each one is here because it caused a freeze, a regression, or a wrong conclusion in a real app, and the same mistake was made more than once.
Apple's own skills — swiftui-specialist, swiftui-whats-new-27,
app-intents-specialist and the rest that ship with Xcode — are the
authority on framework APIs. This skill covers what they do not: the
failure modes of putting those APIs together. Where they overlap, Apple
wins.
Nothing that touches the disk, the network or a renderer belongs in body
A view's body runs far more often than it looks. Every unrelated state
change re-runs it, so anything expensive in there runs on the main thread
at the frequency of redraws.
Three separate instances of this shipped before being caught:
ShareLink(item: writeVCardFile())— wrote a file to disk on every redraw of a detail screen, because the argument is evaluated when the view is built, not when the user taps share.- Serialising a whole record to JSON inside a toolbar
Menu, twice, to decide whether two menu items should appear. - Rendering barcodes with Core Image inside
body, at up to 12× scale.
The fix is always the same shape: prepare the value in .task(id:), hold
it in @State, and let body read the finished thing.
@State private var exportURL: URL?
var body: some View {
List { … }
.task(id: exportSignature) {
let payload = Payload(record) // main actor: reads the model
exportURL = await Task.detached { // off the main thread: writes
payload.writeTemporaryFile()
}.value
}
}
Give .task an id that covers every field the output depends on, or the
file goes stale after an edit.
Keep external-storage blobs off the main thread
Core Data attributes with external storage (photos, PDFs) hit the file system when read, deleted, or attached — the property access looks free and is not. Three separate freezes in one app traced back to this.
Delete through performBackgroundTask, build attachments in the
background, write temporary files in a detached task. Never touch a blob
attribute in a body, in a ForEach, or in a synchronous button action.
Compute derived collections once per pass
A computed property that sorts or filters runs again at every use, and a computed property used inside a closure runs once per element:
// O(n² log n): `hero` re-sorts the whole collection for every card
let rest = sortedCards.filter { $0 != hero }
Hoist it into a local (let hero = hero), or better, build one value that
carries everything the body needs — the sorted list, the highlighted item,
the groups, the filter chips — and compute it once at the top of body.
Watch for repeated database fetches hidden inside such properties; a
registry lookup called from three computed properties is three fetches per
redraw.
Keychain access does not belong in a view's init
State(initialValue: Keychain.load(…)) in an initialiser looks like it
runs once. View initialisers run on every parent redraw, and the value is
discarded every time but the first. Read it in the parent's .task and
pass the loaded value down as a plain let.
Also: check what the Keychain returned. SecItemUpdate and SecItemAdd
fail on a locked device (errSecInteractionNotAllowed). Ignoring the
status means the person walks away believing a PIN was saved that was not.
Return the status and surface it.
Prefer sheet(item:) over hand-built bindings
sheet(isPresented: Binding(get:set:)) allocates a closure on every body
evaluation and compares poorly. When two optionals must agree — a share
and its container, say — wrap them in one Identifiable value so the
sheet cannot open with half the data.
Cache expensive renderers, not just their output
CIContext() is costly to create; one shared instance serves the whole
app. Rendered images are worth an NSCache keyed by their inputs, which
fixes every call site at once instead of restructuring each view.
XcodeGen: regenerate, then build every target
After adding a file: xcodegen generate, or the build fails with "cannot
find in scope".
More important, and easier to miss: targets list their sources individually. A model type added for the phone app is not automatically available to a watch or widget target that compiles the same shared model file. A watch app sat broken for several commits this way, because only the iOS and macOS builds were being checked.
Build every target after a change to shared model code, not just the one being worked on.
Read the right test summary
With Swift Testing, xcodebuild's familiar line
Executed 0 tests, with 0 failures
is the XCTest counter and says nothing about Swift Testing suites. Theirs looks like:
✔ Test run with 84 tests in 14 suites passed after 0.809 seconds.
Reaching a conclusion from the wrong line means declaring a whole suite
dead when it is passing. Also: do not pipe a test run through tail or a
narrow grep and then diagnose from what survived — filter the saved log
afterwards instead.
Check a failure against an unchanged tree before blaming the change
A UI test started failing in the same run that introduced three API changes. The obvious suspect — the one touching that exact screen — was reverted, and the test still failed; a modifier order was corrected, still failed; the simulator's app data was wiped, still failed. Only then was the test run against a clean checkout with none of the work present, and it failed there too. The change had been innocent all along, and two rounds of edits went into explaining a fault that did not exist.
git stash and one run is cheaper than any amount of reasoning about
which edit did it. Do it first, not fourth. The same goes for what gets
written down afterwards: a note recording a false cause is worse than no
note, because the next person believes it.
Corollary for UI tests: when one goes red without a code change, suspect
what the test depends on but does not control — accumulated seed data,
@AppStorage surviving between launches, a simulator location and the
"nearby" sort order it drives, or an element scrolled out of a lazy
container and therefore missing from the accessibility tree entirely.
Core Data in tests
Build objects through the context's own model rather than
NSManagedObject.entity(); with more than one coordinator alive, the
latter returns an entity from the wrong store and the object silently
loses its defaults. Keep the context alive for the whole test — a context
that deallocates mid-test takes its objects with it.
Default main actor isolation
In a project that defaults to main actor isolation, plain data types need
nonisolated explicitly: Codable structs crossing to background work,
ActivityAttributes conformances handed to ActivityKit, enums used from
detached tasks. Swift 6 rejects using a main-actor-isolated conformance
from a nonisolated async context, so this surfaces as a build failure
against a new SDK rather than as a bug.
Small things that cost an afternoon each
navigationDestinationbelongs on the root of a stack, not inside rows.- One
CLMonitorinstance per name; a second one crashes with "already in use". - Anchor
confirmationDialogon the control that triggered it, or iPad shows a popover floating in the middle of the screen.