improve SwiftUI performance skill

This commit is contained in:
Thomas Ricouard 2026-03-15 11:01:31 +01:00
parent 0395637cf4
commit b638136486
4 changed files with 296 additions and 152 deletions

View file

@ -5,184 +5,82 @@ description: Audit and improve SwiftUI runtime performance from code review and
# SwiftUI Performance Audit
## Overview
## Quick start
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Use this skill to diagnose SwiftUI performance issues from code first, then request profiling evidence when code review alone cannot explain the symptoms.
## Workflow Decision Tree
## Workflow
- If the user provides code, start with "Code-First Review."
- If the user only describes symptoms, ask for minimal code/context, then do "Code-First Review."
- If code review is inconclusive, go to "Guide the User to Profile" and ask for a trace or screenshots.
1. Classify the symptom: slow rendering, janky scrolling, high CPU, memory growth, hangs, or excessive view updates.
2. If code is available, start with a code-first review using `references/code-smells.md`.
3. If code is not available, ask for the smallest useful slice: target view, data flow, reproduction steps, and deployment target.
4. If code review is inconclusive or runtime evidence is required, guide the user through profiling with `references/profiling-intake.md`.
5. Summarize likely causes, evidence, remediation, and validation steps using `references/report-template.md`.
## 1. Code-First Review
## 1. Intake
Collect:
- Target view/feature code.
- Data flow: state, environment, observable models.
- Symptoms and reproduction steps.
- Target view or feature code.
- Symptoms and exact reproduction steps.
- Data flow: `@State`, `@Binding`, environment dependencies, and observable models.
- Whether the issue shows up on device or simulator, and whether it was observed in Debug or Release.
Ask the user to classify the issue if possible:
- CPU spike or battery drain
- Janky scrolling or dropped frames
- High memory or image pressure
- Hangs or unresponsive interactions
- Excessive or unexpectedly broad view updates
For the full profiling intake checklist, read `references/profiling-intake.md`.
## 2. Code-First Review
Focus on:
- View invalidation storms from broad state changes.
- Unstable identity in lists (`id` churn, `UUID()` per render).
- Top-level conditional view swapping (`if/else` returning different root branches).
- Heavy work in `body` (formatting, sorting, image decoding).
- Layout thrash (deep stacks, `GeometryReader`, preference chains).
- Large images without downsampling or resizing.
- Over-animated hierarchies (implicit animations on large trees).
- Invalidation storms from broad observation or environment reads.
- Unstable identity in lists and `ForEach`.
- Heavy derived work in `body` or view builders.
- Layout thrash from complex hierarchies, `GeometryReader`, or preference chains.
- Large image decode or resize work on the main thread.
- Animation or transition work applied too broadly.
Use `references/code-smells.md` for the detailed smell catalog and fix guidance.
Provide:
- Likely root causes with code references.
- Suggested fixes and refactors.
- If needed, a minimal repro or instrumentation suggestion.
## 2. Guide the User to Profile
## 3. Guide the User to Profile
Explain how to collect data with Instruments:
- Use the SwiftUI template in Instruments (Release build).
- Reproduce the exact interaction (scroll, navigation, animation).
- Capture SwiftUI timeline and Time Profiler.
- Export or screenshot the relevant lanes and the call tree.
Ask for:
- Trace export or screenshots of SwiftUI lanes + Time Profiler call tree.
If code review does not explain the issue, ask for runtime evidence:
- A trace export or screenshots of the SwiftUI timeline and Time Profiler call tree.
- Device/OS/build configuration.
- The exact interaction being profiled.
- Before/after metrics if the user is comparing a change.
## 3. Analyze and Diagnose
Use `references/profiling-intake.md` for the exact checklist and collection steps.
Prioritize likely SwiftUI culprits:
- View invalidation storms from broad state changes.
- Unstable identity in lists (`id` churn, `UUID()` per render).
- Top-level conditional view swapping (`if/else` returning different root branches).
- Heavy work in `body` (formatting, sorting, image decoding).
- Layout thrash (deep stacks, `GeometryReader`, preference chains).
- Large images without downsampling or resizing.
- Over-animated hierarchies (implicit animations on large trees).
## 4. Analyze and Diagnose
Summarize findings with evidence from traces/logs.
- Map the evidence to the most likely category: invalidation, identity churn, layout thrash, main-thread work, image cost, or animation cost.
- Prioritize problems by impact, not by how easy they are to explain.
- Distinguish code-level suspicion from trace-backed evidence.
- Call out when profiling is still insufficient and what additional evidence would reduce uncertainty.
## 4. Remediate
## 5. Remediate
Apply targeted fixes:
- Narrow state scope (`@State`/`@Observable` closer to leaf views).
- Narrow state scope and reduce broad observation fan-out.
- Stabilize identities for `ForEach` and lists.
- Move heavy work out of `body` (precompute, cache, `@State`).
- Use `equatable()` or value wrappers for expensive subtrees.
- Move heavy work out of `body` into derived state updated from inputs, model-layer precomputation, memoized helpers, or background preprocessing. Use `@State` only for view-owned state, not as an ad hoc cache for arbitrary computation.
- Use `equatable()` only when equality is cheaper than recomputing the subtree and the inputs are truly value-semantic.
- Downsample images before rendering.
- Reduce layout complexity or use fixed sizing where possible.
## Common Code Smells (and Fixes)
Use `references/code-smells.md` for examples, Observation-specific fan-out guidance, and remediation patterns.
Look for these patterns during code review.
### Expensive formatters in `body`
```swift
var body: some View {
let number = NumberFormatter() // slow allocation
let measure = MeasurementFormatter() // slow allocation
Text(measure.string(from: .init(value: meters, unit: .meters)))
}
```
Prefer cached formatters in a model or a dedicated helper:
```swift
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}
```
### Computed properties that do heavy work
```swift
var filtered: [Item] {
items.filter { $0.isEnabled } // runs on every body eval
}
```
Prefer precompute or cache on change:
```swift
@State private var filtered: [Item] = []
// update filtered when inputs change
```
### Sorting/filtering in `body` or `ForEach`
```swift
List {
ForEach(items.sorted(by: sortRule)) { item in
Row(item)
}
}
```
Prefer sort once before view updates:
```swift
let sortedItems = items.sorted(by: sortRule)
```
### Inline filtering in `ForEach`
```swift
ForEach(items.filter { $0.isEnabled }) { item in
Row(item)
}
```
Prefer a prefiltered collection with stable identity.
### Unstable identity
```swift
ForEach(items, id: \.self) { item in
Row(item)
}
```
Avoid `id: \.self` for non-stable values; use a stable ID.
### Top-level conditional view swapping
```swift
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}
```
Prefer one stable base view and localize conditions to sections/modifiers (for example inside `toolbar`, row content, `overlay`, or `disabled`). This reduces root identity churn and helps SwiftUI diffing stay efficient.
### Image decoding on the main thread
```swift
Image(uiImage: UIImage(data: data)!)
```
Prefer decode/downsample off the main thread and store the result.
### Broad dependencies in observable models
```swift
@Observable class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}
```
Prefer granular view models or per-item state to reduce update fan-out.
## 5. Verify
## 6. Verify
Ask the user to re-run the same capture and compare with baseline metrics.
Summarize the delta (CPU, frame drops, memory peak) if provided.
@ -194,9 +92,14 @@ Provide:
- Top issues (ordered by impact).
- Proposed fixes with estimated effort.
Use `references/report-template.md` when formatting the final audit.
## References
Add Apple documentation and WWDC resources under `references/` as they are supplied by the user.
- Profiling intake and collection checklist: `references/profiling-intake.md`
- Common code smells and remediation patterns: `references/code-smells.md`
- Audit output template: `references/report-template.md`
- Add Apple documentation and WWDC resources under `references/` as they are supplied by the user.
- Optimizing SwiftUI performance with Instruments: `references/optimizing-swiftui-performance-instruments.md`
- Understanding and improving SwiftUI performance: `references/understanding-improving-swiftui-performance.md`
- Understanding hangs in your app: `references/understanding-hangs-in-your-app.md`

