brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
swiftui-performance-auditFinds out why a SwiftUI screen is slow and what to change: classifies the symptom as a hang, a hitch or excessive updates, audits the code for the usual causes, reads Instruments evidence (the SwiftUI instrument, Time Profiler, Hangs, Hitches), proposes targeted fixes and checks them with a second measurement. Use when the user says "my list scrolls badly",…
Independent GitHub snapshot · Automated classification · Original documentation governs
SwiftUI is slow in two ways. Either one update takes too long (a body, a layout pass or an image decode that does not fit in a frame), or there are too many updates (views recomputed because something they depend on changed, although nothing they show did). Both end as work on the main thread, and the person sees a frozen control or stuttering motion. An audit names which of the two is happening, on which view, caused by which dependency or call, and proves the fix with the same measurement taken before and after. Guessing from code alone is allowed as a first pass, but it must be labelled as a hypothesis until a trace confirms it.
Ask for, or find in the report: the exact interaction (for example "typing in the search field on Orders"), device model and OS version, build configuration, how much data is on screen, and whether it was always slow or regressed.
| What the person sees | Term | Numbers to hold on to | First tool |
|---|---|---|---|
| A tap, keystroke or screen opening responds late | Hang: the main run loop is busy | Under 100 ms is rarely noticed; Apple's tools start reporting at 250 ms | Hangs instrument with Time Profiler |
| Scrolling or animation stutters | Hitch: a frame arrived late | A frame has 16.7 ms at 60 Hz and 8.3 ms at 120 Hz. Organizer hitch rate: up to 10 ms/s good, up to 25 warning, up to 50 critical | Hitches instrument, SwiftUI instrument |
| CPU busy or battery drain while nothing seems to change | Excess updates | Count of body updates per interaction | SwiftUI instrument, Update Groups lane |
| Memory climbs while scrolling | Usually full-size images | Peak memory before and after | Allocations template |
A hitch caused by the app's main thread missing its deadline is a commit hitch; one caused by a frame that is too expensive to draw (many layers, blurs, shadows, glass) is a render hitch. They have different fixes, so note which one the Hitches instrument reports.
Read the slow view, the views it contains, and the model types it reads. Check each of these and cite the line when you find one.
| Cause | What it looks like in code | Fix |
|---|---|---|
Work inside body | Sorting, filtering, grouping, string formatting, creating a DateFormatter or NumberFormatter, reading files, decoding images in body, in a view init, or in onAppear | Compute once in the model when inputs change; store the result; format with formatted() styles or a shared formatter |
| Dependencies wider than needed | An ObservableObject with many @Published properties observed by many views; a whole model passed to a row that shows two fields; frequently changing values (scroll offset, timers, geometry) placed in the environment | Adopt @Observable (iOS 17+), which only invalidates views whose body read the changed property; pass plain values to leaf views; keep fast-changing values out of the environment |
| Unstable identity | ForEach(items, id: \.self) on values that are not unique or are expensive to hash; ids made from array indices for data that reorders; .id(UUID()); if/else that swaps whole subtrees; AnyView | Identifiable with a stable id from the data; keep one view and vary its modifiers |
| Variable row count per element | An if inside ForEach content that sometimes yields no view | Filter the collection before ForEach, so List can count rows without building them |
| Eager containers | VStack or HStack in a ScrollView with hundreds of children | List, LazyVStack, LazyVGrid, after profiling shows the load cost |
| Layout feedback | GeometryReader around large content; onGeometryChange or scroll callbacks writing state every frame | Update state only when the value crosses a threshold; move the dependent view into a small sibling |
| Stored closures | A view storing an escaping @ViewBuilder closure that captures the parent's state | Call the builder in init and store the resulting view |
| Images | Photos decoded at full size for small cells; decoding on the main thread | byPreparingThumbnail(ofSize:) or preparingThumbnail(of:) off the main actor, sized in pixels |
| Expensive rendering | Blur, shadow or glass on every cell; animating a large container | Apply effects to one parent; animate with animation(_:value:) on the smallest view that changes |
To see why one view's body runs, add let _ = Self._printChanges() as the first line of body in a debug build. It prints which property changed, or that the view value itself did. The underscore means it is a debugging aid that may disappear, and Apple says never to ship a call to it. Remove it before committing.
Code review produces suspects. A trace turns them into findings. Ask the user to record this (the agent cannot drive Instruments):
body that takes the time.If the user cannot record, deliver the code audit with every item marked "unconfirmed" and say which single measurement would confirm it.
Order findings by measured cost (duration × count), not by how easy they are. Make one change, measure again, then decide on the next. Typical moves:
View with its own inputs.body into the model, recomputed when its inputs change.equatable() only when the view's inputs are cheap to compare and the trace shows the parent invalidating it needlessly.Re-record the same interaction on the same device and build configuration and put the numbers side by side. For screens that matter, suggest a UI performance test so the regression is caught next time:
import XCTest
final class OrdersScrollPerformanceTests: XCTestCase {
func testScrollingOrders() {
let app = XCUIApplication()
app.launch()
app.buttons["Orders"].tap()
measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric]) {
app.swipeUp(velocity: .fast)
app.swipeDown(velocity: .fast)
}
}
}
On the 26 releases and later, XCTHitchMetric(application: app) can be added to the metrics array to count hitches directly.
SwiftUI performance audit: Orders search (iPhone 14, iOS 26.1, Release)
Symptom: typing lags, about 0.3 s per character with 4,200 orders. Class: hang.
# Finding Evidence Status
1 Filter and sort of all orders in body OrdersScreen.body 38 ms/update confirmed (trace)
2 Every row observes the whole store 26 row updates per sync tick confirmed (trace)
3 DateFormatter created per row update 3rd heaviest frame in call tree confirmed (trace)
4 id: \.self hashes the full Order code only unconfirmed
Changes made: 1, 2, 3. Not changed: 4 (re-measure first).
body per keystroke long body updates in 10 s row updates per sync tick
Before 38 ms 214 26
After 0.4 ms 3 0
The user reports lag while typing on a screen with 4,200 orders, worse while a background sync is running. Code as found:
final class OrdersStore: ObservableObject {
@Published var orders: [Order] = []
@Published var query = ""
@Published var syncProgress = 0.0 // changes about 10 times a second during sync
}
struct OrdersScreen: View {
@StateObject private var store = OrdersStore()
var body: some View {
List {
ForEach(store.orders
.filter { store.query.isEmpty || $0.customer.localizedCaseInsensitiveContains(store.query) }
.sorted { $0.placedAt > $1.placedAt }, id: \.self) { order in
OrderRow(order: order, store: store)
}
}
.searchable(text: $store.query)
}
}
struct OrderRow: View {
let order: Order
@ObservedObject var store: OrdersStore
var body: some View {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return HStack { Text(order.customer); Spacer(); Text(formatter.string(from: order.placedAt)) }
}
}
Audit: the whole collection is filtered and sorted in body on every keystroke, and also on every syncProgress tick because any @Published change invalidates every observer; each row observes the store although it only shows two fields; a formatter is built per row update. The trace confirms it (report above). Rewritten:
@Observable final class OrdersModel {
private(set) var visible: [Order] = []
var query = ""
var syncProgress = 0.0
private var all: [Order] = [] // kept sorted by placedAt when loaded
func applyQuery() {
let text = query
visible = text.isEmpty ? all : all.filter { $0.customer.localizedCaseInsensitiveContains(text) }
}
}
struct OrdersScreen: View {
@State private var model = OrdersModel()
var body: some View {
@Bindable var model = model
List(model.visible) { order in
OrderRow(customer: order.customer, placedAt: order.placedAt)
}
.searchable(text: $model.query)
.task(id: model.query) {
do { try await Task.sleep(for: .milliseconds(250)) } catch { return }
model.applyQuery()
}
}
}
struct OrderRow: View {
let customer: String
let placedAt: Date
var body: some View {
HStack { Text(customer); Spacer(); Text(placedAt.formatted(date: .abbreviated, time: .omitted)) }
}
}
OrdersScreen.body reads visible and query but not syncProgress, so sync ticks no longer touch the list. Each new keystroke cancels the pending task, so filtering runs once, 250 ms after typing pauses. Rows depend on two values and are skipped when those are unchanged.
The user cannot profile today and asks for a code review of a grid that hitches while scrolling 3,000 photos.
ScrollView {
VStack {
ForEach(photos.indices, id: \.self) { index in
Image(uiImage: UIImage(contentsOfFile: photos[index].path)!)
.resizable().scaledToFill()
.frame(width: 96, height: 96).clipped()
.id(UUID())
}
}
}
Findings, all marked unconfirmed until measured: an eager VStack builds 3,000 cells up front; .id(UUID()) gives every cell a new identity on each update, so nothing is reused; index ids break when photos are inserted; a 12-megapixel image is decoded on the main thread for a 96-point cell. Suggested replacement for the cell:
struct PhotoCell: View {
let photo: Photo // Identifiable, with a stable id
@State private var thumbnail: UIImage?
@Environment(\.displayScale) private var displayScale
var body: some View {
Color.gray.opacity(0.15)
.frame(width: 96, height: 96)
.overlay { if let thumbnail { Image(uiImage: thumbnail).resizable().scaledToFill() } }
.clipped()
.task(id: photo.id) {
guard let full = UIImage(contentsOfFile: photo.path) else { return }
let side = 96 * displayScale // pixels; square photos assumed
thumbnail = await full.byPreparingThumbnail(ofSize: CGSize(width: side, height: side))
}
}
}
Used inside LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))]) with ForEach(photos). The measurement that would confirm the diagnosis: the Hitches instrument during a fast scroll, and peak memory in Allocations, before and after.
equatable(), drawingGroup() and manual caching are last resorts. Each hides a dependency problem that is usually cheaper to fix at the source, and drawingGroup() cannot render views backed by platform controls.@Observable changes update behaviour. A view updates only for properties its body reads directly, so migrating can expose code that relied on over-invalidation. Migrate one model at a time and retest.@State what can be derived cheaply, and do not create objects in a view init: SwiftUI creates view values very often.