@kata-sh/cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +95 -0
- package/dist/resource-loader.d.ts +18 -0
- package/dist/resource-loader.js +50 -0
- package/dist/wizard.d.ts +15 -0
- package/dist/wizard.js +159 -0
- package/package.json +50 -21
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +45 -0
- package/src/resources/AGENTS.md +108 -0
- package/src/resources/KATA-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +200 -0
- package/src/resources/extensions/bg-shell/index.ts +2758 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4916 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/github/formatters.ts +207 -0
- package/src/resources/extensions/github/gh-api.ts +537 -0
- package/src/resources/extensions/github/index.ts +778 -0
- package/src/resources/extensions/kata/activity-log.ts +88 -0
- package/src/resources/extensions/kata/auto.ts +2786 -0
- package/src/resources/extensions/kata/commands.ts +355 -0
- package/src/resources/extensions/kata/crash-recovery.ts +85 -0
- package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
- package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
- package/src/resources/extensions/kata/doctor.ts +683 -0
- package/src/resources/extensions/kata/files.ts +730 -0
- package/src/resources/extensions/kata/gitignore.ts +165 -0
- package/src/resources/extensions/kata/guided-flow.ts +976 -0
- package/src/resources/extensions/kata/index.ts +556 -0
- package/src/resources/extensions/kata/metrics.ts +397 -0
- package/src/resources/extensions/kata/observability-validator.ts +408 -0
- package/src/resources/extensions/kata/package.json +11 -0
- package/src/resources/extensions/kata/paths.ts +346 -0
- package/src/resources/extensions/kata/preferences.ts +695 -0
- package/src/resources/extensions/kata/prompt-loader.ts +50 -0
- package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
- package/src/resources/extensions/kata/prompts/discuss.md +151 -0
- package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
- package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
- package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
- package/src/resources/extensions/kata/prompts/queue.md +85 -0
- package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
- package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
- package/src/resources/extensions/kata/prompts/system.md +341 -0
- package/src/resources/extensions/kata/session-forensics.ts +550 -0
- package/src/resources/extensions/kata/skill-discovery.ts +137 -0
- package/src/resources/extensions/kata/state.ts +509 -0
- package/src/resources/extensions/kata/templates/context.md +76 -0
- package/src/resources/extensions/kata/templates/decisions.md +8 -0
- package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/kata/templates/plan.md +133 -0
- package/src/resources/extensions/kata/templates/preferences.md +15 -0
- package/src/resources/extensions/kata/templates/project.md +31 -0
- package/src/resources/extensions/kata/templates/reassessment.md +28 -0
- package/src/resources/extensions/kata/templates/requirements.md +81 -0
- package/src/resources/extensions/kata/templates/research.md +46 -0
- package/src/resources/extensions/kata/templates/roadmap.md +118 -0
- package/src/resources/extensions/kata/templates/slice-context.md +58 -0
- package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
- package/src/resources/extensions/kata/templates/state.md +19 -0
- package/src/resources/extensions/kata/templates/task-plan.md +52 -0
- package/src/resources/extensions/kata/templates/task-summary.md +57 -0
- package/src/resources/extensions/kata/templates/uat.md +54 -0
- package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
- package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
- package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
- package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
- package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
- package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
- package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
- package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
- package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
- package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
- package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
- package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
- package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
- package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
- package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
- package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
- package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
- package/src/resources/extensions/kata/types.ts +159 -0
- package/src/resources/extensions/kata/unit-runtime.ts +163 -0
- package/src/resources/extensions/kata/workspace-index.ts +203 -0
- package/src/resources/extensions/kata/worktree.ts +182 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +68 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +822 -0
- package/src/resources/extensions/shared/next-action-ui.ts +235 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +92 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1293 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
- package/dist/commands/task.d.ts +0 -9
- package/dist/commands/task.d.ts.map +0 -1
- package/dist/commands/task.js +0 -129
- package/dist/commands/task.js.map +0 -1
- package/dist/commands/task.test.d.ts +0 -2
- package/dist/commands/task.test.d.ts.map +0 -1
- package/dist/commands/task.test.js +0 -169
- package/dist/commands/task.test.js.map +0 -1
- package/dist/e2e/task-e2e.test.d.ts +0 -2
- package/dist/e2e/task-e2e.test.d.ts.map +0 -1
- package/dist/e2e/task-e2e.test.js +0 -173
- package/dist/e2e/task-e2e.test.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -93
- package/dist/index.js.map +0 -1
- package/dist/slug.d.ts +0 -2
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -12
- package/dist/slug.js.map +0 -1
- package/dist/slug.test.d.ts +0 -2
- package/dist/slug.test.d.ts.map +0 -1
- package/dist/slug.test.js +0 -32
- package/dist/slug.test.js.map +0 -1
|
@@ -0,0 +1,1706 @@
|
|
|
1
|
+
# SwiftUI Performance Reference
|
|
2
|
+
|
|
3
|
+
<overview>
|
|
4
|
+
SwiftUI's declarative, data-driven architecture provides automatic UI updates, but this comes with performance implications. Understanding the update cycle, view identity, and optimization strategies enables building responsive apps.
|
|
5
|
+
|
|
6
|
+
**Core Performance Model:**
|
|
7
|
+
|
|
8
|
+
1. **State Change**: A property wrapped with @State, @Observable, or similar changes
|
|
9
|
+
2. **Body Recomputation**: SwiftUI evaluates the view's body property
|
|
10
|
+
3. **Diffing**: SwiftUI compares new view hierarchy against previous
|
|
11
|
+
4. **Minimal Updates**: Only changed parts render to screen via Core Animation
|
|
12
|
+
|
|
13
|
+
**Key Principle**: SwiftUI only recomputes body when dependency values change. Mastering what triggers recomputation and how to minimize it is essential for performance.
|
|
14
|
+
|
|
15
|
+
**Performance Philosophy**: Profile before optimizing. SwiftUI includes automatic optimizations. Only intervene when profiling identifies actual bottlenecks. Premature optimization adds complexity without benefit.
|
|
16
|
+
</overview>
|
|
17
|
+
|
|
18
|
+
<view_identity>
|
|
19
|
+
**View Identity** determines how SwiftUI tracks views across updates. Identity affects state preservation, transitions, and performance.
|
|
20
|
+
|
|
21
|
+
## Two Types of Identity
|
|
22
|
+
|
|
23
|
+
### Structural Identity
|
|
24
|
+
|
|
25
|
+
SwiftUI identifies views by their position in the view hierarchy. Most common form of identity.
|
|
26
|
+
|
|
27
|
+
```swift
|
|
28
|
+
// Structural identity - views identified by position
|
|
29
|
+
VStack {
|
|
30
|
+
Text("First") // Identity: VStack > position 0
|
|
31
|
+
Text("Second") // Identity: VStack > position 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Problematic: branches change structural identity
|
|
35
|
+
if isLoggedIn {
|
|
36
|
+
ProfileView() // Identity: if branch > ProfileView
|
|
37
|
+
} else {
|
|
38
|
+
LoginView() // Identity: else branch > LoginView
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Best Practice**: Prefer conditional modifiers over branches to preserve identity:
|
|
43
|
+
|
|
44
|
+
```swift
|
|
45
|
+
// Bad - changes structural identity, loses state
|
|
46
|
+
if isExpanded {
|
|
47
|
+
DetailView(expanded: true)
|
|
48
|
+
} else {
|
|
49
|
+
DetailView(expanded: false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Good - preserves structural identity
|
|
53
|
+
DetailView(expanded: isExpanded)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Explicit Identity
|
|
57
|
+
|
|
58
|
+
Use the `.id()` modifier to explicitly control identity. SwiftUI treats views with different IDs as completely distinct.
|
|
59
|
+
|
|
60
|
+
```swift
|
|
61
|
+
// Force view recreation by changing ID
|
|
62
|
+
ScrollView {
|
|
63
|
+
ContentView()
|
|
64
|
+
.id(selectedCategory) // New ID = destroy and recreate
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// List items use Identifiable for explicit identity
|
|
68
|
+
struct Item: Identifiable {
|
|
69
|
+
let id: UUID
|
|
70
|
+
let name: String
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
List(items) { item in
|
|
74
|
+
Text(item.name) // SwiftUI tracks by item.id
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**When to Use .id()**:
|
|
79
|
+
|
|
80
|
+
- Reset view state (form after submission, scroll position)
|
|
81
|
+
- Force view recreation when data fundamentally changes
|
|
82
|
+
- Ensure transitions work correctly
|
|
83
|
+
|
|
84
|
+
**Performance Impact**: Changing a view's ID destroys the old view and creates a new one, discarding all state. Expensive operation - use judiciously.
|
|
85
|
+
|
|
86
|
+
## Identity and State Preservation
|
|
87
|
+
|
|
88
|
+
SwiftUI maintains @State values as long as view identity remains stable:
|
|
89
|
+
|
|
90
|
+
```swift
|
|
91
|
+
struct CounterView: View {
|
|
92
|
+
@State private var count = 0 // Preserved while identity stable
|
|
93
|
+
|
|
94
|
+
var body: some View {
|
|
95
|
+
VStack {
|
|
96
|
+
Text("Count: \(count)")
|
|
97
|
+
Button("Increment") { count += 1 }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Branching destroys identity and @State
|
|
103
|
+
if showCounter {
|
|
104
|
+
CounterView() // count resets to 0 when toggled
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Better: preserve identity with opacity/hidden
|
|
108
|
+
CounterView()
|
|
109
|
+
.opacity(showCounter ? 1 : 0) // State preserved
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Debugging Identity
|
|
113
|
+
|
|
114
|
+
Use `Self._printChanges()` to see what triggers body recomputation:
|
|
115
|
+
|
|
116
|
+
```swift
|
|
117
|
+
var body: some View {
|
|
118
|
+
let _ = Self._printChanges() // Xcode console shows changed properties
|
|
119
|
+
|
|
120
|
+
VStack {
|
|
121
|
+
Text("Content")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
</view_identity>
|
|
126
|
+
|
|
127
|
+
<lazy_containers>
|
|
128
|
+
**Lazy containers** create views on-demand as they scroll into view, rather than creating all views upfront.
|
|
129
|
+
|
|
130
|
+
## Lazy Stack Types
|
|
131
|
+
|
|
132
|
+
```swift
|
|
133
|
+
// LazyVStack - vertical scrolling
|
|
134
|
+
ScrollView {
|
|
135
|
+
LazyVStack(spacing: 16) {
|
|
136
|
+
ForEach(items) { item in
|
|
137
|
+
ItemRow(item: item) // Created only when visible
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// LazyHStack - horizontal scrolling
|
|
143
|
+
ScrollView(.horizontal) {
|
|
144
|
+
LazyHStack(spacing: 16) {
|
|
145
|
+
ForEach(items) { item in
|
|
146
|
+
ItemCard(item: item)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// LazyVGrid - grid layout
|
|
152
|
+
ScrollView {
|
|
153
|
+
LazyVGrid(columns: [
|
|
154
|
+
GridItem(.adaptive(minimum: 150))
|
|
155
|
+
], spacing: 16) {
|
|
156
|
+
ForEach(items) { item in
|
|
157
|
+
ItemCard(item: item)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// LazyHGrid - horizontal grid
|
|
163
|
+
ScrollView(.horizontal) {
|
|
164
|
+
LazyHGrid(rows: [
|
|
165
|
+
GridItem(.fixed(100)),
|
|
166
|
+
GridItem(.fixed(100))
|
|
167
|
+
], spacing: 16) {
|
|
168
|
+
ForEach(items) { item in
|
|
169
|
+
ItemCard(item: item)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Performance Characteristics
|
|
176
|
+
|
|
177
|
+
**Benefits**:
|
|
178
|
+
- **Reduced Memory**: 80-90% less memory than non-lazy equivalents for large lists
|
|
179
|
+
- **Faster Load**: Milliseconds vs seconds for initial render
|
|
180
|
+
- **Smooth Scrolling**: Maintains 60fps even with hundreds of items
|
|
181
|
+
|
|
182
|
+
**Tradeoffs**:
|
|
183
|
+
- Views created lazily incur small bookkeeping overhead
|
|
184
|
+
- Once created, views stay in memory (not recycled like UITableView)
|
|
185
|
+
- For very large datasets (thousands of items), List provides view recycling
|
|
186
|
+
|
|
187
|
+
```swift
|
|
188
|
+
// Memory comparison for 200 items:
|
|
189
|
+
// VStack: ~300MB, 2-3 second load
|
|
190
|
+
// LazyVStack: ~40MB, <100ms load
|
|
191
|
+
// List: ~40MB with view recycling (better for 1000+ items)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## When to Use Lazy Containers
|
|
195
|
+
|
|
196
|
+
**Use LazyVStack/LazyHStack when**:
|
|
197
|
+
- Scrolling list with dozens to hundreds of items
|
|
198
|
+
- Items contain images, videos, or heavy views
|
|
199
|
+
- Custom animations and transitions required
|
|
200
|
+
- ScrollView directly wraps the stack
|
|
201
|
+
|
|
202
|
+
**Use List when**:
|
|
203
|
+
- Thousands of items (view recycling needed)
|
|
204
|
+
- Standard list appearance acceptable
|
|
205
|
+
- Platform-native behavior desired
|
|
206
|
+
|
|
207
|
+
**Avoid Lazy when**:
|
|
208
|
+
- Small number of items (< 20)
|
|
209
|
+
- All views fit on screen without scrolling
|
|
210
|
+
- Lazy overhead exceeds benefit (profile first)
|
|
211
|
+
|
|
212
|
+
```swift
|
|
213
|
+
// Decision example
|
|
214
|
+
struct ContentView: View {
|
|
215
|
+
let items: [Item]
|
|
216
|
+
|
|
217
|
+
var body: some View {
|
|
218
|
+
ScrollView {
|
|
219
|
+
if items.count > 50 {
|
|
220
|
+
// Use lazy for large lists
|
|
221
|
+
LazyVStack {
|
|
222
|
+
ForEach(items) { ItemRow(item: $0) }
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// Regular stack fine for small lists
|
|
226
|
+
VStack {
|
|
227
|
+
ForEach(items) { ItemRow(item: $0) }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Lazy Container Best Practices
|
|
236
|
+
|
|
237
|
+
**Design Lightweight Views**: Lazy loading doesn't eliminate cost of heavy views.
|
|
238
|
+
|
|
239
|
+
```swift
|
|
240
|
+
// Bad - heavy view defeats lazy loading benefits
|
|
241
|
+
struct ItemRow: View {
|
|
242
|
+
let item: Item
|
|
243
|
+
|
|
244
|
+
var body: some View {
|
|
245
|
+
VStack {
|
|
246
|
+
AsyncImage(url: item.imageURL) { image in
|
|
247
|
+
image.resizable() // No size limit - uses full resolution
|
|
248
|
+
} placeholder: {
|
|
249
|
+
ProgressView()
|
|
250
|
+
}
|
|
251
|
+
Text(item.longDescription) // Renders all text upfront
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Good - lightweight view
|
|
257
|
+
struct ItemRow: View {
|
|
258
|
+
let item: Item
|
|
259
|
+
|
|
260
|
+
var body: some View {
|
|
261
|
+
VStack {
|
|
262
|
+
AsyncImage(url: item.imageURL) { image in
|
|
263
|
+
image
|
|
264
|
+
.resizable()
|
|
265
|
+
.aspectRatio(contentMode: .fill)
|
|
266
|
+
.frame(height: 200) // Limit size
|
|
267
|
+
.clipped()
|
|
268
|
+
} placeholder: {
|
|
269
|
+
Color.gray.frame(height: 200)
|
|
270
|
+
}
|
|
271
|
+
Text(item.shortDescription) // Just what's visible
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Pinned Views**: Use for sticky headers/footers.
|
|
278
|
+
|
|
279
|
+
```swift
|
|
280
|
+
LazyVStack(pinnedViews: [.sectionHeaders]) {
|
|
281
|
+
ForEach(sections) { section in
|
|
282
|
+
Section {
|
|
283
|
+
ForEach(section.items) { item in
|
|
284
|
+
ItemRow(item: item)
|
|
285
|
+
}
|
|
286
|
+
} header: {
|
|
287
|
+
Text(section.title)
|
|
288
|
+
.font(.headline)
|
|
289
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
290
|
+
.background(Color.gray.opacity(0.2))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
</lazy_containers>
|
|
296
|
+
|
|
297
|
+
<body_recomputation>
|
|
298
|
+
Understanding what triggers body recomputation and how to minimize it is critical for performance.
|
|
299
|
+
|
|
300
|
+
## What Triggers Body Evaluation
|
|
301
|
+
|
|
302
|
+
SwiftUI evaluates body when:
|
|
303
|
+
|
|
304
|
+
1. **@State property changes**: View owns the state
|
|
305
|
+
2. **@Binding updates**: Parent changed bound value
|
|
306
|
+
3. **@Observable property accessed in body changes**: Fine-grained observation
|
|
307
|
+
4. **ObservableObject publishes change**: Any @Published property (not fine-grained)
|
|
308
|
+
5. **@Environment value changes**: Environment changed
|
|
309
|
+
6. **Parent view recreates child**: Parent's body evaluated with different child value
|
|
310
|
+
|
|
311
|
+
```swift
|
|
312
|
+
struct ProfileView: View {
|
|
313
|
+
@State private var name = "User" // Change triggers body
|
|
314
|
+
@State private var age = 25 // Change triggers body
|
|
315
|
+
let id: UUID // Never changes - no trigger
|
|
316
|
+
|
|
317
|
+
var body: some View {
|
|
318
|
+
let _ = Self._printChanges() // Debug what changed
|
|
319
|
+
|
|
320
|
+
VStack {
|
|
321
|
+
Text("Name: \(name)") // Depends on name
|
|
322
|
+
Text("Age: \(age)") // Depends on age
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Minimizing Recomputation
|
|
329
|
+
|
|
330
|
+
### Extract Subviews
|
|
331
|
+
|
|
332
|
+
Move stable content into separate views to prevent recomputation:
|
|
333
|
+
|
|
334
|
+
```swift
|
|
335
|
+
// Bad - entire body recomputes on count change
|
|
336
|
+
struct ContentView: View {
|
|
337
|
+
@State private var count = 0
|
|
338
|
+
|
|
339
|
+
var body: some View {
|
|
340
|
+
VStack {
|
|
341
|
+
ExpensiveHeaderView() // Recomputes unnecessarily
|
|
342
|
+
Text("Count: \(count)")
|
|
343
|
+
Button("Increment") { count += 1 }
|
|
344
|
+
ExpensiveFooterView() // Recomputes unnecessarily
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Good - isolate expensive views
|
|
350
|
+
struct ContentView: View {
|
|
351
|
+
@State private var count = 0
|
|
352
|
+
|
|
353
|
+
var body: some View {
|
|
354
|
+
VStack {
|
|
355
|
+
StaticHeader() // Separate view - stable identity
|
|
356
|
+
CounterDisplay(count: count) // Only this recomputes
|
|
357
|
+
StaticFooter() // Separate view - stable identity
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
struct StaticHeader: View {
|
|
363
|
+
var body: some View {
|
|
364
|
+
ExpensiveHeaderView() // Body only called once
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Avoid Expensive Computations in Body
|
|
370
|
+
|
|
371
|
+
```swift
|
|
372
|
+
// Bad - recalculates on every body evaluation
|
|
373
|
+
struct ListView: View {
|
|
374
|
+
let items: [Item]
|
|
375
|
+
|
|
376
|
+
var body: some View {
|
|
377
|
+
let sortedItems = items.sorted { $0.date > $1.date } // Expensive!
|
|
378
|
+
List(sortedItems) { item in
|
|
379
|
+
Text(item.name)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Good - compute once, cache result
|
|
385
|
+
struct ListView: View {
|
|
386
|
+
let items: [Item]
|
|
387
|
+
|
|
388
|
+
private var sortedItems: [Item] {
|
|
389
|
+
items.sorted { $0.date > $1.date }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
var body: some View {
|
|
393
|
+
List(sortedItems) { item in
|
|
394
|
+
Text(item.name)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Better - compute outside view if possible
|
|
400
|
+
struct ListView: View {
|
|
401
|
+
let sortedItems: [Item] // Parent sorted once
|
|
402
|
+
|
|
403
|
+
var body: some View {
|
|
404
|
+
List(sortedItems) { item in
|
|
405
|
+
Text(item.name)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Use Equatable for Custom Comparison
|
|
412
|
+
|
|
413
|
+
Tell SwiftUI exactly when to recompute by conforming to Equatable:
|
|
414
|
+
|
|
415
|
+
```swift
|
|
416
|
+
struct ItemDetailView: View, Equatable {
|
|
417
|
+
let item: Item
|
|
418
|
+
let metadata: Metadata // Large, rarely changes
|
|
419
|
+
|
|
420
|
+
static func == (lhs: ItemDetailView, rhs: ItemDetailView) -> Bool {
|
|
421
|
+
lhs.item.id == rhs.item.id // Only recompute if item ID changes
|
|
422
|
+
// Ignores metadata changes
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
var body: some View {
|
|
426
|
+
VStack {
|
|
427
|
+
Text(item.name)
|
|
428
|
+
Text(metadata.description)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Use with .equatable() modifier
|
|
434
|
+
ParentView {
|
|
435
|
+
ItemDetailView(item: item, metadata: metadata)
|
|
436
|
+
.equatable() // Uses custom == for comparison
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Scope Data Sources Appropriately
|
|
441
|
+
|
|
442
|
+
```swift
|
|
443
|
+
// Bad - entire hierarchy recomputes
|
|
444
|
+
struct AppView: View {
|
|
445
|
+
@State private var settings = AppSettings() // Top-level state
|
|
446
|
+
|
|
447
|
+
var body: some View {
|
|
448
|
+
NavigationStack {
|
|
449
|
+
ContentView() // Recomputes when settings change
|
|
450
|
+
.environment(settings)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Good - state lives close to where it's used
|
|
456
|
+
struct SettingsButton: View {
|
|
457
|
+
@State private var showSettings = false // Local state
|
|
458
|
+
|
|
459
|
+
var body: some View {
|
|
460
|
+
Button("Settings") { showSettings = true }
|
|
461
|
+
.sheet(isPresented: $showSettings) {
|
|
462
|
+
SettingsView()
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
## Understanding Body Evaluation vs Rendering
|
|
469
|
+
|
|
470
|
+
**Critical distinction**: Body evaluation ≠ rendering to screen.
|
|
471
|
+
|
|
472
|
+
```swift
|
|
473
|
+
// Body evaluated frequently...
|
|
474
|
+
struct CounterView: View {
|
|
475
|
+
@State private var count = 0
|
|
476
|
+
|
|
477
|
+
var body: some View {
|
|
478
|
+
// This code runs on every evaluation
|
|
479
|
+
VStack {
|
|
480
|
+
Text("Count: \(count)") // ...but SwiftUI only renders if text changed
|
|
481
|
+
Button("Increment") { count += 1 }
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
SwiftUI evaluates body, then diffs the result. If nothing changed, no rendering occurs. This is why expensive computations hurt even if output is identical.
|
|
488
|
+
</body_recomputation>
|
|
489
|
+
|
|
490
|
+
<observable_performance>
|
|
491
|
+
**@Observable** (iOS 17+) provides superior performance compared to ObservableObject through fine-grained change tracking.
|
|
492
|
+
|
|
493
|
+
## ObservableObject Limitations
|
|
494
|
+
|
|
495
|
+
```swift
|
|
496
|
+
// ObservableObject - coarse-grained updates
|
|
497
|
+
class UserSettings: ObservableObject {
|
|
498
|
+
@Published var username = "User"
|
|
499
|
+
@Published var theme = "Light"
|
|
500
|
+
@Published var notifications = true
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
struct ProfileView: View {
|
|
504
|
+
@ObservedObject var settings: UserSettings
|
|
505
|
+
|
|
506
|
+
var body: some View {
|
|
507
|
+
VStack {
|
|
508
|
+
Text(settings.username) // Only reads username...
|
|
509
|
+
}
|
|
510
|
+
// ...but body recomputes when theme or notifications change!
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**Problem**: If ANY @Published property changes, ALL views observing the object recompute, regardless of which properties they actually read.
|
|
516
|
+
|
|
517
|
+
## @Observable Solution
|
|
518
|
+
|
|
519
|
+
```swift
|
|
520
|
+
// @Observable - fine-grained updates
|
|
521
|
+
@Observable
|
|
522
|
+
class UserSettings {
|
|
523
|
+
var username = "User"
|
|
524
|
+
var theme = "Light"
|
|
525
|
+
var notifications = true
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
struct ProfileView: View {
|
|
529
|
+
@State var settings: UserSettings
|
|
530
|
+
|
|
531
|
+
var body: some View {
|
|
532
|
+
VStack {
|
|
533
|
+
Text(settings.username) // Only reads username...
|
|
534
|
+
}
|
|
535
|
+
// ...body only recomputes when username changes!
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Benefit**: Body only evaluates when properties actually accessed in body change. Automatic, compiler-generated tracking.
|
|
541
|
+
|
|
542
|
+
## Performance Impact
|
|
543
|
+
|
|
544
|
+
Real-world measurements:
|
|
545
|
+
|
|
546
|
+
- **80-90% fewer body evaluations** for views reading subset of properties
|
|
547
|
+
- **No Combine overhead**: @Observable uses Swift's observation system, not Combine
|
|
548
|
+
- **Automatic optimization**: No manual effort to minimize updates
|
|
549
|
+
|
|
550
|
+
```swift
|
|
551
|
+
// Performance comparison
|
|
552
|
+
@Observable
|
|
553
|
+
class DataStore {
|
|
554
|
+
var items: [Item] = [] // Changes frequently
|
|
555
|
+
var settings: Settings = .default // Changes rarely
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
struct ItemListView: View {
|
|
559
|
+
@State var store: DataStore
|
|
560
|
+
|
|
561
|
+
var body: some View {
|
|
562
|
+
// With ObservableObject: recomputes on settings change (unnecessary)
|
|
563
|
+
// With @Observable: only recomputes on items change (correct)
|
|
564
|
+
List(store.items) { item in
|
|
565
|
+
ItemRow(item: item)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
## Migration Guidelines
|
|
572
|
+
|
|
573
|
+
**Use @Observable for new code**. It's simpler and faster:
|
|
574
|
+
|
|
575
|
+
```swift
|
|
576
|
+
// Old pattern - remove
|
|
577
|
+
class ViewModel: ObservableObject {
|
|
578
|
+
@Published var name = ""
|
|
579
|
+
@Published var count = 0
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
struct OldView: View {
|
|
583
|
+
@StateObject private var viewModel = ViewModel() // ObservableObject
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// New pattern - use
|
|
587
|
+
@Observable
|
|
588
|
+
class ViewModel {
|
|
589
|
+
var name = ""
|
|
590
|
+
var count = 0
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
struct NewView: View {
|
|
594
|
+
@State private var viewModel = ViewModel() // @Observable
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Key differences**:
|
|
599
|
+
|
|
600
|
+
1. No ObservableObject conformance
|
|
601
|
+
2. No @Published wrapper
|
|
602
|
+
3. Use @State (not @StateObject) for ownership
|
|
603
|
+
4. Use @Bindable for bindings
|
|
604
|
+
|
|
605
|
+
```swift
|
|
606
|
+
@Observable
|
|
607
|
+
class FormData {
|
|
608
|
+
var name = ""
|
|
609
|
+
var email = ""
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
struct FormView: View {
|
|
613
|
+
@State private var formData = FormData()
|
|
614
|
+
|
|
615
|
+
var body: some View {
|
|
616
|
+
Form {
|
|
617
|
+
// Need @Bindable for bindings
|
|
618
|
+
TextField("Name", text: $formData.name)
|
|
619
|
+
TextField("Email", text: $formData.email)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Alternative: @Bindable parameter
|
|
625
|
+
struct FormFields: View {
|
|
626
|
+
@Bindable var formData: FormData
|
|
627
|
+
|
|
628
|
+
var body: some View {
|
|
629
|
+
Form {
|
|
630
|
+
TextField("Name", text: $formData.name)
|
|
631
|
+
TextField("Email", text: $formData.email)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
## Important: @State Behavior Difference
|
|
638
|
+
|
|
639
|
+
Critical difference between @StateObject and @State:
|
|
640
|
+
|
|
641
|
+
```swift
|
|
642
|
+
// ObservableObject with @StateObject
|
|
643
|
+
class OldModel: ObservableObject {
|
|
644
|
+
init() { print("OldModel init") }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
struct OldView: View {
|
|
648
|
+
@StateObject private var model = OldModel()
|
|
649
|
+
// Prints "OldModel init" ONCE - @StateObject preserves across view recreations
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// @Observable with @State
|
|
653
|
+
@Observable
|
|
654
|
+
class NewModel {
|
|
655
|
+
init() { print("NewModel init") }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
struct NewView: View {
|
|
659
|
+
@State private var model = NewModel()
|
|
660
|
+
// Prints "NewModel init" on EVERY view recreation!
|
|
661
|
+
// SwiftUI preserves the instance, but re-runs initializer
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
**Best practice**: Only use @State for @Observable at the view that creates the instance. Pass to child views without @State:
|
|
666
|
+
|
|
667
|
+
```swift
|
|
668
|
+
struct ParentView: View {
|
|
669
|
+
@State private var model = DataModel() // Owner uses @State
|
|
670
|
+
|
|
671
|
+
var body: some View {
|
|
672
|
+
ChildView(model: model) // Child receives plain reference
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
struct ChildView: View {
|
|
677
|
+
let model: DataModel // NOT @State
|
|
678
|
+
|
|
679
|
+
var body: some View {
|
|
680
|
+
Text(model.name) // Still reactive - automatic observation
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
```
|
|
684
|
+
</observable_performance>
|
|
685
|
+
|
|
686
|
+
<images>
|
|
687
|
+
Image loading and rendering are common performance bottlenecks. SwiftUI provides AsyncImage for remote images, but requires careful optimization.
|
|
688
|
+
|
|
689
|
+
## AsyncImage Basics
|
|
690
|
+
|
|
691
|
+
```swift
|
|
692
|
+
// Basic AsyncImage
|
|
693
|
+
AsyncImage(url: URL(string: "https://example.com/image.jpg")) { image in
|
|
694
|
+
image
|
|
695
|
+
.resizable()
|
|
696
|
+
.aspectRatio(contentMode: .fill)
|
|
697
|
+
} placeholder: {
|
|
698
|
+
ProgressView()
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
## Critical Issue: AsyncImage Does Not Cache
|
|
703
|
+
|
|
704
|
+
**Important**: AsyncImage does NOT cache images between screen loads. Scrolling an image off-screen and back may trigger a new network request.
|
|
705
|
+
|
|
706
|
+
```swift
|
|
707
|
+
// Problem: re-downloads on scroll
|
|
708
|
+
ScrollView {
|
|
709
|
+
LazyVStack {
|
|
710
|
+
ForEach(items) { item in
|
|
711
|
+
AsyncImage(url: item.imageURL) { image in
|
|
712
|
+
image.resizable()
|
|
713
|
+
} placeholder: {
|
|
714
|
+
ProgressView()
|
|
715
|
+
}
|
|
716
|
+
// Scrolls off screen -> image released
|
|
717
|
+
// Scrolls back on screen -> downloads again!
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
## Solution 1: Configure URLCache
|
|
724
|
+
|
|
725
|
+
AsyncImage uses URLSession.shared, which respects URLCache. Configure cache size:
|
|
726
|
+
|
|
727
|
+
```swift
|
|
728
|
+
// In @main App init
|
|
729
|
+
@main
|
|
730
|
+
struct MyApp: App {
|
|
731
|
+
init() {
|
|
732
|
+
// Configure URLCache for AsyncImage
|
|
733
|
+
URLCache.shared.memoryCapacity = 50_000_000 // 50 MB memory
|
|
734
|
+
URLCache.shared.diskCapacity = 1_000_000_000 // 1 GB disk
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
var body: some Scene {
|
|
738
|
+
WindowGroup {
|
|
739
|
+
ContentView()
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
**Limitation**: URLCache respects HTTP cache headers. If server doesn't provide appropriate headers, caching may not work as expected.
|
|
746
|
+
|
|
747
|
+
## Solution 2: Build Custom Cached AsyncImage
|
|
748
|
+
|
|
749
|
+
Use NSCache for in-memory caching with custom control:
|
|
750
|
+
|
|
751
|
+
```swift
|
|
752
|
+
// Image cache manager
|
|
753
|
+
@Observable
|
|
754
|
+
class ImageCache {
|
|
755
|
+
static let shared = ImageCache()
|
|
756
|
+
private var cache = NSCache<NSString, UIImage>()
|
|
757
|
+
|
|
758
|
+
func get(url: String) -> UIImage? {
|
|
759
|
+
cache.object(forKey: url as NSString)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
func set(url: String, image: UIImage) {
|
|
763
|
+
cache.setObject(image, forKey: url as NSString)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Cached AsyncImage view
|
|
768
|
+
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
|
769
|
+
let url: URL?
|
|
770
|
+
let content: (Image) -> Content
|
|
771
|
+
let placeholder: () -> Placeholder
|
|
772
|
+
|
|
773
|
+
@State private var image: UIImage?
|
|
774
|
+
|
|
775
|
+
var body: some View {
|
|
776
|
+
Group {
|
|
777
|
+
if let image {
|
|
778
|
+
content(Image(uiImage: image))
|
|
779
|
+
} else {
|
|
780
|
+
placeholder()
|
|
781
|
+
.task {
|
|
782
|
+
await loadImage()
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private func loadImage() async {
|
|
789
|
+
guard let url else { return }
|
|
790
|
+
|
|
791
|
+
// Check cache first
|
|
792
|
+
if let cached = ImageCache.shared.get(url: url.absoluteString) {
|
|
793
|
+
image = cached
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Download if not cached
|
|
798
|
+
do {
|
|
799
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
800
|
+
if let downloaded = UIImage(data: data) {
|
|
801
|
+
ImageCache.shared.set(url: url.absoluteString, image: downloaded)
|
|
802
|
+
image = downloaded
|
|
803
|
+
}
|
|
804
|
+
} catch {
|
|
805
|
+
print("Failed to load image: \(error)")
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
## Solution 3: Use Third-Party Libraries
|
|
812
|
+
|
|
813
|
+
For production apps, consider mature image loading libraries:
|
|
814
|
+
|
|
815
|
+
- **Nuke**: High-performance image loading with aggressive caching
|
|
816
|
+
- **Kingfisher**: Feature-rich with SwiftUI support
|
|
817
|
+
- **SDWebImage**: Battle-tested, widely used
|
|
818
|
+
|
|
819
|
+
```swift
|
|
820
|
+
// Example with third-party library
|
|
821
|
+
import Nuke
|
|
822
|
+
import NukeUI
|
|
823
|
+
|
|
824
|
+
LazyImage(url: item.imageURL) { state in
|
|
825
|
+
if let image = state.image {
|
|
826
|
+
image.resizable().aspectRatio(contentMode: .fill)
|
|
827
|
+
} else {
|
|
828
|
+
ProgressView()
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
## Image Sizing Best Practices
|
|
834
|
+
|
|
835
|
+
**Always specify image dimensions** to prevent SwiftUI from using full resolution:
|
|
836
|
+
|
|
837
|
+
```swift
|
|
838
|
+
// Bad - loads full resolution image
|
|
839
|
+
AsyncImage(url: imageURL) { image in
|
|
840
|
+
image.resizable() // Loads 4K image for 100x100 display
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Good - constrains size
|
|
844
|
+
AsyncImage(url: imageURL) { image in
|
|
845
|
+
image
|
|
846
|
+
.resizable()
|
|
847
|
+
.aspectRatio(contentMode: .fill)
|
|
848
|
+
.frame(width: 100, height: 100) // Limits memory usage
|
|
849
|
+
.clipped()
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Better - serve appropriately sized images
|
|
853
|
+
// Use CDN or server-side resizing to deliver thumbnails, not full resolution
|
|
854
|
+
AsyncImage(url: item.thumbnailURL) { image in // 200x200 version
|
|
855
|
+
image
|
|
856
|
+
.resizable()
|
|
857
|
+
.aspectRatio(contentMode: .fill)
|
|
858
|
+
.frame(width: 100, height: 100)
|
|
859
|
+
}
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
## Prefetching for Scrolling
|
|
863
|
+
|
|
864
|
+
Prefetch images just before they become visible:
|
|
865
|
+
|
|
866
|
+
```swift
|
|
867
|
+
struct OptimizedImageList: View {
|
|
868
|
+
let items: [Item]
|
|
869
|
+
|
|
870
|
+
var body: some View {
|
|
871
|
+
ScrollView {
|
|
872
|
+
LazyVStack {
|
|
873
|
+
ForEach(items) { item in
|
|
874
|
+
CachedAsyncImage(url: item.imageURL) { image in
|
|
875
|
+
image.resizable()
|
|
876
|
+
} placeholder: {
|
|
877
|
+
Color.gray
|
|
878
|
+
}
|
|
879
|
+
.onAppear {
|
|
880
|
+
// Prefetch next items
|
|
881
|
+
prefetchNextImages(after: item)
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private func prefetchNextImages(after item: Item) {
|
|
889
|
+
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
|
|
890
|
+
let nextItems = items.dropFirst(index + 1).prefix(3)
|
|
891
|
+
|
|
892
|
+
Task {
|
|
893
|
+
for nextItem in nextItems {
|
|
894
|
+
// Start download without displaying
|
|
895
|
+
_ = try? await URLSession.shared.data(from: nextItem.imageURL)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
```
|
|
901
|
+
</images>
|
|
902
|
+
|
|
903
|
+
<instruments>
|
|
904
|
+
Xcode's Instruments app provides powerful profiling for SwiftUI performance analysis.
|
|
905
|
+
|
|
906
|
+
## Starting a Profile Session
|
|
907
|
+
|
|
908
|
+
1. Build in Release mode: Product > Profile (Cmd+I)
|
|
909
|
+
2. Select **SwiftUI** template (Xcode 16+) or **Time Profiler** (earlier versions)
|
|
910
|
+
3. **Always profile on real device**, never simulator
|
|
911
|
+
|
|
912
|
+
```bash
|
|
913
|
+
# Release mode optimizations match production
|
|
914
|
+
# Simulator performance doesn't reflect real device
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
## SwiftUI Instruments Template (Xcode 16+)
|
|
918
|
+
|
|
919
|
+
The SwiftUI template includes specialized tracks:
|
|
920
|
+
|
|
921
|
+
### 1. Update Groups Lane
|
|
922
|
+
|
|
923
|
+
Shows when SwiftUI is performing update work. If CPU spikes when this lane is empty, the bottleneck is outside SwiftUI (networking, data processing, etc.).
|
|
924
|
+
|
|
925
|
+
### 2. View Body Lane
|
|
926
|
+
|
|
927
|
+
Tracks how often view body properties are evaluated.
|
|
928
|
+
|
|
929
|
+
**Key metrics**:
|
|
930
|
+
- **Count**: Number of times body evaluated
|
|
931
|
+
- **Avg Duration**: Average time per evaluation
|
|
932
|
+
- **Total Duration**: Cumulative time
|
|
933
|
+
|
|
934
|
+
**What to look for**:
|
|
935
|
+
- Views with high count but low duration: Unnecessary evaluations (fix with Equatable, extract subviews)
|
|
936
|
+
- Views with high duration: Expensive computations in body (move outside body)
|
|
937
|
+
|
|
938
|
+
### 3. View Properties Lane
|
|
939
|
+
|
|
940
|
+
Shows every view property change. Property updates are more frequent than body updates (SwiftUI batches multiple property changes into single body update).
|
|
941
|
+
|
|
942
|
+
**Use to identify**:
|
|
943
|
+
- Properties updating more frequently than expected
|
|
944
|
+
- Cascading updates from parent to children
|
|
945
|
+
|
|
946
|
+
### 4. Core Animation Commits Lane
|
|
947
|
+
|
|
948
|
+
Shows when SwiftUI commits changes to Core Animation for rendering. Expensive commits indicate actual pixel changes on screen.
|
|
949
|
+
|
|
950
|
+
**Correlation**:
|
|
951
|
+
- Many body evaluations + few commits = good (SwiftUI diffing working)
|
|
952
|
+
- Many commits = actual rendering work (investigate why so many pixel changes)
|
|
953
|
+
|
|
954
|
+
### 5. Time Profiler Lane
|
|
955
|
+
|
|
956
|
+
Shows CPU usage by function. Reveals which code is running and how long.
|
|
957
|
+
|
|
958
|
+
**How to use**:
|
|
959
|
+
1. Record profile session
|
|
960
|
+
2. Stop after representative user interaction
|
|
961
|
+
3. Look for heavy call stacks
|
|
962
|
+
4. Drill into SwiftUI view types to find bottlenecks
|
|
963
|
+
|
|
964
|
+
## Analyzing Body Evaluations
|
|
965
|
+
|
|
966
|
+
After profiling, Instruments shows "All Updates Summary":
|
|
967
|
+
|
|
968
|
+
```swift
|
|
969
|
+
// Example summary
|
|
970
|
+
ViewType Count Avg Duration Total Duration
|
|
971
|
+
------------------------------------------------------------------
|
|
972
|
+
ProductListView 456 2.3ms 1,048ms
|
|
973
|
+
ProductCard 2,340 0.8ms 1,872ms
|
|
974
|
+
HeaderView 1 0.2ms 0.2ms
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
**Interpretation**:
|
|
978
|
+
- ProductListView: 456 evaluations in one session is suspicious - should be much fewer
|
|
979
|
+
- ProductCard: High count expected (many instances), but 0.8ms average is acceptable
|
|
980
|
+
- HeaderView: 1 evaluation is ideal for static content
|
|
981
|
+
|
|
982
|
+
## Finding Excessive Updates
|
|
983
|
+
|
|
984
|
+
Use Cmd+1 or select "Summary: All Updates" from jump bar:
|
|
985
|
+
|
|
986
|
+
```swift
|
|
987
|
+
// Views updating the most appear at top
|
|
988
|
+
// Click view name -> see what triggered updates
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Look for:
|
|
992
|
+
- Static views updating repeatedly (should be 1-2 times)
|
|
993
|
+
- Views updating when dependencies haven't changed
|
|
994
|
+
- Cascading updates (parent change triggers all children)
|
|
995
|
+
|
|
996
|
+
## Debugging with _printChanges()
|
|
997
|
+
|
|
998
|
+
Combine Instruments with runtime debugging:
|
|
999
|
+
|
|
1000
|
+
```swift
|
|
1001
|
+
struct ProblematicView: View {
|
|
1002
|
+
@State private var count = 0
|
|
1003
|
+
@State private var name = "Test"
|
|
1004
|
+
|
|
1005
|
+
var body: some View {
|
|
1006
|
+
let _ = Self._printChanges() // Prints to Xcode console
|
|
1007
|
+
|
|
1008
|
+
VStack {
|
|
1009
|
+
Text("Count: \(count)")
|
|
1010
|
+
Text("Name: \(name)")
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Console output when count changes:
|
|
1016
|
+
// ProblematicView: @self, @identity, _count changed.
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
## Common Findings and Solutions
|
|
1020
|
+
|
|
1021
|
+
| Finding | Cause | Solution |
|
|
1022
|
+
|---------|-------|----------|
|
|
1023
|
+
| Header view updates 100+ times | Parent state change | Extract to separate view |
|
|
1024
|
+
| Image view high duration | Full resolution loading | Constrain frame size |
|
|
1025
|
+
| List scrolling causes body storm | Expensive row computations | Move computation outside body |
|
|
1026
|
+
| State changes cause app-wide updates | Top-level state | Move state closer to usage |
|
|
1027
|
+
|
|
1028
|
+
## Weekly Profiling Practice
|
|
1029
|
+
|
|
1030
|
+
Profile incrementally to catch performance regressions early:
|
|
1031
|
+
|
|
1032
|
+
```swift
|
|
1033
|
+
// Profiling routine
|
|
1034
|
+
1. Profile baseline before changes
|
|
1035
|
+
2. Implement feature
|
|
1036
|
+
3. Profile again
|
|
1037
|
+
4. Compare metrics
|
|
1038
|
+
5. Fix regressions before merging
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
Small, consistent profiling catches issues when they're easy to fix, rather than debugging performance problems across large changesets.
|
|
1042
|
+
</instruments>
|
|
1043
|
+
|
|
1044
|
+
<optimization_strategies>
|
|
1045
|
+
Specific techniques for optimizing SwiftUI performance.
|
|
1046
|
+
|
|
1047
|
+
## 1. Task Prioritization with Priority
|
|
1048
|
+
|
|
1049
|
+
Control async task priority to keep UI responsive:
|
|
1050
|
+
|
|
1051
|
+
```swift
|
|
1052
|
+
struct DataLoadingView: View {
|
|
1053
|
+
@State private var essentialData: [Item] = []
|
|
1054
|
+
@State private var optionalData: [Detail] = []
|
|
1055
|
+
|
|
1056
|
+
var body: some View {
|
|
1057
|
+
VStack {
|
|
1058
|
+
List(essentialData) { item in
|
|
1059
|
+
ItemRow(item: item)
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
.task(priority: .high) {
|
|
1063
|
+
// Load critical data first
|
|
1064
|
+
essentialData = await dataStore.loadEssentialData()
|
|
1065
|
+
}
|
|
1066
|
+
.task(priority: .low) {
|
|
1067
|
+
// Load nice-to-have data later
|
|
1068
|
+
optionalData = await dataStore.loadOptionalData()
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
## 2. Pagination for Large Datasets
|
|
1075
|
+
|
|
1076
|
+
Load data in chunks as user scrolls:
|
|
1077
|
+
|
|
1078
|
+
```swift
|
|
1079
|
+
struct PaginatedListView: View {
|
|
1080
|
+
@State private var items: [Item] = []
|
|
1081
|
+
@State private var page = 1
|
|
1082
|
+
@State private var isLoading = false
|
|
1083
|
+
|
|
1084
|
+
var body: some View {
|
|
1085
|
+
ScrollView {
|
|
1086
|
+
LazyVStack {
|
|
1087
|
+
ForEach(items) { item in
|
|
1088
|
+
ItemRow(item: item)
|
|
1089
|
+
.onAppear {
|
|
1090
|
+
loadMoreIfNeeded(currentItem: item)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if isLoading {
|
|
1095
|
+
ProgressView()
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
.task {
|
|
1100
|
+
await loadPage()
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
private func loadMoreIfNeeded(currentItem: Item) {
|
|
1105
|
+
guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else { return }
|
|
1106
|
+
|
|
1107
|
+
// Load next page when reaching last 5 items
|
|
1108
|
+
if index >= items.count - 5 && !isLoading {
|
|
1109
|
+
Task {
|
|
1110
|
+
await loadPage()
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private func loadPage() async {
|
|
1116
|
+
guard !isLoading else { return }
|
|
1117
|
+
isLoading = true
|
|
1118
|
+
|
|
1119
|
+
let newItems = await dataStore.loadItems(page: page)
|
|
1120
|
+
items.append(contentsOf: newItems)
|
|
1121
|
+
page += 1
|
|
1122
|
+
|
|
1123
|
+
isLoading = false
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
## 3. Debouncing Expensive Updates
|
|
1129
|
+
|
|
1130
|
+
Delay expensive operations while user is typing:
|
|
1131
|
+
|
|
1132
|
+
```swift
|
|
1133
|
+
struct SearchView: View {
|
|
1134
|
+
@State private var searchText = ""
|
|
1135
|
+
@State private var searchResults: [Item] = []
|
|
1136
|
+
@State private var searchTask: Task<Void, Never>?
|
|
1137
|
+
|
|
1138
|
+
var body: some View {
|
|
1139
|
+
VStack {
|
|
1140
|
+
TextField("Search", text: $searchText)
|
|
1141
|
+
.onChange(of: searchText) { oldValue, newValue in
|
|
1142
|
+
// Cancel previous search
|
|
1143
|
+
searchTask?.cancel()
|
|
1144
|
+
|
|
1145
|
+
// Debounce: wait 300ms before searching
|
|
1146
|
+
searchTask = Task {
|
|
1147
|
+
try? await Task.sleep(for: .milliseconds(300))
|
|
1148
|
+
guard !Task.isCancelled else { return }
|
|
1149
|
+
await performSearch(query: newValue)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
List(searchResults) { result in
|
|
1154
|
+
Text(result.name)
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private func performSearch(query: String) async {
|
|
1160
|
+
searchResults = await searchService.search(query)
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
## 4. Drawing Performance with Canvas
|
|
1166
|
+
|
|
1167
|
+
For complex custom drawing, use Canvas instead of GeometryReader and Path:
|
|
1168
|
+
|
|
1169
|
+
```swift
|
|
1170
|
+
// Slow - triggers relayout frequently
|
|
1171
|
+
struct SlowGraph: View {
|
|
1172
|
+
let data: [Double]
|
|
1173
|
+
|
|
1174
|
+
var body: some View {
|
|
1175
|
+
GeometryReader { geometry in
|
|
1176
|
+
Path { path in
|
|
1177
|
+
// Complex path drawing
|
|
1178
|
+
for (index, value) in data.enumerated() {
|
|
1179
|
+
let x = CGFloat(index) * geometry.size.width / CGFloat(data.count)
|
|
1180
|
+
let y = geometry.size.height * (1 - CGFloat(value))
|
|
1181
|
+
if index == 0 {
|
|
1182
|
+
path.move(to: CGPoint(x: x, y: y))
|
|
1183
|
+
} else {
|
|
1184
|
+
path.addLine(to: CGPoint(x: x, y: y))
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
.stroke(Color.blue, lineWidth: 2)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Fast - optimized drawing
|
|
1194
|
+
struct FastGraph: View {
|
|
1195
|
+
let data: [Double]
|
|
1196
|
+
|
|
1197
|
+
var body: some View {
|
|
1198
|
+
Canvas { context, size in
|
|
1199
|
+
var path = Path()
|
|
1200
|
+
for (index, value) in data.enumerated() {
|
|
1201
|
+
let x = CGFloat(index) * size.width / CGFloat(data.count)
|
|
1202
|
+
let y = size.height * (1 - CGFloat(value))
|
|
1203
|
+
if index == 0 {
|
|
1204
|
+
path.move(to: CGPoint(x: x, y: y))
|
|
1205
|
+
} else {
|
|
1206
|
+
path.addLine(to: CGPoint(x: x, y: y))
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
context.stroke(path, with: .color(.blue), lineWidth: 2)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
## 5. Reduce Modifier Overhead
|
|
1216
|
+
|
|
1217
|
+
Combine modifiers when possible:
|
|
1218
|
+
|
|
1219
|
+
```swift
|
|
1220
|
+
// Multiple modifier evaluations
|
|
1221
|
+
Text("Hello")
|
|
1222
|
+
.foregroundStyle(.blue)
|
|
1223
|
+
.font(.headline)
|
|
1224
|
+
.padding()
|
|
1225
|
+
.background(.gray)
|
|
1226
|
+
.cornerRadius(8)
|
|
1227
|
+
|
|
1228
|
+
// Combined where possible - no performance gain in most cases,
|
|
1229
|
+
// but clearer code. SwiftUI optimizes modifier chains automatically.
|
|
1230
|
+
// Real optimization: avoid conditional modifiers if value doesn't change.
|
|
1231
|
+
|
|
1232
|
+
// Inefficient - creates new modifier on every body evaluation
|
|
1233
|
+
.opacity(isVisible ? 1.0 : 1.0) // Condition always results in same value
|
|
1234
|
+
|
|
1235
|
+
// Efficient - only apply when needed
|
|
1236
|
+
.opacity(isVisible ? 1.0 : 0.0)
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
## 6. PreferenceKey for Bottom-Up Communication
|
|
1240
|
+
|
|
1241
|
+
Use PreferenceKey instead of @Binding for child-to-parent data flow when performance matters:
|
|
1242
|
+
|
|
1243
|
+
```swift
|
|
1244
|
+
struct SizePreferenceKey: PreferenceKey {
|
|
1245
|
+
static var defaultValue: CGSize = .zero
|
|
1246
|
+
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
|
1247
|
+
value = nextValue()
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
struct ParentView: View {
|
|
1252
|
+
@State private var childSize: CGSize = .zero
|
|
1253
|
+
|
|
1254
|
+
var body: some View {
|
|
1255
|
+
VStack {
|
|
1256
|
+
Text("Child size: \(childSize.width) x \(childSize.height)")
|
|
1257
|
+
|
|
1258
|
+
ChildView()
|
|
1259
|
+
.onPreferenceChange(SizePreferenceKey.self) { size in
|
|
1260
|
+
childSize = size
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
struct ChildView: View {
|
|
1267
|
+
var body: some View {
|
|
1268
|
+
Text("Hello")
|
|
1269
|
+
.background(
|
|
1270
|
+
GeometryReader { geometry in
|
|
1271
|
+
Color.clear.preference(
|
|
1272
|
+
key: SizePreferenceKey.self,
|
|
1273
|
+
value: geometry.size
|
|
1274
|
+
)
|
|
1275
|
+
}
|
|
1276
|
+
)
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
```
|
|
1280
|
+
</optimization_strategies>
|
|
1281
|
+
|
|
1282
|
+
<decision_tree>
|
|
1283
|
+
When to investigate performance and what to optimize.
|
|
1284
|
+
|
|
1285
|
+
## Should You Optimize?
|
|
1286
|
+
|
|
1287
|
+
```
|
|
1288
|
+
Is there a user-facing performance issue?
|
|
1289
|
+
├─ No → Don't optimize
|
|
1290
|
+
└─ Yes → Continue
|
|
1291
|
+
|
|
1292
|
+
Have you profiled with Instruments?
|
|
1293
|
+
├─ No → Profile first (never optimize without data)
|
|
1294
|
+
└─ Yes → Continue
|
|
1295
|
+
|
|
1296
|
+
Did profiling identify a specific bottleneck?
|
|
1297
|
+
├─ No → Issue might not be SwiftUI (check networking, data layer)
|
|
1298
|
+
└─ Yes → Continue
|
|
1299
|
+
|
|
1300
|
+
Is the bottleneck in SwiftUI view updates?
|
|
1301
|
+
├─ No → Optimize data layer, networking, image loading
|
|
1302
|
+
└─ Yes → Continue to optimization strategies
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
## Optimization Priority
|
|
1306
|
+
|
|
1307
|
+
**1. High Impact, Low Effort**:
|
|
1308
|
+
- Switch VStack to LazyVStack for long lists
|
|
1309
|
+
- Configure URLCache for AsyncImage
|
|
1310
|
+
- Extract static subviews from frequently updating views
|
|
1311
|
+
|
|
1312
|
+
**2. High Impact, Medium Effort**:
|
|
1313
|
+
- Replace ObservableObject with @Observable
|
|
1314
|
+
- Implement pagination for large datasets
|
|
1315
|
+
- Add custom Equatable to expensive views
|
|
1316
|
+
|
|
1317
|
+
**3. Medium Impact, Low Effort**:
|
|
1318
|
+
- Debounce text field updates
|
|
1319
|
+
- Use task priority for non-critical work
|
|
1320
|
+
- Constrain image sizes with frame modifiers
|
|
1321
|
+
|
|
1322
|
+
**4. Medium Impact, High Effort**:
|
|
1323
|
+
- Build custom cached image loading
|
|
1324
|
+
- Rewrite complex views to reduce body complexity
|
|
1325
|
+
- Implement view recycling for very large datasets
|
|
1326
|
+
|
|
1327
|
+
**5. Low Priority** (only if profiling shows specific issue):
|
|
1328
|
+
- Optimize modifier ordering
|
|
1329
|
+
- Use Canvas for complex drawing
|
|
1330
|
+
- PreferenceKey instead of Binding
|
|
1331
|
+
|
|
1332
|
+
## Red Flags Requiring Optimization
|
|
1333
|
+
|
|
1334
|
+
| Symptom | Likely Cause | Action |
|
|
1335
|
+
|---------|--------------|--------|
|
|
1336
|
+
| Scrolling stutters | Heavy row views | Profile, use lazy loading, simplify rows |
|
|
1337
|
+
| Typing lags | Expensive search on every keystroke | Debounce, move work off main thread |
|
|
1338
|
+
| Navigation slow | Loading all data upfront | Implement pagination, async loading |
|
|
1339
|
+
| App hangs on launch | Too much work in view init | Move to task, use loading states |
|
|
1340
|
+
| Memory growing unbounded | Images not releasing | Implement image cache with limits |
|
|
1341
|
+
|
|
1342
|
+
## Performance Targets
|
|
1343
|
+
|
|
1344
|
+
**Scrolling**: Maintain 60fps (16.67ms per frame)
|
|
1345
|
+
- Budget ~10ms for SwiftUI updates
|
|
1346
|
+
- Budget ~6ms for rendering
|
|
1347
|
+
|
|
1348
|
+
**Interactions**: Respond within 100ms
|
|
1349
|
+
- User perceives instant response < 100ms
|
|
1350
|
+
- 100-300ms feels sluggish
|
|
1351
|
+
- \> 300ms feels broken
|
|
1352
|
+
|
|
1353
|
+
**Launch**: Show content within 1 second
|
|
1354
|
+
- Use skeleton screens / placeholders
|
|
1355
|
+
- Load critical content first, optional content later
|
|
1356
|
+
</decision_tree>
|
|
1357
|
+
|
|
1358
|
+
<anti_patterns>
|
|
1359
|
+
Common performance mistakes and how to avoid them.
|
|
1360
|
+
|
|
1361
|
+
## 1. Using @State with Reference Types
|
|
1362
|
+
|
|
1363
|
+
**Problem**: @State creates new instance on every view recreation when used with classes.
|
|
1364
|
+
|
|
1365
|
+
```swift
|
|
1366
|
+
// Wrong - creates new instance repeatedly
|
|
1367
|
+
struct BadView: View {
|
|
1368
|
+
@State private var viewModel = ViewModel() // ViewModel is a class
|
|
1369
|
+
|
|
1370
|
+
var body: some View {
|
|
1371
|
+
Text(viewModel.text)
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Correct - use @Observable and @State for iOS 17+
|
|
1376
|
+
@Observable
|
|
1377
|
+
class ViewModel {
|
|
1378
|
+
var text = "Hello"
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
struct GoodView: View {
|
|
1382
|
+
@State private var viewModel = ViewModel()
|
|
1383
|
+
|
|
1384
|
+
var body: some View {
|
|
1385
|
+
Text(viewModel.text)
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Alternative for iOS 16- - use @StateObject with ObservableObject
|
|
1390
|
+
class LegacyViewModel: ObservableObject {
|
|
1391
|
+
@Published var text = "Hello"
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
struct LegacyView: View {
|
|
1395
|
+
@StateObject private var viewModel = LegacyViewModel()
|
|
1396
|
+
|
|
1397
|
+
var body: some View {
|
|
1398
|
+
Text(viewModel.text)
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
## 2. Overusing AnyView
|
|
1404
|
+
|
|
1405
|
+
**Problem**: Type erasure prevents SwiftUI from diffing efficiently, forcing complete view recreation.
|
|
1406
|
+
|
|
1407
|
+
```swift
|
|
1408
|
+
// Wrong - loses type information
|
|
1409
|
+
func makeView(type: ViewType) -> some View {
|
|
1410
|
+
switch type {
|
|
1411
|
+
case .text:
|
|
1412
|
+
return AnyView(Text("Hello"))
|
|
1413
|
+
case .image:
|
|
1414
|
+
return AnyView(Image(systemName: "star"))
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Correct - preserve types with @ViewBuilder
|
|
1419
|
+
@ViewBuilder
|
|
1420
|
+
func makeView(type: ViewType) -> some View {
|
|
1421
|
+
switch type {
|
|
1422
|
+
case .text:
|
|
1423
|
+
Text("Hello")
|
|
1424
|
+
case .image:
|
|
1425
|
+
Image(systemName: "star")
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Alternative - use Group for conditional views
|
|
1430
|
+
var body: some View {
|
|
1431
|
+
Group {
|
|
1432
|
+
if showText {
|
|
1433
|
+
Text("Hello")
|
|
1434
|
+
} else {
|
|
1435
|
+
Image(systemName: "star")
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
## 3. Creating New Objects in Body
|
|
1442
|
+
|
|
1443
|
+
**Problem**: Every body evaluation creates new instances, preventing SwiftUI from recognizing stable values.
|
|
1444
|
+
|
|
1445
|
+
```swift
|
|
1446
|
+
// Wrong - creates new DateFormatter on every body evaluation
|
|
1447
|
+
struct BadDateView: View {
|
|
1448
|
+
let date: Date
|
|
1449
|
+
|
|
1450
|
+
var body: some View {
|
|
1451
|
+
let formatter = DateFormatter() // New instance every time!
|
|
1452
|
+
formatter.dateStyle = .medium
|
|
1453
|
+
return Text(formatter.string(from: date))
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Correct - create once, reuse
|
|
1458
|
+
struct GoodDateView: View {
|
|
1459
|
+
let date: Date
|
|
1460
|
+
|
|
1461
|
+
private static let formatter: DateFormatter = {
|
|
1462
|
+
let f = DateFormatter()
|
|
1463
|
+
f.dateStyle = .medium
|
|
1464
|
+
return f
|
|
1465
|
+
}()
|
|
1466
|
+
|
|
1467
|
+
var body: some View {
|
|
1468
|
+
Text(Self.formatter.string(from: date))
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Alternative - use built-in formatters
|
|
1473
|
+
struct BetterDateView: View {
|
|
1474
|
+
let date: Date
|
|
1475
|
+
|
|
1476
|
+
var body: some View {
|
|
1477
|
+
Text(date, style: .date) // SwiftUI handles formatting
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
## 4. Branching on View State Instead of Modifiers
|
|
1483
|
+
|
|
1484
|
+
**Problem**: Branches change structural identity, losing state and triggering transitions.
|
|
1485
|
+
|
|
1486
|
+
```swift
|
|
1487
|
+
// Wrong - structural identity changes on toggle
|
|
1488
|
+
struct BadToggleView: View {
|
|
1489
|
+
@State private var isExpanded = false
|
|
1490
|
+
|
|
1491
|
+
var body: some View {
|
|
1492
|
+
if isExpanded {
|
|
1493
|
+
ExpandedContentView() // Destroyed on collapse
|
|
1494
|
+
} else {
|
|
1495
|
+
CollapsedContentView() // Destroyed on expand
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Correct - preserve identity with conditional modifiers
|
|
1501
|
+
struct GoodToggleView: View {
|
|
1502
|
+
@State private var isExpanded = false
|
|
1503
|
+
|
|
1504
|
+
var body: some View {
|
|
1505
|
+
ContentView(isExpanded: isExpanded) // Same view, different state
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Alternative - use opacity/frame to hide
|
|
1510
|
+
struct AlternativeToggleView: View {
|
|
1511
|
+
@State private var isExpanded = false
|
|
1512
|
+
|
|
1513
|
+
var body: some View {
|
|
1514
|
+
VStack {
|
|
1515
|
+
HeaderView()
|
|
1516
|
+
|
|
1517
|
+
DetailView()
|
|
1518
|
+
.frame(height: isExpanded ? nil : 0) // Collapse without destroying
|
|
1519
|
+
.opacity(isExpanded ? 1 : 0)
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
## 5. Excessive GeometryReader Usage
|
|
1526
|
+
|
|
1527
|
+
**Problem**: GeometryReader recalculates on every layout change, triggering cascade of updates.
|
|
1528
|
+
|
|
1529
|
+
```swift
|
|
1530
|
+
// Wrong - unnecessary GeometryReader
|
|
1531
|
+
struct BadLayout: View {
|
|
1532
|
+
var body: some View {
|
|
1533
|
+
GeometryReader { geometry in
|
|
1534
|
+
VStack {
|
|
1535
|
+
Text("Width: \(geometry.size.width)")
|
|
1536
|
+
.frame(width: geometry.size.width * 0.8) // Could use .frame(maxWidth:)
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Correct - use frame modifiers
|
|
1543
|
+
struct GoodLayout: View {
|
|
1544
|
+
var body: some View {
|
|
1545
|
+
VStack {
|
|
1546
|
+
Text("Responsive width")
|
|
1547
|
+
.frame(maxWidth: .infinity) // Fills available space
|
|
1548
|
+
.padding(.horizontal) // 80% width effect
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Use GeometryReader only when truly needed
|
|
1554
|
+
struct ValidGeometryUse: View {
|
|
1555
|
+
var body: some View {
|
|
1556
|
+
GeometryReader { geometry in
|
|
1557
|
+
// Valid: need actual size for custom drawing
|
|
1558
|
+
CustomShape(size: geometry.size)
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
## 6. Not Using Lazy Containers for Long Lists
|
|
1565
|
+
|
|
1566
|
+
**Problem**: Non-lazy stacks create all views immediately, consuming excessive memory.
|
|
1567
|
+
|
|
1568
|
+
```swift
|
|
1569
|
+
// Wrong - loads all 1000 items immediately
|
|
1570
|
+
ScrollView {
|
|
1571
|
+
VStack {
|
|
1572
|
+
ForEach(0..<1000) { index in
|
|
1573
|
+
HeavyItemView(index: index) // All 1000 created at once
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Correct - lazy loading
|
|
1579
|
+
ScrollView {
|
|
1580
|
+
LazyVStack {
|
|
1581
|
+
ForEach(0..<1000) { index in
|
|
1582
|
+
HeavyItemView(index: index) // Created as scrolled into view
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
## 7. Performing Expensive Work on Main Thread
|
|
1589
|
+
|
|
1590
|
+
**Problem**: Blocking main thread makes UI unresponsive.
|
|
1591
|
+
|
|
1592
|
+
```swift
|
|
1593
|
+
// Wrong - expensive work blocks UI
|
|
1594
|
+
struct BadDataView: View {
|
|
1595
|
+
@State private var data: [Item] = []
|
|
1596
|
+
|
|
1597
|
+
var body: some View {
|
|
1598
|
+
List(data) { item in
|
|
1599
|
+
Text(item.name)
|
|
1600
|
+
}
|
|
1601
|
+
.onAppear {
|
|
1602
|
+
// Blocks UI while loading
|
|
1603
|
+
data = loadDataFromDisk() // Expensive!
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Correct - async work off main thread
|
|
1609
|
+
struct GoodDataView: View {
|
|
1610
|
+
@State private var data: [Item] = []
|
|
1611
|
+
@State private var isLoading = true
|
|
1612
|
+
|
|
1613
|
+
var body: some View {
|
|
1614
|
+
Group {
|
|
1615
|
+
if isLoading {
|
|
1616
|
+
ProgressView()
|
|
1617
|
+
} else {
|
|
1618
|
+
List(data) { item in
|
|
1619
|
+
Text(item.name)
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
.task {
|
|
1624
|
+
// Runs on background thread
|
|
1625
|
+
data = await loadDataAsync()
|
|
1626
|
+
isLoading = false
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
## 8. Using ObservableObject Without Scoping Published Properties
|
|
1633
|
+
|
|
1634
|
+
**Problem**: Views recompute when any @Published property changes, even ones they don't use.
|
|
1635
|
+
|
|
1636
|
+
```swift
|
|
1637
|
+
// Problematic - view recomputes on all changes
|
|
1638
|
+
class AppState: ObservableObject {
|
|
1639
|
+
@Published var userProfile: User? // Changes rarely
|
|
1640
|
+
@Published var unreadCount: Int = 0 // Changes frequently
|
|
1641
|
+
@Published var networkStatus: Status = .online // Changes frequently
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
struct ProfileView: View {
|
|
1645
|
+
@ObservedObject var appState: AppState
|
|
1646
|
+
|
|
1647
|
+
var body: some View {
|
|
1648
|
+
// Only uses userProfile, but recomputes on unreadCount changes!
|
|
1649
|
+
Text(appState.userProfile?.name ?? "")
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Solution 1: Use @Observable (iOS 17+) for fine-grained observation
|
|
1654
|
+
@Observable
|
|
1655
|
+
class AppState {
|
|
1656
|
+
var userProfile: User? // ProfileView only observes this
|
|
1657
|
+
var unreadCount: Int = 0
|
|
1658
|
+
var networkStatus: Status = .online
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Solution 2: Split into focused ObservableObjects
|
|
1662
|
+
class UserState: ObservableObject {
|
|
1663
|
+
@Published var profile: User?
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
class NotificationState: ObservableObject {
|
|
1667
|
+
@Published var unreadCount: Int = 0
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
struct ProfileView: View {
|
|
1671
|
+
@ObservedObject var userState: UserState // Only observes relevant state
|
|
1672
|
+
|
|
1673
|
+
var body: some View {
|
|
1674
|
+
Text(userState.profile?.name ?? "")
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
```
|
|
1678
|
+
|
|
1679
|
+
These anti-patterns account for the majority of SwiftUI performance issues. Profiling with Instruments reveals which patterns affect your specific app.
|
|
1680
|
+
</anti_patterns>
|
|
1681
|
+
|
|
1682
|
+
---
|
|
1683
|
+
|
|
1684
|
+
## Sources
|
|
1685
|
+
|
|
1686
|
+
- [Optimizing SwiftUI Performance: Best Practices](https://medium.com/@garejakirit/optimizing-swiftui-performance-best-practices-93b9cc91c623)
|
|
1687
|
+
- [Demystify SwiftUI performance - WWDC23](https://developer.apple.com/videos/play/wwdc2023/10160/)
|
|
1688
|
+
- [Making our production SwiftUI app 100x faster — Clay](https://clay.earth/stories/production-swiftui-performance-increase)
|
|
1689
|
+
- [How the SwiftUI View Lifecycle and Identity work - DoorDash Engineering](https://doordash.engineering/2022/05/31/how-the-swiftui-view-lifecycle-and-identity-work/)
|
|
1690
|
+
- [Identity in SwiftUI - Geek Culture](https://medium.com/geekculture/identity-in-swiftui-6aacf8f587d9)
|
|
1691
|
+
- [Demystify SwiftUI - WWDC21](https://developer.apple.com/videos/play/wwdc2021/10022/)
|
|
1692
|
+
- [id(_): Identifying SwiftUI Views - The SwiftUI Lab](https://swiftui-lab.com/swiftui-id/)
|
|
1693
|
+
- [How to use Instruments to profile your SwiftUI code - Hacking with Swift](https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-instruments-to-profile-your-swiftui-code-and-identify-slow-layouts)
|
|
1694
|
+
- [Profiling SwiftUI app using Instruments - Swift with Majid](https://swiftwithmajid.com/2021/01/20/profiling-swiftui-app-using-instruments/)
|
|
1695
|
+
- [@Observable Macro performance increase over ObservableObject](https://www.avanderlee.com/swiftui/observable-macro-performance-increase-observableobject/)
|
|
1696
|
+
- [@Observable vs ObservableObject in SwiftUI - Malcolm Hall](https://www.malcolmhall.com/2024/04/22/observable-vs-observableobject-in-swiftui/)
|
|
1697
|
+
- [Tuning Lazy Stacks and Grids in SwiftUI: A Performance Guide](https://medium.com/@wesleymatlock/tuning-lazy-stacks-and-grids-in-swiftui-a-performance-guide-2fb10786f76a)
|
|
1698
|
+
- [Tips and Considerations for Using Lazy Containers in SwiftUI](https://fatbobman.com/en/posts/tips-and-considerations-for-using-lazy-containers-in-swiftui/)
|
|
1699
|
+
- [List or LazyVStack - Choosing the Right Lazy Container in SwiftUI](https://fatbobman.com/en/posts/list-or-lazyvstack/)
|
|
1700
|
+
- [Optimizing AsyncImage in SwiftUI: Build a Custom Cached Solution](https://medium.com/@sviatoslav.kliuchev/improve-asyncimage-in-swiftui-5aae28f1a331)
|
|
1701
|
+
- [AsyncImage in SwiftUI: Loading Images from URLs with Caching](https://matteomanferdini.com/swiftui-asyncimage/)
|
|
1702
|
+
- [SwiftUI Performance and Stability: Avoiding the Most Costly Mistakes](https://dev.to/arshtechpro/swiftui-performance-and-stability-avoiding-the-most-costly-mistakes-234c)
|
|
1703
|
+
- [Common SwiftUI Mistakes - Hacking with Swift](https://www.hackingwithswift.com/articles/224/common-swiftui-mistakes-and-how-to-fix-them)
|
|
1704
|
+
- [Avoiding having to recompute values within SwiftUI views - Swift by Sundell](https://www.swiftbysundell.com/articles/avoiding-swiftui-value-recomputation/)
|
|
1705
|
+
- [Optimizing SwiftUI: Reducing Body Recalculation and Minimizing @State Updates](https://medium.com/@wesleymatlock/optimizing-swiftui-reducing-body-recalculation-and-minimizing-state-updates-8f7944253725)
|
|
1706
|
+
- [How to Avoid Repeating SwiftUI View Updates](https://fatbobman.com/en/posts/avoid_repeated_calculations_of_swiftui_views/)
|