The earlier entry said only "suspect what the test does not control". Having now found it, the specifics are worth more than the warning. State leaks between UI tests because @AppStorage is UserDefaults and the simulator keeps them for days; the fix is a debug hook writing to the normal domain, because launch arguments land in NSArgumentDomain and outrank the app, making settings unchangeable rather than merely predictable. Elements below the fold of a lazy container genuinely do not exist, which reads exactly like a bug. Both cost a full evening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
229 lines
9.5 KiB
Markdown
229 lines
9.5 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. In our case it was exactly
|
||
that, and the two causes are worth naming.
|
||
|
||
## UI tests inherit whatever the last test left behind
|
||
|
||
`@AppStorage` is UserDefaults, and the simulator keeps those between
|
||
tests, between runs, and between days. A test that switched the card list
|
||
to its List layout left it that way; a different test, written for the
|
||
grid, then failed — days later, with nobody having touched the code.
|
||
|
||
Reset the state at launch. Do it with a **debug hook that writes to the
|
||
normal domain**, not with launch arguments:
|
||
|
||
```swift
|
||
// in the app, under #if DEBUG
|
||
if ProcessInfo.processInfo.arguments.contains("-resetUserState") {
|
||
UserDefaults.standard.removeObject(forKey: "cardTypeFilter")
|
||
UserDefaults.standard.set("cards", forKey: "cardLayoutStyle")
|
||
}
|
||
```
|
||
|
||
Launch arguments look like the obvious answer and are a trap: they form
|
||
`NSArgumentDomain`, which outranks anything the app writes at runtime. The
|
||
setting can then be forced but never changed, so every test that toggles
|
||
that setting fails. Trying it turned one failing test into five.
|
||
|
||
While resetting, prefer values that don't depend on the environment. A
|
||
"sort by distance" default makes card order a function of the simulator's
|
||
location.
|
||
|
||
## An element scrolled out of a lazy container does not exist
|
||
|
||
`List`, `LazyVStack` and `LazyVGrid` only build what is visible, so
|
||
`element.exists` is `false` for a row further down — indistinguishable
|
||
from a genuine bug. Anything below the fold needs a helper that swipes
|
||
until the element appears:
|
||
|
||
```swift
|
||
func scrollToStaticText(_ label: String, maxSwipes: Int = 8) -> XCUIElement {
|
||
let element = staticTexts[label]
|
||
if element.waitForExistence(timeout: 5) { return element }
|
||
for _ in 0..<maxSwipes where !element.exists { swipeUp() }
|
||
return element
|
||
}
|
||
```
|
||
|
||
Watch for content whose position depends on grouping: ours groups cards by
|
||
type with user-defined types last, which put exactly those cards off
|
||
screen.
|
||
|
||
## 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.
|