Performance Profiler
View Identity & Diffing
- Stable, persistent identity (
Identifiablewith stableid, avoid random UUIDs in rows) - Prefer value types + structural identity over class reference identity when possible
- Avoid
.id()unless absolutely necessary (use stable keys instead)
Body Computation & Redraws
- No heavy work (network, decoding, sorting, filtering) inside
body - Expensive derived state computed once and stored (
.onAppear, ViewModel, or memoized computed property) -
@State,@Bindable,@Observableproperties are narrowly scoped - Child views extracted when parent state changes frequently
- Prefer
@Observable(Observation framework) over@ObservableObject/@Publishedfor new code — finer-grained invalidation, less boilerplate
Lists & Large Collections
- Use
List/LazyVStack/LazyHGridinstead ofScrollView + ForEach + VStack - Rows are lightweight; avoid
@StateObject/@ObservedObjectinside row bodies - Use stable
Identifiableconformance or explicitid:key path
Concurrency & Main Actor (Swift 6 era)
-
@MainActorused appropriately on view-bound types (avoid overuse → thread hops) - Nonisolated properties / computed vars when safe
- Actors used for model / service layers; avoid
@MainActoron pure data models - No data races when using
@Observabletypes across concurrency domains - Prefer
.taskover.onAppearfor async work (automatic cancellation on view disappear, structured concurrency)
Environment & State Propagation
- Avoid injecting large
@Observableobjects via@Environmentwhen only a small slice of state is needed — extract a child view that reads only what it needs - Minimize over-subscription from
EnvironmentObject— split into focused, smaller observable types if necessary
Memory & Retain Cycles
-
[weak self]in async / escaping closures that captureself -
@StateObjectfor view-owned view models (not inListrows) - Remote images use proper caching (
AsyncImagewith cache, orKingfisher/Nuke) - Downscale images to display size before rendering (
.resizable()+.frame()alone doesn't reduce memory) - Use
preparingThumbnail(of:)for large images - Avoid long-lived strong references in
@Observable/@ObservableObject
Layout & Geometry
- Minimize
GeometryReaderusage inside scroll views (causes layout thrashing) - Avoid
PreferenceKeyupdates that trigger parent re-layouts in tight loops - Use
drawingGroup()for complex vector paths / heavy Canvas content
Animations & Transitions
- Scope animations narrowly (
.animation(…, value: …)) instead of global.animation() - Use
.matchedGeometryEffectonly when necessary (expensive) - Test with reduced motion enabled
Navigation & Sheets
- Use lazy destination resolution in
NavigationStack(avoid pre-building destination views) - Ensure
.sheet/.fullScreenCovercontent isn't computed until presentation - Avoid deep view hierarchies in destinations — flatten or split into smaller views
Quick Wins
| Issue | Fix / Pattern | Severity |
|---|---|---|
| Parent redraws child unnecessarily | Extract stable child view / use Equatable conformance |
🟡 |
Heavy logic in body |
Move to ViewModel / .task / computed property |
🔴 |
Unstable ForEach IDs |
Use stable id: \.self or persistent model ID |
🔴 |
| Retain cycle | \[weak self\] in closures |
🔴 |
@StateObject recreated in List row |
Move ownership to parent / use @Observable value type |
🔴 |
| Broad / unnecessary animations | .animation(nil) or value-specific .animation(…, value:) |
🟡 |
| Frequent main-actor hops | Use nonisolated properties, actors for model layer | 🟡 |
GeometryReader in scroll views |
Move geometry reads outside scroll or cache values | 🟡 |
| Large images not downscaled | preparingThumbnail(of:) or resize before display |
🟡 |
Over-subscribed @Environment |
Extract child view reading only needed state | 🟡 |
| Eager sheet / destination init | Lazy destination resolution in NavigationStack |
🟡 |
Still using @ObservableObject |
Migrate to @Observable (Observation framework) |
🟢 |
| Complex vector / Canvas paths | Use drawingGroup() for off-screen rendering |
🟢 |
Debug Helpers (Xcode 16+)
// In any View body — logs when/why view redraws
let _ = Self._printChanges()
// os_signpost for custom performance intervals
import os.signpost
let log = OSLog(subsystem: "com.app.performance", category: "ViewLoading")
let signpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "LoadData", signpostID: signpostID)
// ... work ...
os_signpost(.end, log: log, name: "LoadData", signpostID: signpostID)
Instruments & Tools
- SwiftUI template + Animation instrument in Instruments
- SwiftUI View Body instrument (Xcode 16+) — tracks body evaluations per view
- SwiftUI Performance HUD —
⌥⌘Pin Simulator - MetricKit /
MXMetricManager— production performance & diagnostic data - Memory Graph Debugger — catch retain cycles and leaks at runtime
Severity Legend
🔴 Critical — Visible lag, dropped frames, memory growth, crashes
🟡 Moderate — Noticeable jank on low-end devices or large datasets
🟢 Minor — Optimization opportunity / future-proofs for larger scale