swiftui-debugging
Diagnose SwiftUI performance issues including unnecessary re-renders, view identity problems, and slow body evaluations. Use when SwiftUI views are slow, janky, or re-rendering too often.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install rshankras-claude-code-apple-skills-swiftui-debugging
Repository
Skill path: skills/performance/swiftui-debugging
Diagnose SwiftUI performance issues including unnecessary re-renders, view identity problems, and slow body evaluations. Use when SwiftUI views are slow, janky, or re-rendering too often.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: rshankras.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install swiftui-debugging into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/rshankras/claude-code-apple-skills before adding swiftui-debugging to shared team environments
- Use swiftui-debugging for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: swiftui-debugging
description: Diagnose SwiftUI performance issues including unnecessary re-renders, view identity problems, and slow body evaluations. Use when SwiftUI views are slow, janky, or re-rendering too often.
allowed-tools: [Read, Glob, Grep]
---
# SwiftUI Performance Debugging
Systematic guide for diagnosing and fixing SwiftUI performance problems: unnecessary view re-evaluations, identity issues, expensive body computations, and lazy loading mistakes.
## When This Skill Activates
Use this skill when the user:
- Reports slow or janky SwiftUI views
- Sees excessive view re-renders or body re-evaluations
- Asks about `Self._printChanges()` or view debugging
- Has scrolling performance issues with lists or grids
- Asks why a view keeps updating when nothing changed
- Mentions `@Observable` or `ObservableObject` performance differences
- Wants to understand SwiftUI view identity or diffing
- Uses `AnyView` and asks about performance implications
- Has a hang or stutter traced to SwiftUI rendering
## Decision Tree
```
What SwiftUI performance problem are you seeing?
|
+- Views re-render when they should not
| +- Read body-reevaluation.md
| +- Self._printChanges() to identify which property changed
| +- @Observable vs ObservableObject observation differences
| +- Splitting views to narrow observation scope
|
+- Scrolling is slow / choppy (lists, grids)
| +- Read lazy-loading.md
| +- VStack vs LazyVStack, ForEach without lazy container
| +- List prefetching, grid cell reuse
|
+- Views lose state unexpectedly / animate when they should not
| +- Read view-identity.md
| +- Structural vs explicit identity
| +- .id() misuse, conditional view branching
|
+- Known pitfall (AnyView, DateFormatter in body, etc.)
| +- Read common-pitfalls.md
| +- AnyView type erasure, object creation in body
| +- Over-observation, expensive computations
|
+- General "my SwiftUI app is slow" (unknown cause)
| +- Start with body-reevaluation.md, then common-pitfalls.md
| +- Use Instruments SwiftUI template (see Debugging Tools below)
```
## API Availability
| API / Technique | Minimum Version | Reference |
|----------------|-----------------|-----------|
| `Self._printChanges()` | iOS 15 | body-reevaluation.md |
| `@Observable` | iOS 17 / macOS 14 | body-reevaluation.md |
| `@ObservableObject` | iOS 13 | body-reevaluation.md |
| `LazyVStack` / `LazyHStack` | iOS 14 | lazy-loading.md |
| `LazyVGrid` / `LazyHGrid` | iOS 14 | lazy-loading.md |
| `.id()` modifier | iOS 13 | view-identity.md |
| Instruments SwiftUI template | Xcode 14+ | SKILL.md |
| `os_signpost` | iOS 12 | SKILL.md |
## Top 5 Mistakes -- Quick Reference
| # | Mistake | Fix | Details |
|---|---------|-----|---------|
| 1 | Large `ForEach` inside `VStack` or `ScrollView` without lazy container | Wrap in `LazyVStack` -- eager `VStack` creates all views upfront | lazy-loading.md |
| 2 | Using `AnyView` to erase types | Use `@ViewBuilder`, `Group`, or concrete generic types -- `AnyView` defeats diffing | common-pitfalls.md |
| 3 | Creating objects in `body` (`DateFormatter()`, `NumberFormatter()`) | Use `static let` shared instances or `@State` for mutable objects | common-pitfalls.md |
| 4 | Observing entire model when only one property is needed | Split into smaller `@Observable` objects or extract subviews | body-reevaluation.md |
| 5 | Unstable `.id()` values causing full view recreation every render | Use stable identifiers (database IDs, UUIDs), never array indices or random values | view-identity.md |
## Debugging Tools
### Self._printChanges()
Add to any view body to see what triggered re-evaluation:
```swift
var body: some View {
let _ = Self._printChanges()
// ... view content
}
```
Output reads: `ViewName: @self, @identity, _propertyName changed.`
See body-reevaluation.md for full interpretation guide.
### Instruments SwiftUI Template
1. **Xcode > Product > Profile** (Cmd+I)
2. Choose **SwiftUI** template (includes View Body, View Properties, Core Animation Commits)
3. Record, reproduce the slow interaction, stop
4. **View Body** lane shows which views had their body evaluated and how often
5. **View Properties** lane shows which properties changed
### os_signpost for Custom Measurement
```swift
import os
private let perfLog = OSLog(subsystem: "com.app.perf", category: "SwiftUI")
var body: some View {
let _ = os_signpost(.event, log: perfLog, name: "MyView.body")
// ... view content
}
```
View in Instruments with the **os_signpost** instrument to count body evaluations per second.
## Review Checklist
### View Identity
- [ ] No unstable `.id()` values (random, Date(), array index on mutable arrays)
- [ ] Conditional branches (`if`/`else`) do not cause unnecessary view destruction
- [ ] `ForEach` uses stable, unique identifiers from the model
### Body Re-evaluation
- [ ] Views observe only the properties they actually use
- [ ] `@Observable` classes preferred over `ObservableObject` (iOS 17+)
- [ ] No unnecessary `@State` changes that trigger body re-evaluation
- [ ] Large views split into smaller subviews to narrow observation scope
### Lazy Loading
- [ ] Large collections use `LazyVStack` / `LazyHStack`, not `VStack` / `HStack`
- [ ] `List` or lazy stack used for 50+ items
- [ ] No `.frame(maxHeight: .infinity)` on children inside lazy containers (defeats laziness)
### Common Pitfalls
- [ ] No `AnyView` type erasure (use `@ViewBuilder` or `Group`)
- [ ] No object allocation in `body` (`DateFormatter`, `NSPredicate`, view models)
- [ ] Expensive computations moved to background with `task { }` or `Task.detached`
- [ ] Images use `AsyncImage` or `.resizable()` with proper sizing, not raw `UIImage` decoding in body
## Reference Files
| File | Content |
|------|---------|
| [view-identity.md](view-identity.md) | Structural vs explicit identity, `.id()` usage, conditional branching |
| [body-reevaluation.md](body-reevaluation.md) | What triggers body, `_printChanges()`, `@Observable` vs `ObservableObject` |
| [lazy-loading.md](lazy-loading.md) | Lazy vs eager containers, `List`, `ForEach`, grid performance |
| [common-pitfalls.md](common-pitfalls.md) | `AnyView`, object creation in body, over-observation, expensive computations |
| [../profiling/SKILL.md](../profiling/SKILL.md) | General Instruments profiling (Time Profiler, Memory, Energy) |
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### view-identity.md
```markdown
# View Identity
How SwiftUI decides whether two views are the **same view** (update in place) or **different views** (destroy and recreate). Getting identity wrong causes lost state, broken animations, and unnecessary work.
## Two Kinds of Identity
### Structural Identity (Implicit)
SwiftUI assigns identity based on a view's **position in the view hierarchy**. Two views at the same position in the same parent are considered the same view across renders.
```swift
// These two Text views have different structural identities
// because they are at different positions in the VStack
VStack {
Text("First") // position 0
Text("Second") // position 1
}
```
### Explicit Identity
You override structural identity using `.id()` or `ForEach` identifiers. This tells SwiftUI: "this specific value identifies this view."
```swift
ForEach(items, id: \.id) { item in
ItemRow(item: item) // identity = item.id
}
DetailView(item: selectedItem)
.id(selectedItem.id) // identity = selectedItem.id
```
## How SwiftUI Uses Identity
| Identity comparison | SwiftUI behavior |
|--------------------|-----------------|
| Same identity, same type | **Update** existing view (preserves `@State`) |
| Same identity, different type | **Destroy** old view, **create** new view |
| Different identity | **Destroy** old view, **create** new view |
| No identity change | **Diff** the view's properties and update as needed |
Key consequence: when identity changes, **all `@State` is lost** and the view is recreated from scratch.
## .id() Modifier
### When to Use .id()
Force SwiftUI to treat a view as a completely new instance:
```swift
// Force recreation when switching between items
// Without .id(), SwiftUI would try to update the same view,
// which can show stale state or skip the appear animation
DetailView(item: selectedItem)
.id(selectedItem.id)
```
### Stable vs Unstable Identifiers
```swift
// ✅ Stable: database ID, UUID, or model-provided unique key
ForEach(items, id: \.databaseID) { item in
ItemRow(item: item)
}
// ✅ Stable: using Identifiable conformance
ForEach(items) { item in // uses item.id automatically
ItemRow(item: item)
}
// ❌ Unstable: array index on a mutable array
// When items are inserted/deleted, indices shift and
// SwiftUI matches the wrong data to the wrong view
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemRow(item: item)
}
// ❌ Unstable: random value or Date()
// Generates a new identity every render, destroying all state
DetailView(item: item)
.id(UUID()) // NEVER do this -- recreates view every body call
```
### Performance Impact of Unstable .id()
When `.id()` changes every render:
1. SwiftUI destroys the entire view subtree (including all child views)
2. All `@State` and `@StateObject` are lost
3. `onAppear` fires again
4. Animations treat it as a new view (insertion transition, not update)
5. Any in-flight async `task` is cancelled and restarted
This is the single most expensive identity mistake. A view with `N` children all get destroyed and recreated.
## Conditional View Branching
### The if/else Identity Problem
`if`/`else` branches create **different structural identities**:
```swift
// ❌ Problem: toggling isLoading destroys and recreates ContentView
// because the two branches are different structural positions
if isLoading {
ProgressView()
} else {
ContentView()
}
```
When `isLoading` changes from `true` to `false`:
- `ProgressView` is destroyed (it existed at the "if-true" branch)
- `ContentView` is created from scratch (it appears at the "if-else" branch)
- All `@State` inside `ContentView` resets
### When Destruction Is Acceptable
If the two branches show completely different content (like loading vs loaded), this destruction is fine and expected. The problem arises when you use `if`/`else` to toggle **modifiers** on the same view:
```swift
// ❌ Bad: destroys and recreates the same Text view
if isHighlighted {
Text(item.title)
.foregroundStyle(.yellow)
.bold()
} else {
Text(item.title)
.foregroundStyle(.primary)
}
// ✅ Good: same structural identity, just different modifier values
Text(item.title)
.foregroundStyle(isHighlighted ? .yellow : .primary)
.bold(isHighlighted)
```
### Ternary Expressions vs if/else in View Body
```swift
// ✅ Ternary: single structural identity, SwiftUI diffs the values
RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? Color.blue : Color.gray)
.frame(height: isExpanded ? 200 : 80)
// ❌ if/else: two different structural identities
if isSelected {
RoundedRectangle(cornerRadius: 12).fill(.blue)
} else {
RoundedRectangle(cornerRadius: 12).fill(.gray)
}
```
### AnyView and Identity
`AnyView` erases type information, which breaks structural identity tracking:
```swift
// ❌ AnyView: SwiftUI cannot track structural identity across renders
func makeView() -> AnyView {
if condition {
return AnyView(ViewA())
} else {
return AnyView(ViewB())
}
}
// ✅ @ViewBuilder: preserves structural identity
@ViewBuilder
func makeView() -> some View {
if condition {
ViewA()
} else {
ViewB()
}
}
```
See common-pitfalls.md for more on `AnyView` performance costs.
## ForEach Identity
### How ForEach Uses Identifiers
`ForEach` maps each data element to a view using the `id` key path. SwiftUI uses these identifiers to:
- Match existing views to updated data (minimizing recreations)
- Animate insertions, removals, and reorderings
- Preserve `@State` in each row
```swift
// ✅ Identifiable conformance (cleanest)
struct Item: Identifiable {
let id: UUID
var title: String
}
ForEach(items) { item in
ItemRow(item: item)
}
// ✅ Explicit id key path
ForEach(items, id: \.uniqueKey) { item in
ItemRow(item: item)
}
```
### Duplicate Identifiers
If two items share the same `id`, SwiftUI behavior is **undefined** -- it may show duplicates, skip items, or crash:
```swift
// ❌ Dangerous: if two items have the same title, behavior is undefined
ForEach(items, id: \.title) { item in
ItemRow(item: item)
}
// ✅ Always use a truly unique identifier
ForEach(items, id: \.id) { item in
ItemRow(item: item)
}
```
## Debugging Identity Issues
### Symptoms of Identity Problems
| Symptom | Likely cause |
|---------|-------------|
| View `@State` resets unexpectedly | Identity changed, causing view recreation |
| `onAppear` fires repeatedly on the same view | Identity keeps changing (unstable `.id()`) |
| Animations show insertion/removal instead of smooth update | View destroyed and recreated instead of updated |
| Scroll position resets in a list | `ForEach` identifiers changed for existing items |
| Text fields lose focus and typed content | The `TextField` view got a new identity |
### Using _printChanges to Detect Recreation
```swift
var body: some View {
let _ = Self._printChanges()
// If you see "@identity changed" in the output,
// SwiftUI considers this a brand-new view instance
Text("Content")
}
```
Output `@identity changed` means the view was **destroyed and recreated**, not updated in place. This usually means an unstable `.id()` or a structural identity change from `if`/`else` branching.
### Verifying with onAppear / onDisappear
```swift
DetailView(item: item)
.id(item.id)
.onAppear { print("DetailView appeared: \(item.id)") }
.onDisappear { print("DetailView disappeared") }
```
If you see rapid appear/disappear cycles for the same content, the identity is unstable.
## Fix Patterns Summary
| Problem | Fix |
|---------|-----|
| `.id(UUID())` or `.id(Date())` | Use a stable model identifier |
| `if`/`else` toggling modifiers on same view | Use ternary expressions for modifier values |
| `ForEach` with array index as id | Use `Identifiable` conformance or stable key path |
| State lost on navigation | Add `.id(item.id)` to force correct recreation timing |
| Duplicate `ForEach` identifiers | Ensure model has truly unique `id` property |
```
### body-reevaluation.md
```markdown
# Body Re-evaluation
Understanding what triggers SwiftUI to call a view's `body` property, how to diagnose unnecessary re-evaluations, and how to minimize wasted work.
## What Triggers body Re-evaluation
SwiftUI calls `body` when it detects that a view's **dependencies** have changed. Dependencies include:
| Dependency type | Triggers body when... |
|----------------|----------------------|
| `@State` | The wrapped value changes |
| `@Binding` | The source value changes |
| `@Environment` | The environment value changes |
| `@Observable` object (iOS 17+) | A property **that body actually reads** changes |
| `@ObservedObject` / `@EnvironmentObject` | The `objectWillChange` publisher fires (any property) |
| Parent passes new value | An input property's value is different from last render |
Key insight: `body` being called does **not** necessarily mean the view re-renders on screen. SwiftUI diffs the output of `body` against the previous output and only updates what changed. However, calling `body` itself has a cost -- especially for complex view hierarchies.
## Self._printChanges()
The most important debugging tool for re-evaluation. Add it as the first line in `body`:
```swift
struct ItemDetailView: View {
let item: Item
@State private var isEditing = false
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let _ = Self._printChanges()
// ... view content
}
}
```
### Reading the Output
Output format: `ViewName: _dependency1, _dependency2 changed.`
| Output | Meaning |
|--------|---------|
| `ItemDetailView: _item changed.` | The `item` property received a new value from the parent |
| `ItemDetailView: _isEditing changed.` | The `@State` property `isEditing` was modified |
| `ItemDetailView: _colorScheme changed.` | The environment's color scheme changed |
| `ItemDetailView: @self changed.` | The view struct itself was recreated (parent's body ran) |
| `ItemDetailView: @identity changed.` | The view's identity changed (destroyed and recreated) |
### @self changed -- What It Means
`@self changed` means the parent view's `body` was called, which recreated this view's struct. Even if all property values are identical, SwiftUI may still call `body` because the struct was freshly allocated.
This is the most common source of "unnecessary" re-evaluations. Fix it by ensuring the parent does not needlessly recreate child views.
### Reducing @self Changes
```swift
// ❌ Parent rebuilds child every time its own body runs
struct ParentView: View {
@State private var counter = 0
var body: some View {
VStack {
Text("Count: \(counter)")
Button("+1") { counter += 1 }
ExpensiveChildView() // @self changes every time counter changes
}
}
}
// ✅ Extract child so its body only runs when its own deps change
struct ParentView: View {
@State private var counter = 0
var body: some View {
VStack {
CounterSection(counter: $counter)
ExpensiveChildView() // Now in its own struct, isolated from counter
}
}
}
struct CounterSection: View {
@Binding var counter: Int
var body: some View {
VStack {
Text("Count: \(counter)")
Button("+1") { counter += 1 }
}
}
}
```
## @Observable vs ObservableObject
This is the single largest performance improvement available in modern SwiftUI.
### ObservableObject: Whole-Object Observation (iOS 13+)
`ObservableObject` notifies **all** observing views when **any** `@Published` property changes:
```swift
// Every view observing this object re-evaluates when ANY property changes
class UserStore: ObservableObject {
@Published var name = "" // Change triggers all observers
@Published var email = "" // Change triggers all observers
@Published var avatarURL: URL? // Change triggers all observers
@Published var preferences = Preferences() // Change triggers all observers
}
struct NameLabel: View {
@ObservedObject var store: UserStore
var body: some View {
let _ = Self._printChanges()
// This body runs when email, avatarURL, or preferences change too!
Text(store.name)
}
}
```
### @Observable: Per-Property Observation (iOS 17+)
`@Observable` tracks which properties each view's `body` actually **reads** and only notifies when those specific properties change:
```swift
@Observable
class UserStore {
var name = ""
var email = ""
var avatarURL: URL?
var preferences = Preferences()
}
struct NameLabel: View {
var store: UserStore
var body: some View {
let _ = Self._printChanges()
// Only re-evaluates when store.name changes
// Changes to email, avatarURL, preferences do NOT trigger this view
Text(store.name)
}
}
```
### Migration Benefit
| Scenario | ObservableObject | @Observable |
|----------|-----------------|-------------|
| 10 views observe a model with 5 properties | Changing 1 property re-evaluates all 10 views | Only views reading that specific property re-evaluate |
| List of 100 rows, each observing a shared store | All 100 rows re-evaluate on any change | Only affected rows re-evaluate |
| Form with 20 fields bound to a model | Every keystroke re-evaluates all 20 fields | Only the active field re-evaluates |
### @Observable Gotchas
**Reading a property outside body does not create observation:**
```swift
@Observable class Model {
var value = 0
}
struct MyView: View {
var model: Model
// ❌ This read happens in init, not body -- no observation established
var label: String { "Value: \(model.value)" }
var body: some View {
// ✅ Must read model.value inside body (directly or via computed property called from body)
Text("Value: \(model.value)")
}
}
```
**Computed properties that read observed properties do establish observation when called from body:**
```swift
@Observable class Model {
var firstName = ""
var lastName = ""
var fullName: String { "\(firstName) \(lastName)" }
}
struct NameView: View {
var model: Model
var body: some View {
// ✅ This observes both firstName and lastName
// because fullName reads them, and fullName is called from body
Text(model.fullName)
}
}
```
## Minimizing Re-evaluations
### Strategy 1: Extract Subviews
The most effective technique. Each extracted subview only re-evaluates when its own dependencies change:
```swift
// ❌ Monolithic: entire view re-evaluates when any state changes
struct ProfileView: View {
@State private var isEditingName = false
@State private var isEditingBio = false
var user: User
var body: some View {
VStack {
// All of this re-evaluates when isEditingBio changes
HStack {
Text(user.name)
Button("Edit") { isEditingName.toggle() }
}
// All of this re-evaluates when isEditingName changes
HStack {
Text(user.bio)
Button("Edit") { isEditingBio.toggle() }
}
}
}
}
// ✅ Split: each section only re-evaluates for its own state
struct ProfileView: View {
var user: User
var body: some View {
VStack {
NameSection(name: user.name)
BioSection(bio: user.bio)
}
}
}
struct NameSection: View {
let name: String
@State private var isEditing = false
var body: some View {
HStack {
Text(name)
Button("Edit") { isEditing.toggle() }
}
}
}
struct BioSection: View {
let bio: String
@State private var isEditing = false
var body: some View {
HStack {
Text(bio)
Button("Edit") { isEditing.toggle() }
}
}
}
```
### Strategy 2: Use EquatableView or Equatable Conformance
Tell SwiftUI to skip body if properties are equal:
```swift
struct ExpensiveView: View, Equatable {
let data: LargeDataSet
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.data.id == rhs.data.id && lhs.data.version == rhs.data.version
}
var body: some View {
// Complex rendering that should only happen when data truly changes
ComplexChart(data: data)
}
}
// Usage: .equatable() tells SwiftUI to use the Equatable conformance
ExpensiveView(data: chartData)
.equatable()
```
### Strategy 3: Move State Down
Keep `@State` as close to the view that uses it as possible:
```swift
// ❌ State at the top causes the entire tree to re-evaluate
struct ContentView: View {
@State private var searchText = "" // Every keystroke re-evaluates everything
var body: some View {
NavigationStack {
VStack {
SearchBar(text: $searchText)
ResultsList(query: searchText)
// ... many other views that don't need searchText
SidePanel()
Footer()
}
}
}
}
// ✅ Encapsulate search state in a dedicated view
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
SearchSection() // Owns its own @State
SidePanel() // Not affected by search keystrokes
Footer() // Not affected by search keystrokes
}
}
}
}
struct SearchSection: View {
@State private var searchText = ""
var body: some View {
VStack {
SearchBar(text: $searchText)
ResultsList(query: searchText)
}
}
}
```
## Counting Body Evaluations
### Instruments SwiftUI Template
The **View Body** instrument counts how many times each view type's body is called:
1. **Xcode > Product > Profile** (Cmd+I)
2. Choose **SwiftUI** template
3. Record while reproducing the slow interaction
4. The **View Body** lane shows evaluation counts per view type
5. Sort by count to find views that evaluate most frequently
### Manual Counting with os_signpost
```swift
import os
private let logger = Logger(subsystem: "com.app", category: "ViewBody")
struct MyView: View {
var body: some View {
let _ = logger.debug("MyView.body evaluated")
// ... view content
}
}
```
Filter Console output by your subsystem to see evaluation frequency.
### What Counts Are Acceptable
| Scenario | Expected body count | Concerning if |
|----------|-------------------|---------------|
| View appears once | 1-2 | > 5 |
| Scroll through 100 rows | ~20-30 (visible + prefetch) | 100+ (not lazy) |
| Typing in a text field | 1 per keystroke for the field | 50+ other views also updating |
| Tapping a button | 1-3 for affected views | 20+ unrelated views updating |
## Environment Changes
Environment values like `colorScheme`, `dynamicTypeSize`, and `locale` affect **every** view in the subtree. When they change, a large portion of the view tree re-evaluates.
This is normal and expected -- but avoid reading environment values in views that do not need them:
```swift
// ❌ Reads colorScheme even though it does not use it
struct ItemRow: View {
@Environment(\.colorScheme) private var colorScheme
let item: Item
var body: some View {
Text(item.title) // Does not actually use colorScheme
}
}
// ✅ Only read environment values you actually use in body
struct ItemRow: View {
let item: Item
var body: some View {
Text(item.title)
}
}
```
```
### lazy-loading.md
```markdown
# Lazy vs Eager Loading
How SwiftUI loads views in stacks, lists, and grids -- and the performance consequences of choosing the wrong container.
## Eager vs Lazy Containers
### Eager Containers
`VStack`, `HStack`, and `ZStack` create **all** child views immediately, regardless of whether they are visible on screen:
```swift
// ❌ Creates ALL 10,000 rows immediately -- hangs on appear
ScrollView {
VStack {
ForEach(items) { item in // 10,000 items
ItemRow(item: item) // All 10,000 created at once
}
}
}
```
### Lazy Containers
`LazyVStack`, `LazyHStack`, `LazyVGrid`, and `LazyHGrid` create child views **on demand** as they scroll into the visible area:
```swift
// ✅ Only creates visible rows + a small prefetch buffer
ScrollView {
LazyVStack {
ForEach(items) { item in // 10,000 items
ItemRow(item: item) // ~20-30 created at a time
}
}
}
```
### List
`List` is **always lazy**. It manages its own scroll view and creates rows on demand:
```swift
// ✅ Always lazy -- handles large data sets efficiently
List(items) { item in
ItemRow(item: item)
}
```
## When to Use Each Container
| Container | Use when | Creates views |
|-----------|----------|---------------|
| `VStack` / `HStack` | Few items (< 50) or all must render at once | All immediately |
| `LazyVStack` / `LazyHStack` | Many items in a `ScrollView` | On demand |
| `LazyVGrid` / `LazyHGrid` | Grid layout with many items | On demand |
| `List` | Scrollable list with system styling (swipe actions, separators) | On demand |
Rule of thumb: if you have a `ForEach` with more than ~50 items inside a `ScrollView`, use a lazy container.
## LazyVStack vs VStack: Performance Comparison
### The Cost of Eager Loading
For a list of 1,000 items, each with a moderately complex row:
| Metric | `VStack` | `LazyVStack` |
|--------|----------|-------------|
| Views created on appear | 1,000 | ~20-30 |
| Time to first frame | Seconds (hang) | Milliseconds |
| Memory at rest | All 1,000 rows in memory | Only visible rows |
| Scroll start latency | None (already created) | Small (creates as you scroll) |
### Practical Threshold
| Item count | Recommendation |
|-----------|---------------|
| 1-20 | `VStack` is fine |
| 20-50 | Either works; use `LazyVStack` if rows are complex |
| 50+ | Always use `LazyVStack` or `List` |
| 500+ | `List` or `LazyVStack` mandatory; consider pagination |
## LazyVStack Configuration
### Pinned Headers
```swift
ScrollView {
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
ForEach(sections) { section in
Section {
ForEach(section.items) { item in
ItemRow(item: item)
}
} header: {
SectionHeader(title: section.title)
}
}
}
}
```
### Alignment and Spacing
```swift
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(items) { item in
ItemRow(item: item)
}
}
```
## Common Pitfalls with Lazy Containers
### Pitfall 1: Child Views That Defeat Laziness
If a child view requests infinite height, the lazy container must measure all children to determine layout, defeating the purpose of laziness:
```swift
// ❌ .frame(maxHeight: .infinity) forces the lazy container to measure all children
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
.frame(maxHeight: .infinity) // Defeats laziness!
}
}
}
// ✅ Use fixed or intrinsic sizing
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
.frame(height: 60) // Fixed height, laziness preserved
}
}
}
```
### Pitfall 2: GeometryReader Inside Lazy Containers
`GeometryReader` proposes `.infinity` to its children. Inside a lazy container, this can cause layout issues:
```swift
// ❌ GeometryReader inside LazyVStack: layout problems
ScrollView {
LazyVStack {
ForEach(items) { item in
GeometryReader { proxy in
ItemRow(item: item, width: proxy.size.width)
}
}
}
}
// ✅ Use GeometryReader outside the lazy container
ScrollView {
GeometryReader { proxy in
LazyVStack {
ForEach(items) { item in
ItemRow(item: item, width: proxy.size.width)
}
}
}
}
// ✅ Or use containerRelativeFrame (iOS 17+)
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
.containerRelativeFrame(.horizontal)
}
}
}
```
### Pitfall 3: ScrollView With No Lazy Container
A `ScrollView` does **not** make its content lazy. It only provides scrolling:
```swift
// ❌ ScrollView alone does NOT make content lazy
ScrollView {
ForEach(items) { item in // All created immediately!
ItemRow(item: item)
}
}
// ✅ Must wrap in LazyVStack
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
```
### Pitfall 4: Nesting ScrollViews
Nesting scroll views in the same direction causes undefined behavior:
```swift
// ❌ Nested vertical scroll views -- inner scroll may not work
ScrollView {
LazyVStack {
ForEach(sections) { section in
ScrollView { // Nested vertical scroll!
ForEach(section.items) { item in
ItemRow(item: item)
}
}
}
}
}
// ✅ Use a single scroll view with sections
ScrollView {
LazyVStack(pinnedViews: [.sectionHeaders]) {
ForEach(sections) { section in
Section {
ForEach(section.items) { item in
ItemRow(item: item)
}
} header: {
SectionHeader(title: section.title)
}
}
}
}
```
## Grid Performance
### LazyVGrid
```swift
let columns = [
GridItem(.adaptive(minimum: 150, maximum: 200))
]
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
ItemCell(item: item)
}
}
.padding()
}
```
### Fixed vs Adaptive vs Flexible Columns
| Column type | Behavior | Performance note |
|------------|----------|-----------------|
| `.fixed(200)` | Exact width | Fastest layout calculation |
| `.flexible(minimum: 100, maximum: 300)` | Fills available space | Moderate layout cost |
| `.adaptive(minimum: 150)` | As many as fit | Most layout calculation |
For large grids (500+ items), prefer `.fixed()` columns to minimize layout computation.
## List Performance
### Built-in Optimizations
`List` provides features that lazy stacks do not:
- Cell reuse (like `UITableView`)
- Built-in swipe actions
- Separator management
- Edit mode with reordering and deletion
### List Optimization Tips
```swift
List(items) { item in
ItemRow(item: item)
.listRowSeparator(.hidden) // Slightly faster if you don't need separators
}
.listStyle(.plain) // .plain is faster than .insetGrouped for large lists
```
### When to Use List vs LazyVStack
| Feature | `List` | `LazyVStack` in `ScrollView` |
|---------|--------|------------------------------|
| Cell reuse | Yes | No (but still lazy creation) |
| Custom styling | Limited | Full control |
| Swipe actions | Built-in | Manual implementation |
| Section headers | Built-in | Manual with `pinnedViews` |
| Scroll performance | Generally better for 1000+ items | Good for most cases |
| Pull to refresh | `.refreshable()` works | `.refreshable()` works |
## Scroll Position and Prefetching
### ScrollViewReader for Programmatic Scrolling
```swift
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
.id(item.id)
}
}
}
.onChange(of: selectedItemID) { _, newID in
withAnimation {
proxy.scrollTo(newID, anchor: .center)
}
}
}
```
### Data Prefetching for Pagination
Lazy containers do not have built-in prefetch callbacks like `UICollectionViewDataSourcePrefetching`. Implement manually:
```swift
struct PaginatedListView: View {
@State private var items: [Item] = []
@State private var isLoadingMore = false
var body: some View {
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
.onAppear {
if item == items.last {
loadMoreIfNeeded()
}
}
}
if isLoadingMore {
ProgressView()
}
}
}
}
private func loadMoreIfNeeded() {
guard !isLoadingMore else { return }
isLoadingMore = true
Task {
let newItems = await fetchNextPage()
items.append(contentsOf: newItems)
isLoadingMore = false
}
}
}
```
## Debugging Lazy Loading Issues
### Confirming Laziness
Add a print statement to verify views are created lazily:
```swift
struct ItemRow: View {
let item: Item
init(item: Item) {
self.item = item
print("ItemRow created: \(item.id)") // Should only print for visible rows
}
var body: some View {
Text(item.title)
}
}
```
If you see all 1,000 items printed at once, the container is not lazy.
### Measuring Scroll Frame Rate
Use CADisplayLink or the Core Animation Commits instrument in Instruments to measure frame rate during scrolling. Dropped frames (below 60fps / 120fps on ProMotion) indicate scroll performance issues.
```
### common-pitfalls.md
```markdown
# Common SwiftUI Performance Pitfalls
Specific anti-patterns that cause SwiftUI performance problems, with concrete before/after fixes for each.
## 1. AnyView Type Erasure
### The Problem
`AnyView` wraps a view in a type-erased container. SwiftUI uses **type information** for its diffing algorithm -- when types are erased, SwiftUI cannot efficiently diff and must do more work:
- Cannot use structural identity (all `AnyView` instances have the same type)
- Cannot skip diffing subtrees when the type has not changed
- Prevents compile-time optimization of the view hierarchy
### Before (Slow)
```swift
// ❌ AnyView defeats SwiftUI's diffing optimization
func makeView(for item: Item) -> AnyView {
switch item.kind {
case .text:
return AnyView(TextItemView(item: item))
case .image:
return AnyView(ImageItemView(item: item))
case .video:
return AnyView(VideoItemView(item: item))
}
}
// Usage in ForEach compounds the problem
ForEach(items) { item in
makeView(for: item) // SwiftUI cannot diff between renders
}
```
### After (Fast)
```swift
// ✅ @ViewBuilder preserves type information for the compiler
@ViewBuilder
func makeView(for item: Item) -> some View {
switch item.kind {
case .text:
TextItemView(item: item)
case .image:
ImageItemView(item: item)
case .video:
VideoItemView(item: item)
}
}
// ✅ Or better: dedicated view struct (avoids large switch in @ViewBuilder)
struct ItemView: View {
let item: Item
var body: some View {
switch item.kind {
case .text:
TextItemView(item: item)
case .image:
ImageItemView(item: item)
case .video:
VideoItemView(item: item)
}
}
}
```
### When AnyView Is Acceptable
`AnyView` has negligible cost in these cases:
- A single view used once (not in a list or `ForEach`)
- Prototyping / throwaway code
- Bridging to UIKit hosting controllers where type erasure is required
## 2. Object Creation in body
### The Problem
`body` can be called frequently (dozens of times per second during animation). Creating objects each time wastes CPU and memory:
```swift
// ❌ Creates a new DateFormatter every time body runs
// DateFormatter init is ~50x more expensive than a simple alloc
var body: some View {
let formatter = DateFormatter()
formatter.dateStyle = .medium
Text(formatter.string(from: date))
}
```
### Expensive Objects to Watch For
| Object | Cost of creation | Alternative |
|--------|-----------------|-------------|
| `DateFormatter` | ~5-10 microseconds | `static let` shared instance |
| `NumberFormatter` | ~5-10 microseconds | `static let` shared instance |
| `JSONDecoder` / `JSONEncoder` | Moderate | `static let` shared instance |
| `NSRegularExpression` | Moderate (compiles regex) | `static let` or Swift `Regex` literal |
| `NSPredicate` | Moderate | `static let` or reuse via `@State` |
| `URLSession` | Heavy | Use `URLSession.shared` or inject |
| View models / controllers | Heavy | `@State` or `@StateObject` |
| `AttributedString` with markdown | Moderate (parses markdown) | Cache in model or `@State` |
### After (Fast)
```swift
// ✅ Static shared formatter -- created once, reused forever
struct DateLabel: View {
let date: Date
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
var body: some View {
Text(Self.formatter.string(from: date))
}
}
// ✅ Or use the modern formatted() API which handles caching internally
struct DateLabel: View {
let date: Date
var body: some View {
Text(date.formatted(.dateTime.month().day().year()))
}
}
```
## 3. Over-Observation with ObservableObject
### The Problem
With `ObservableObject`, changing **any** `@Published` property notifies **all** observers. If a large view observes a store with many properties, it re-evaluates on every change:
```swift
// ❌ All views observing AppState re-evaluate when ANY property changes
class AppState: ObservableObject {
@Published var user: User?
@Published var items: [Item] = []
@Published var searchText = "" // Typing here re-evaluates everything
@Published var selectedTab = 0
@Published var isOnboarding = false
@Published var notifications: [Notification] = []
}
```
### Fix Option A: Migrate to @Observable (iOS 17+)
```swift
// ✅ Per-property observation -- views only update when their dependencies change
@Observable
class AppState {
var user: User?
var items: [Item] = []
var searchText = "" // Only views reading searchText re-evaluate
var selectedTab = 0
var notifications: [Notification] = []
}
```
### Fix Option B: Split Into Focused Stores (Pre-iOS 17)
```swift
// ✅ Each store has a narrow scope
class UserStore: ObservableObject {
@Published var user: User?
}
class SearchStore: ObservableObject {
@Published var searchText = ""
@Published var results: [Item] = []
}
class NavigationStore: ObservableObject {
@Published var selectedTab = 0
}
```
### Fix Option C: Extract Subviews to Narrow Scope
```swift
// ❌ Entire view re-evaluates when searchText changes
struct ContentView: View {
@ObservedObject var store: AppState
var body: some View {
VStack {
TextField("Search", text: $store.searchText)
ItemList(items: store.items)
UserBadge(user: store.user)
}
}
}
// ✅ Each subview only connects to what it needs
struct ContentView: View {
@ObservedObject var store: AppState
var body: some View {
VStack {
SearchBar(store: store)
ItemList(items: store.items)
UserBadge(user: store.user)
}
}
}
struct SearchBar: View {
@ObservedObject var store: AppState // Re-evaluates often, but it's tiny
var body: some View {
TextField("Search", text: $store.searchText)
}
}
```
## 4. Expensive Computation in body
### The Problem
`body` runs on the main thread. Any expensive work directly blocks the UI:
```swift
// ❌ Filtering and sorting 10,000 items every time body runs
var body: some View {
let filtered = items.filter { $0.matchesQuery(searchText) }
let sorted = filtered.sorted { $0.date > $1.date }
List(sorted) { item in
ItemRow(item: item)
}
}
```
### After: Cache Computed Results
```swift
// ✅ Compute on change, not on every body call
struct ItemListView: View {
let items: [Item]
@State private var searchText = ""
@State private var filteredItems: [Item] = []
var body: some View {
VStack {
TextField("Search", text: $searchText)
List(filteredItems) { item in
ItemRow(item: item)
}
}
.onChange(of: searchText) { _, newValue in
filteredItems = items.filter { $0.matchesQuery(newValue) }
.sorted { $0.date > $1.date }
}
.onAppear {
filteredItems = items.sorted { $0.date > $1.date }
}
}
}
```
### After: Offload to Background for Large Data Sets
```swift
// ✅ Background computation for expensive operations
struct ItemListView: View {
let items: [Item]
@State private var searchText = ""
@State private var filteredItems: [Item] = []
var body: some View {
VStack {
TextField("Search", text: $searchText)
List(filteredItems) { item in
ItemRow(item: item)
}
}
.task(id: searchText) {
// Runs on a background cooperative thread
// Automatically cancelled if searchText changes again
let query = searchText
let allItems = items
let results = await Task.detached {
allItems.filter { $0.matchesQuery(query) }
.sorted { $0.date > $1.date }
}.value
filteredItems = results
}
}
}
```
## 5. Large Images Without Proper Sizing
### The Problem
Loading full-resolution images in a scrolling list causes memory spikes and decode latency:
```swift
// ❌ Loads full resolution, decodes on main thread
List(photos) { photo in
Image(uiImage: UIImage(contentsOfFile: photo.path)!)
.resizable()
.frame(height: 200)
}
```
### After (Fast)
```swift
// ✅ AsyncImage handles loading and caching
List(photos) { photo in
AsyncImage(url: photo.url) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(height: 200)
.clipped()
}
// ✅ For local images: thumbnail generation off main thread
struct ThumbnailView: View {
let imagePath: String
@State private var thumbnail: UIImage?
var body: some View {
Group {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
} else {
Color.gray.opacity(0.2)
}
}
.frame(height: 200)
.clipped()
.task {
thumbnail = await UIImage(contentsOfFile: imagePath)?
.byPreparingThumbnail(ofSize: CGSize(width: 400, height: 400))
}
}
}
```
## 6. Unnecessary @State Updates
### The Problem
Setting a `@State` variable to its current value still triggers a re-evaluation:
```swift
// ❌ Triggers re-evaluation even when value hasn't changed
.onReceive(timer) { _ in
currentTime = Date() // Always a new value, always triggers body
}
// ❌ Setting state in body (causes infinite loop risk)
var body: some View {
let _ = { isReady = true }() // NEVER modify state during body
Text("Hello")
}
```
### After (Fast)
```swift
// ✅ Guard against redundant updates
.onReceive(timer) { _ in
let newTime = Date()
// Only update if the displayed value actually changes
if Calendar.current.component(.second, from: newTime) !=
Calendar.current.component(.second, from: currentTime) {
currentTime = newTime
}
}
// ✅ Use TimelineView for time-based updates (iOS 15+)
TimelineView(.periodic(from: .now, by: 1.0)) { context in
Text(context.date.formatted(.dateTime.hour().minute().second()))
}
```
## 7. Deep View Hierarchies
### The Problem
Deeply nested view modifiers create large view trees that are expensive to diff:
```swift
// ❌ Each modifier adds a wrapper view to the tree
Text("Hello")
.padding()
.background(Color.blue)
.cornerRadius(8)
.padding()
.background(Color.gray)
.cornerRadius(12)
.shadow(radius: 4)
.padding()
.background(Color.white)
.cornerRadius(16)
```
### After: Consolidate Modifiers
```swift
// ✅ Flatten by using composite modifiers and fewer nesting levels
Text("Hello")
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue)
)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray)
.shadow(radius: 4)
)
.padding(8)
```
### Custom ViewModifier for Reusable Combinations
```swift
// ✅ Encapsulate common modifier combinations
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(uiColor: .secondarySystemBackground))
)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardStyle())
}
}
// Usage: single modifier instead of three
Text("Hello").cardStyle()
```
## 8. Inefficient Conditional Rendering
### The Problem
Using `opacity(0)` or `hidden()` to hide views still keeps them in the view tree and evaluates their body:
```swift
// ❌ View is invisible but still evaluated and in the layout
ExpensiveView()
.opacity(showExpensive ? 1 : 0)
```
### After: Conditional Inclusion
```swift
// ✅ View is not created at all when hidden
if showExpensive {
ExpensiveView()
}
// ✅ Or use Group with optional for cleaner syntax
Group {
if showExpensive {
ExpensiveView()
}
}
```
Note: this changes the view's structural identity (see view-identity.md). Use `opacity` when you need to preserve state; use `if` when you want to avoid the evaluation cost.
## Quick Diagnosis Table
| Symptom | Likely pitfall | Fix |
|---------|---------------|-----|
| Entire screen re-renders on any state change | Over-observation (#3) | Migrate to `@Observable` or split stores |
| List scrolling stutters | Eager loading, images (#5) | Use `LazyVStack`, async image loading |
| View body runs 100+ times/second | Unnecessary state updates (#6) | Guard against redundant state changes |
| Memory climbs while scrolling | Full-resolution images (#5) | Use thumbnails, `AsyncImage` |
| Initial load takes seconds | Eager container (#1 in lazy-loading.md) | Switch to lazy container |
| Type checker hangs during build | Large `@ViewBuilder` switch | Extract cases into separate view structs |
| Animation stutters | Expensive computation in body (#4) | Move to `.task {}` or `onChange` |
```
### ../profiling/SKILL.md
```markdown
---
name: performance-profiling
description: Guide performance profiling with Instruments, diagnose hangs, memory issues, slow launches, and energy drain. Use when reviewing app performance or investigating specific bottlenecks.
allowed-tools: [Read, Glob, Grep, Bash]
---
# Performance Profiling
Systematic guide for profiling Apple platform apps using Instruments, Xcode diagnostics, and MetricKit. Covers CPU, memory, launch time, and energy analysis with actionable fix patterns.
## When This Skill Activates
Use this skill when the user:
- Reports app hangs, stutters, or dropped frames
- Needs to profile CPU usage or find hot code paths
- Has memory leaks, high memory usage, or OOM crashes
- Wants to optimize app launch time
- Needs to reduce battery/energy impact
- Asks about Instruments, Time Profiler, Allocations, or Leaks
- Wants to add `os_signpost` or performance measurement to code
- Is preparing for App Store review and needs performance validation
## Decision Tree
```
What performance problem are you investigating?
│
├─ App hangs / stutters / dropped frames / slow UI
│ └─ Read time-profiler.md
│
├─ High memory / leaks / OOM crashes / growing footprint
│ └─ Read memory-profiling.md
│
├─ Slow app launch / time to first frame
│ └─ Read launch-optimization.md
│
├─ Battery drain / thermal throttling / background energy
│ └─ Read energy-diagnostics.md
│
├─ General "app feels slow" (unknown cause)
│ └─ Start with time-profiler.md, then memory-profiling.md
│
└─ Pre-release performance audit
└─ Read ALL reference files, use Review Checklist below
```
## Quick Reference
| Problem | Instrument / Tool | Key Metric | Reference |
|---------|-------------------|------------|-----------|
| UI hangs > 250ms | Time Profiler + Hangs | Hang duration, main thread stack | time-profiler.md |
| High CPU usage | Time Profiler | CPU % by function, call tree weight | time-profiler.md |
| Memory leak | Leaks + Memory Graph | Leaked bytes, retain cycle paths | memory-profiling.md |
| Memory growth | Allocations | Live bytes, generation analysis | memory-profiling.md |
| Slow launch | App Launch | Time to first frame (pre-main + post-main) | launch-optimization.md |
| Battery drain | Energy Log | Energy Impact score, CPU/GPU/network | energy-diagnostics.md |
| Thermal issues | Activity Monitor | Thermal state transitions | energy-diagnostics.md |
| Network waste | Network profiler | Redundant fetches, large payloads | energy-diagnostics.md |
## Process
### 1. Identify the Problem Category
Ask the user or inspect their description to classify the issue:
- **Responsiveness**: Hangs, stutters, animation drops
- **Memory**: Leaks, growth, OOM crashes
- **Launch**: Slow cold/warm start
- **Energy**: Battery drain, thermal throttling
### 2. Read the Appropriate Reference File
Each file contains:
- Which Instruments template to use
- Step-by-step profiling workflow
- How to interpret results
- Common fix patterns with code examples
### 3. Profile on Real Hardware
Always remind users:
- **Profile on device**, not Simulator (Simulator uses host CPU/memory)
- Use **Release** build configuration (optimizations change behavior)
- Profile with **representative data** (empty databases hide real perf)
- Close other apps to reduce noise
### 4. Apply Fixes and Verify
After identifying bottlenecks:
- Apply targeted fix from the reference file
- Re-profile to confirm improvement
- Add `os_signpost` markers for ongoing monitoring
## Xcode Diagnostic Settings
Recommend enabling these in **Scheme > Run > Diagnostics**:
| Setting | What It Catches |
|---------|-----------------|
| Main Thread Checker | UI work off main thread |
| Thread Sanitizer | Data races |
| Address Sanitizer | Buffer overflows, use-after-free |
| Malloc Stack Logging | Memory allocation call stacks |
| Zombie Objects | Messages to deallocated objects |
## MetricKit Integration
For production monitoring, recommend MetricKit:
```swift
import MetricKit
final class PerformanceReporter: NSObject, MXMetricManagerSubscriber {
func startCollecting() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// Launch time
if let launch = payload.applicationLaunchMetrics {
log("Resume time: \(launch.histogrammedResumeTime)")
}
// Hang rate
if let responsiveness = payload.applicationResponsivenessMetrics {
log("Hang time: \(responsiveness.histogrammedApplicationHangTime)")
}
// Memory
if let memory = payload.memoryMetrics {
log("Peak memory: \(memory.peakMemoryUsage)")
}
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let hangs = payload.hangDiagnostics {
for hang in hangs {
log("Hang: \(hang.callStackTree)")
}
}
}
}
}
```
## Review Checklist
### Responsiveness
- [ ] No synchronous work on main thread > 100ms
- [ ] No file I/O or network calls on main thread
- [ ] Core Data / SwiftData fetches use background contexts for large queries
- [ ] Images decoded off main thread (use `.preparingThumbnail` or async decoding)
- [ ] `@MainActor` only on code that truly needs UI access
### Memory
- [ ] No retain cycles (check delegate patterns, closures with `self`)
- [ ] Large resources freed when not visible (images, caches)
- [ ] Collections don't grow unbounded (capped caches, pagination)
- [ ] `autoreleasepool` used in tight loops creating ObjC objects
### Launch Time
- [ ] No heavy work in `init()` of `@main App` struct
- [ ] Deferred non-essential initialization (analytics, prefetch)
- [ ] Minimal dynamic frameworks (prefer static linking)
- [ ] No synchronous network calls at launch
### Energy
- [ ] Background tasks use `BGProcessingTaskRequest` appropriately
- [ ] Location accuracy matches actual need (not always `.best`)
- [ ] Timers use `tolerance` to allow coalescing
- [ ] Network requests batched where possible
## References
- **time-profiler.md** — CPU profiling, hang detection, signpost API
- **memory-profiling.md** — Allocations, Leaks, memory graph debugger
- **launch-optimization.md** — App launch phases, cold/warm start optimization
- **energy-diagnostics.md** — Battery, thermal state, network efficiency
- [WWDC: Ultimate Application Performance Survival Guide](https://developer.apple.com/videos/play/wwdc2021/10181/)
- [WWDC: Analyze Hangs with Instruments](https://developer.apple.com/videos/play/wwdc2023/10248/)
- [WWDC: Detect and Diagnose Memory Issues](https://developer.apple.com/videos/play/wwdc2021/10180/)
```