View file

@ -0,0 +1,150 @@
# Common code smells and remediation patterns
## Intent
Use this reference during code-first review to map visible SwiftUI patterns to likely runtime costs and safer remediation guidance.
## High-priority smells
### Expensive formatters in `body`
```swift
var body: some View {
let number = NumberFormatter()
let measure = MeasurementFormatter()
Text(measure.string(from: .init(value: meters, unit: .meters)))
}
```
Prefer cached formatters in a model or dedicated helper:
```swift
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}
```
### Heavy computed properties
```swift
var filtered: [Item] {
items.filter { $0.isEnabled }
}
```
Prefer deriving this once per meaningful input change in a model/helper, or store derived view-owned state only when the view truly owns the transformation lifecycle.
### Sorting or filtering inside `body`
```swift
List {
ForEach(items.sorted(by: sortRule)) { item in
Row(item)
}
}
```
Prefer sorting before render work begins:
```swift
let sortedItems = items.sorted(by: sortRule)
```
### Inline filtering inside `ForEach`
```swift
ForEach(items.filter { $0.isEnabled }) { item in
Row(item)
}
```
Prefer a prefiltered collection with stable identity.
### Unstable identity
```swift
ForEach(items, id: \.self) { item in
Row(item)
}
```
Avoid `id: \.self` for non-stable values or collections that reorder. Use a stable domain identifier.
### Top-level conditional view swapping
```swift
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}
```
Prefer one stable base view and localize conditions to sections or modifiers. This reduces root identity churn and makes diffing cheaper.
### Image decoding on the main thread
```swift
Image(uiImage: UIImage(data: data)!)
```
Prefer decode and downsample work off the main thread, then store the processed image.
## Observation fan-out
### Broad `@Observable` reads on iOS 17+
```swift
@Observable final class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}
```
If many views read the same broad collection or root model, small changes can fan out into wide invalidation. Prefer narrower derived inputs, smaller observable surfaces, or per-item state closer to the leaf views.
### Broad `ObservableObject` reads on iOS 16 and earlier
```swift
final class Model: ObservableObject {
@Published var items: [Item] = []
}
```
The same warning applies to legacy observation. Avoid having many descendants observe a large shared object when they only need one derived field.
## Remediation notes
### `@State` is not a generic cache
Use `@State` for view-owned state and derived values that intentionally belong to the view lifecycle. Do not move arbitrary expensive computation into `@State` unless you also define when and why it updates.
Better alternatives:
- precompute in the model or store
- update derived state in response to a specific input change
- memoize in a dedicated helper
- preprocess on a background task before rendering
### `equatable()` is conditional guidance
Use `equatable()` only when:
- equality is cheaper than recomputing the subtree, and
- the view inputs are value-semantic and stable enough for meaningful equality checks
Do not apply `equatable()` as a blanket fix for all redraws.
## Triage order
When multiple smells appear together, prioritize in this order:
1. Broad invalidation and observation fan-out
2. Unstable identity and list churn
3. Main-thread work during render
4. Image decode or resize cost
5. Layout and animation complexity

