418 lines
13 KiB
Markdown
418 lines
13 KiB
Markdown
# SwiftUI Performance Patterns Reference
|
|
|
|
## Table of Contents
|
|
|
|
- [Performance Optimization](#performance-optimization)
|
|
- [Anti-Patterns](#anti-patterns)
|
|
- [Summary Checklist](#summary-checklist)
|
|
|
|
## Performance Optimization
|
|
|
|
### 1. Avoid Redundant State Updates
|
|
|
|
SwiftUI doesn't compare values before triggering updates:
|
|
|
|
```swift
|
|
// BAD - triggers update even if value unchanged
|
|
.onReceive(publisher) { value in
|
|
self.currentValue = value // Always triggers body re-evaluation
|
|
}
|
|
|
|
// GOOD - only update when different
|
|
.onReceive(publisher) { value in
|
|
if self.currentValue != value {
|
|
self.currentValue = value
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Optimize Hot Paths
|
|
|
|
Hot paths are frequently executed code (scroll handlers, animations, gestures):
|
|
|
|
```swift
|
|
// BAD - updates state on every scroll position change
|
|
.onPreferenceChange(ScrollOffsetKey.self) { offset in
|
|
shouldShowTitle = offset.y <= -32 // Fires constantly during scroll!
|
|
}
|
|
|
|
// GOOD - only update when threshold crossed
|
|
.onPreferenceChange(ScrollOffsetKey.self) { offset in
|
|
let shouldShow = offset.y <= -32
|
|
if shouldShow != shouldShowTitle {
|
|
shouldShowTitle = shouldShow // Fires only when crossing threshold
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Pass Only What Views Need
|
|
|
|
**Avoid passing large "config" or "context" objects.** Pass only the specific values each view needs.
|
|
|
|
```swift
|
|
// Good - pass specific values
|
|
ThemeSelector(theme: config.theme)
|
|
FontSizeSlider(fontSize: config.fontSize)
|
|
|
|
// Avoid - passing entire config (creates broad dependency)
|
|
ThemeSelector(config: config) // Notified of ALL config changes
|
|
```
|
|
|
|
With `ObservableObject`, any `@Published` change triggers all observers. With `@Observable`, views update only when accessed properties change, but passing entire objects still creates broader dependencies than necessary.
|
|
|
|
### 4. Use Equatable Views
|
|
|
|
For views with expensive bodies, conform to `Equatable`:
|
|
|
|
```swift
|
|
struct ExpensiveView: View, Equatable {
|
|
let data: SomeData
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.data.id == rhs.data.id // Custom equality check
|
|
}
|
|
|
|
var body: some View {
|
|
// Expensive computation
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
ExpensiveView(data: data)
|
|
.equatable() // Use custom equality
|
|
```
|
|
|
|
**Caution**: If you add new state or dependencies to your view, remember to update your `==` function!
|
|
|
|
### 5. POD Views for Fast Diffing
|
|
|
|
**POD (Plain Old Data) views use `memcmp` for fastest diffing.** A view is POD if it only contains simple value types and no property wrappers.
|
|
|
|
```swift
|
|
// POD view - fastest diffing
|
|
struct FastView: View {
|
|
let title: String
|
|
let count: Int
|
|
|
|
var body: some View {
|
|
Text("\(title): \(count)")
|
|
}
|
|
}
|
|
|
|
// Non-POD view - uses reflection or custom equality
|
|
struct SlowerView: View {
|
|
let title: String
|
|
@State private var isExpanded = false // Property wrapper makes it non-POD
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Advanced Pattern**: Wrap expensive non-POD views in POD parent views:
|
|
|
|
```swift
|
|
// POD wrapper for fast diffing
|
|
struct ExpensiveView: View {
|
|
let value: Int
|
|
|
|
var body: some View {
|
|
ExpensiveViewInternal(value: value)
|
|
}
|
|
}
|
|
|
|
// Internal view with state
|
|
private struct ExpensiveViewInternal: View {
|
|
let value: Int
|
|
@State private var item: Item?
|
|
|
|
var body: some View {
|
|
// Expensive rendering
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why**: The POD parent uses fast `memcmp` comparison. Only when `value` changes does the internal view get diffed.
|
|
|
|
### 6. Lazy Loading
|
|
|
|
Use lazy containers for large collections:
|
|
|
|
```swift
|
|
// BAD - creates all views immediately
|
|
ScrollView {
|
|
VStack {
|
|
ForEach(items) { item in
|
|
ExpensiveRow(item: item)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GOOD - creates views on demand
|
|
ScrollView {
|
|
LazyVStack {
|
|
ForEach(items) { item in
|
|
ExpensiveRow(item: item)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**iOS 26+ note**: Nested scroll views containing lazy stacks now automatically defer loading their children until they are about to appear, matching the behavior of top-level lazy stacks. This benefits patterns like horizontal photo carousels inside a vertical scroll view.
|
|
|
|
> Source: "What's new in SwiftUI" (WWDC25, session 256)
|
|
|
|
### 7. Task Cancellation
|
|
|
|
Cancel async work when view disappears:
|
|
|
|
```swift
|
|
struct DataView: View {
|
|
@State private var data: [Item] = []
|
|
|
|
var body: some View {
|
|
List(data) { item in
|
|
Text(item.name)
|
|
}
|
|
.task {
|
|
// Automatically cancelled when view disappears
|
|
data = await fetchData()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8. Debug View Updates
|
|
|
|
**Use `Self._printChanges()` or `Self._logChanges()` to debug unexpected view updates.**
|
|
|
|
```swift
|
|
struct DebugView: View {
|
|
@State private var count = 0
|
|
@State private var name = ""
|
|
|
|
var body: some View {
|
|
#if DEBUG
|
|
let _ = Self._logChanges() // Xcode 15.1+: logs to com.apple.SwiftUI subsystem
|
|
#endif
|
|
|
|
VStack {
|
|
Text("Count: \(count)")
|
|
Text("Name: \(name)")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- `Self._printChanges()`: Prints which properties changed to standard output.
|
|
- `Self._logChanges()` (iOS 17+): Logs to the `com.apple.SwiftUI` subsystem with category "Changed Body Properties", using `os_log` for structured output.
|
|
|
|
Both print `@self` when the view value itself changed and `@identity` when the view's persistent data was recycled.
|
|
|
|
**Why**: This helps identify which state changes are causing view updates. Isolating redraw triggers into single-responsibility subviews is often the fix -- extracting a subview means SwiftUI can skip its body when its inputs haven't changed.
|
|
|
|
### 9. Eliminate Unnecessary Dependencies
|
|
|
|
**Narrow state scope to reduce update fan-out.** Instead of passing an entire `@Observable` model to a row view (which creates a dependency on all accessed properties), pass only the specific values the view needs as `let` properties.
|
|
|
|
```swift
|
|
// Bad - broad dependency on entire model
|
|
struct ItemRow: View {
|
|
@Environment(AppModel.self) private var model
|
|
let item: Item
|
|
var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) }
|
|
}
|
|
|
|
// Good - narrow dependency
|
|
struct ItemRow: View {
|
|
let item: Item
|
|
let themeColor: Color
|
|
var body: some View { Text(item.name).foregroundStyle(themeColor) }
|
|
}
|
|
```
|
|
|
|
**Avoid storing frequently-changing values in the environment.** Every write to *any* environment key forces every view that reads *any* key in that subtree to be checked. So a high-frequency value (scroll offset, window/container size, drag translation, per-frame animation progress, timer ticks, hover location) flowing into an `@Entry` or `.environment(\.key, value)` becomes an invalidation storm.
|
|
|
|
Instead, hold the value in an `@Observable` model and expose a **coarsened** value — a boolean threshold rather than the raw measurement — so views invalidate only when crossing a meaningful boundary:
|
|
|
|
```swift
|
|
@MainActor @Observable
|
|
final class ViewportModel {
|
|
var width: CGFloat = 0 { didSet { isWide = width > 600 } }
|
|
private(set) var isWide = false // readers invalidate only when this flips
|
|
}
|
|
```
|
|
|
|
The same shape applies per row in a list: storing the raw offset on a model and having every row read it still invalidates all visible rows every frame. Give each item its own `@Observable` whose properties track only that item's derived state (e.g. `isVisible`), so each row invalidates at most twice (enter/leave) regardless of scroll speed — see item #10 below.
|
|
|
|
For purely visual effects driven by scroll position (opacity, scale, rotation), prefer `scrollTransition` or `visualEffect(in:)` — they push the per-frame work to the renderer and skip body re-evaluation entirely. Reach for the `@Observable` + coarsening pattern only when the scroll-derived value must drive non-rendering logic (model updates, prefetches, sibling state).
|
|
|
|
> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)
|
|
|
|
### 10. @Observable Dependency Granularity
|
|
|
|
**Consider per-item `@Observable` state holders (one per row/item) to narrow update scope.** When multiple list items share a dependency on the same `@Observable` array, changing one element causes all items to re-evaluate their bodies.
|
|
|
|
```swift
|
|
// BAD - all item views depend on the full favorites array
|
|
@Observable
|
|
class ModelData {
|
|
var favorites: [Landmark] = []
|
|
|
|
func isFavorite(_ landmark: Landmark) -> Bool {
|
|
favorites.contains(landmark)
|
|
}
|
|
}
|
|
|
|
struct LandmarkRow: View {
|
|
let landmark: Landmark
|
|
@Environment(ModelData.self) private var model
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(landmark.name)
|
|
if model.isFavorite(landmark) {
|
|
Image(systemName: "heart.fill")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GOOD - each item has its own observable view model
|
|
@Observable
|
|
class LandmarkViewModel {
|
|
var isFavorite: Bool = false
|
|
}
|
|
|
|
struct LandmarkRow: View {
|
|
let landmark: Landmark
|
|
let viewModel: LandmarkViewModel
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(landmark.name)
|
|
if viewModel.isFavorite {
|
|
Image(systemName: "heart.fill")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why**: With the bad pattern, toggling one favorite marks the entire array as changed, causing every `LandmarkRow` to re-run its body. With per-item view models, only the toggled item's body runs.
|
|
|
|
> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)
|
|
|
|
### 11. Off-Main-Thread Closures
|
|
|
|
**SwiftUI may call certain closures on a background thread for performance.** These closures must be `Sendable` and should avoid accessing `@MainActor`-isolated state directly. Instead, capture needed values in the closure's capture list.
|
|
|
|
Closures that may run off the main thread:
|
|
- `Shape.path(in:)`
|
|
- `visualEffect` closure
|
|
- `Layout` protocol methods
|
|
- `onGeometryChange` transform closure
|
|
|
|
```swift
|
|
// BAD - accessing @MainActor state directly
|
|
.visualEffect { content, geometry in
|
|
content.blur(radius: self.pulse ? 5 : 0) // Compiler error: @MainActor isolated
|
|
}
|
|
|
|
// GOOD - capture the value
|
|
.visualEffect { [pulse] content, geometry in
|
|
content.blur(radius: pulse ? 5 : 0)
|
|
}
|
|
```
|
|
|
|
> Source: "Explore concurrency in SwiftUI" (WWDC25, session 266)
|
|
|
|
### 12. Common Performance Issues
|
|
|
|
**Be aware of common performance bottlenecks in SwiftUI:**
|
|
|
|
- View invalidation storms from broad state changes
|
|
- Unstable identity in lists causing excessive diffing
|
|
- Heavy work in `body` (formatting, sorting, image decoding)
|
|
- Layout thrash from deep stacks or preference chains
|
|
|
|
**When performance issues arise**, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks.
|
|
|
|
## Anti-Patterns
|
|
|
|
### 1. Creating Objects in Body
|
|
|
|
```swift
|
|
// BAD - creates new formatter every body call
|
|
var body: some View {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .long
|
|
return Text(formatter.string(from: date))
|
|
}
|
|
|
|
// GOOD - static or stored formatter
|
|
private static let dateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateStyle = .long
|
|
return f
|
|
}()
|
|
|
|
var body: some View {
|
|
Text(Self.dateFormatter.string(from: date))
|
|
}
|
|
```
|
|
|
|
### 2. Heavy Computation in Body
|
|
|
|
**Keep view body simple and pure.** Avoid side effects, dispatching, or complex logic.
|
|
|
|
```swift
|
|
// BAD - sorts array every body call
|
|
var body: some View {
|
|
List(items.sorted { $0.name < $1.name }) { item in Text(item.name) }
|
|
}
|
|
|
|
// GOOD - compute once, update via onChange or a computed property in the model
|
|
@State private var sortedItems: [Item] = []
|
|
|
|
var body: some View {
|
|
List(sortedItems) { item in Text(item.name) }
|
|
.onChange(of: items) { _, newItems in
|
|
sortedItems = newItems.sorted { $0.name < $1.name }
|
|
}
|
|
}
|
|
```
|
|
|
|
Move sorting, filtering, and formatting into models or computed properties. The `body` should be a pure structural representation of state.
|
|
|
|
### 3. Unnecessary State
|
|
|
|
```swift
|
|
// BAD - derived state stored separately
|
|
@State private var items: [Item] = []
|
|
@State private var itemCount: Int = 0 // Unnecessary!
|
|
|
|
// GOOD - compute derived values
|
|
@State private var items: [Item] = []
|
|
|
|
var itemCount: Int { items.count } // Computed property
|
|
```
|
|
|
|
## Summary Checklist
|
|
|
|
- [ ] State updates check for value changes before assigning
|
|
- [ ] Hot paths minimize state updates
|
|
- [ ] Pass only needed values to views (avoid large config objects)
|
|
- [ ] Large lists use `LazyVStack`/`LazyHStack`
|
|
- [ ] No object creation in `body`
|
|
- [ ] Heavy computation moved out of `body`
|
|
- [ ] Body kept simple and pure (no side effects)
|
|
- [ ] Derived state computed, not stored
|
|
- [ ] Use `Self._logChanges()` or `Self._printChanges()` to debug unexpected updates
|
|
- [ ] Equatable conformance for expensive views (when appropriate)
|
|
- [ ] Consider POD view wrappers for advanced optimization
|
|
- [ ] Consider using granular @Observable dependencies for list items (smaller observable units per row when it measurably reduces updates)
|
|
- [ ] Frequently-changing values not stored in the environment
|
|
- [ ] Sendable closures (Shape, visualEffect, Layout) capture values instead of accessing @MainActor state
|