swiftui-performance-audit
SwiftUI Performance Audit
Section titled “SwiftUI Performance Audit”Overview
Section titled “Overview”Audit SwiftUI view performance from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Workflow Decision Tree
Section titled “Workflow Decision Tree”- If user provides code: Start with Code-First Review
- If user only describes symptoms: Ask for minimal code/context, then Code-First Review
- If code review is inconclusive: Guide user to profile with Instruments
1. Code-First Review
Section titled “1. Code-First Review”Collect
Section titled “Collect”- Target view/feature code
- Data flow: state, environment, observable models
- Symptoms and reproduction steps
Focus On
Section titled “Focus On”- View invalidation storms from broad state changes
- Unstable identity in lists (
idchurn,UUID()per render) - Heavy work in
body(formatting, sorting, image decoding) - Layout thrash (deep stacks,
GeometryReader, preference chains) - Large images without downsampling
- Over-animated hierarchies (implicit animations on large trees)
- Non-lazy containers (
VStack/HStack) holding large collections instead ofLazyVStack/LazyHStack - Async work in
.taskwithout relying on its automatic cancellation when the view disappears - Closures that may run off the main thread (
Shape.path(in:),visualEffect,Layoutprotocol methods,onGeometryChange) touching@MainActorstate directly instead of capturing values
Provide
Section titled “Provide”- Likely root causes with code references
- Suggested fixes and refactors
- Minimal repro or instrumentation suggestion if needed
2. Guide User to Profile
Section titled “2. Guide User to Profile”Before reaching for Instruments, a cheaper first step: ask the user to add Self._printChanges() (prints to stdout) or Self._logChanges() (iOS 17+, logs to the com.apple.SwiftUI subsystem under “Changed Body Properties”) as the first line of the suspect view’s body. Both print @self when the view value itself changed and @identity when the view’s persistent data was recycled – this often narrows down the offending state before a trace is needed. Remove these calls before shipping.
If code review is inconclusive, explain how to collect data:
- Use SwiftUI template in Instruments (Release build)
- Reproduce the exact interaction (scroll, navigation, animation)
- Capture SwiftUI timeline and Time Profiler
- Export or screenshot relevant lanes and call tree
Ask for:
- Trace export or screenshots
- Device/OS/build configuration
3. Common Code Smells (and Fixes)
Section titled “3. Common Code Smells (and Fixes)”Expensive formatters in body
Section titled “Expensive formatters in body”Bad:
var body: some View { let formatter = NumberFormatter() // Slow allocation every render Text(formatter.string(from: value))}Good:
final class Formatters { static let number = NumberFormatter()}
var body: some View { Text(Formatters.number.string(from: value))}Computed properties with heavy work
Section titled “Computed properties with heavy work”Bad:
var filtered: [Item] { items.filter { $0.isEnabled } // Runs every body eval}Good:
@State private var filtered: [Item] = []
.onChange(of: items) { filtered = items.filter { $0.isEnabled }}Sorting/filtering in ForEach
Section titled “Sorting/filtering in ForEach”Bad:
ForEach(items.sorted(by: sortRule)) { item in Row(item)}Good:
let sortedItems = items.sorted(by: sortRule) // Compute onceForEach(sortedItems) { item in Row(item)}Unstable identity
Section titled “Unstable identity”Bad:
ForEach(items, id: \.self) { item in // \.self may not be stable Row(item)}Good:
ForEach(items, id: \.stableID) { item in Row(item)}Image decoding on main thread
Section titled “Image decoding on main thread”Bad:
Image(uiImage: UIImage(data: data)!)Good:
// Decode/downsample off main thread, cache the result@State private var image: UIImage?
.task { image = await ImageLoader.load(data: data, targetSize: size)}Broad dependencies in observable models
Section titled “Broad dependencies in observable models”Bad:
@Observable class Model { var items: [Item] = []}
var body: some View { Row(isFavorite: model.items.contains(item)) // Entire array dependency}Good:
// Granular view models or per-item state to reduce update fan-outNon-POD views in hot paths
Section titled “Non-POD views in hot paths”A view is POD (Plain Old Data) when it only holds simple value types and no property wrappers – SwiftUI diffs it with fast memcmp instead of reflection. Wrap an expensive non-POD view in a POD parent so the fast comparison gates the expensive one:
Bad:
struct ExpensiveView: View { let value: Int @State private var item: Item? // property wrapper makes this non-POD
var body: some View { // expensive rendering, re-diffed via reflection every time }}Good:
// POD wrapper -- fast memcmp diffing gates the expensive internal viewstruct ExpensiveView: View { let value: Int
var body: some View { ExpensiveViewInternal(value: value) }}
private struct ExpensiveViewInternal: View { let value: Int @State private var item: Item?
var body: some View { // expensive rendering, only diffed when `value` changes }}Off-main-thread closures touching @MainActor state
Section titled “Off-main-thread closures touching @MainActor state”SwiftUI may invoke Shape.path(in:), the visualEffect closure, Layout protocol methods, and the onGeometryChange transform closure on a background thread. They must be Sendable and should capture needed values instead of reading @MainActor-isolated state directly:
Bad:
.visualEffect { content, geometry in content.blur(radius: self.pulse ? 5 : 0) // compiler error: @MainActor isolated}Good:
.visualEffect { [pulse] content, geometry in content.blur(radius: pulse ? 5 : 0)}4. Remediation Strategies
Section titled “4. Remediation Strategies”| Issue | Fix |
|---|---|
| Broad state changes | Narrow scope with @State/@Observable closer to leaves |
| Unstable identities | Use stable, unique IDs for ForEach |
| Heavy work in body | Precompute, cache, move to @State |
| Expensive subtrees | Use equatable() or value wrappers, or a POD wrapper view |
| Large images | Downsample before rendering |
| Layout complexity | Reduce nesting, use fixed sizing where possible |
| Large collections in eager containers | Use LazyVStack/LazyHStack/LazyVGrid/LazyHGrid |
| Unnecessary derived state | Compute via a var instead of storing a second @State that must be kept in sync |
Off-main-thread closures reading @MainActor state |
Capture values in the closure’s capture list instead |
5. Verify
Section titled “5. Verify”Ask user to re-run same capture and compare with baseline:
- CPU usage
- Frame drops
- Memory peak
Output Format
Section titled “Output Format”Provide:
- Metrics table (before/after if available)
- Top issues (ordered by impact)
- Proposed fixes with estimated effort
Profiling Commands
Section titled “Profiling Commands”# Build for profilingxcodebuild -scheme MyApp -configuration Release -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build
# Open in Instrumentsopen -a InstrumentsInstruments Checklist
Section titled “Instruments Checklist”- Use Release build (not Debug)
- Select SwiftUI template
- Reproduce exact problematic interaction
- Look at SwiftUI timeline for body evaluations
- Check Time Profiler for hot spots
- Note frame rate drops in Animation timeline
Source: split from AvdLee/SwiftUI-Agent-Skill’s reference material.