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>
184 lines
7.9 KiB
Markdown
184 lines
7.9 KiB
Markdown
---
|
||
name: xcode-practice
|
||
description: 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.
|
||
|
||
```swift
|
||
@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:
|
||
|
||
```swift
|
||
// 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
|
||
|
||
- `navigationDestination` belongs on the root of a stack, not inside rows.
|
||
- One `CLMonitor` instance per name; a second one crashes with "already in
|
||
use".
|
||
- Anchor `confirmationDialog` on the control that triggered it, or iPad
|
||
shows a popover floating in the middle of the screen.
|