View file

@ -0,0 +1,44 @@
# Profiling intake and collection checklist
## Intent
Use this checklist when code review alone cannot explain the SwiftUI performance issue and you need runtime evidence from the user.
## Ask for first
- Exact symptom: CPU spike, dropped frames, memory growth, hangs, or excessive view updates.
- Exact interaction: scrolling, typing, initial load, navigation push/pop, animation, sheet presentation, or background refresh.
- Target device and OS version.
- Whether the issue was reproduced on a real device or only in Simulator.
- Build configuration: Debug or Release.
- Whether the user already has a baseline or before/after comparison.
## Default profiling request
Ask the user to:
- Run the app in a Release build when possible.
- Use the SwiftUI Instruments template.
- Reproduce the exact problematic interaction only long enough to capture the issue.
- Capture the SwiftUI timeline and Time Profiler together.
- Export the trace or provide screenshots of the key SwiftUI lanes and the Time Profiler call tree.
## Ask for these artifacts
- Trace export or screenshots of the relevant SwiftUI lanes
- Time Profiler call tree screenshot or export
- Device/OS/build configuration
- A short note describing what action was happening at the time of the capture
- If memory is involved, the memory graph or Allocations data if available
## When to ask for more
- Ask for a second capture if the first run mixes multiple interactions.
- Ask for a before/after pair if the user has already tried a fix.
- Ask for a device capture if the issue only appears in Simulator or if scrolling smoothness matters.
## Common traps
- Debug builds can distort SwiftUI timing and allocation behavior.
- Simulator traces can miss device-only rendering or memory issues.
- Mixed interactions in one capture make attribution harder.
- Screenshots without the reproduction note are much harder to interpret.

View file

@ -0,0 +1,47 @@
# Audit output template
## Intent
Use this structure when reporting SwiftUI performance findings so the user can quickly see the symptom, evidence, likely cause, and next validation step.
## Template
```markdown
## Summary
[One short paragraph on the most likely bottleneck and whether the conclusion is code-backed or trace-backed.]
## Findings
1. [Issue title]
- Symptom: [what the user sees]
- Likely cause: [root cause]
- Evidence: [code reference or profiling evidence]
- Fix: [specific change]
- Validation: [what to measure after the fix]
2. [Issue title]
- Symptom: ...
- Likely cause: ...
- Evidence: ...
- Fix: ...
- Validation: ...
## Metrics
| Metric | Before | After | Notes |
| --- | --- | --- | --- |
| CPU | [value] | [value] | [note] |
| Frame drops / hitching | [value] | [value] | [note] |
| Memory peak | [value] | [value] | [note] |
## Next step
[One concrete next action: apply a fix, capture a better trace, or validate on device.]
```
## Notes
- Order findings by impact, not by file order.
- Say explicitly when a conclusion is still a hypothesis.
- If no metrics are available, omit the table and say what should be measured next.