Performance
Finding out what redraws, instead of guessing
SwiftUI performance work is almost entirely about one question: which views are re-evaluating, and
why? Rendering is rarely the problem. body running a thousand times a second is.
The good news is that this is measurable rather than mystical.
Find out what is re-evaluating
The cheapest instrument in the whole framework:
var body: some View {
let _ = Self._printChanges()
…
}
On every re-evaluation this prints the view’s name and what caused it — a property name, @self if
the view value itself changed, or @identity if it is a new view entirely. Put it in a view you
suspect and watch the console while you use the app.
The output tells you which of three problems you have:
- A named property — some state is changing more than you thought, or is being read too high.
@self— the view struct’s own properties changed, usually because a parent is passing new values every time. A closure or an array literal created inline is a common cause.@identity— the view is being replaced rather than updated. Go back to the identity chapter.
Warning
_printChanges is underscored API. It is for the debugger, not for shipping code — wrap it in
#if DEBUG if it survives past the session you added it in.
For a whole-app view, Instruments has a SwiftUI template. The View Body track shows how long each
body took and how often it ran; the Update Groups track shows what triggered each cycle. Long
bodies are worth a look, but a short body running four hundred times is the more common finding.
The changes that actually help
Split large views. Invalidation happens per view, so one enormous body re-runs entirely for any
change it depends on. Extract a struct — not a computed property or a @ViewBuilder function,
which are inlined into the parent and get no separate identity.
Read state as low as possible. Covered twice already, and it is the highest-leverage change there is. A property read in a parent invalidates the parent and its entire subtree; the same read in the leaf invalidates one leaf.
Give ForEach stable ids. An index-based or freshly-generated id makes every row a new view on
every update, which defeats every optimisation the framework has.
Use lazy containers when the list is long. VStack inside a ScrollView creates every child
immediately; LazyVStack creates them as they approach the viewport. For twenty rows the difference
is noise, for two thousand it is the whole problem. List is lazy already.
Do not put GeometryReader in a row. It is greedy in both dimensions, participates in the
sizing pass, and reruns whenever anything about the layout changes. In a list row it is a reliable
way to make scrolling stutter. Alignment guides, containerRelativeFrame or
onGeometryChange cover most of the cases people reach for it.
Keep expensive work out of body. body may run many times per frame. Sorting, filtering,
formatting and date arithmetic in there run every time.
struct OrderList: View {
let orders: [Order]
private var sorted: [Order] {
orders.sorted { $0.date > $1.date } // runs on every evaluation
}
}
Sort it once where the data changes — in the model — and hand the view a list that is already in order.
Things that look like problems and are not
Views being created constantly. A view struct is a few bytes on the stack, and creating millions
is genuinely cheap. The cost is in body being evaluated, not in the struct existing. Do not
contort a design to avoid making view values.
AnyView everywhere. It does erase the type and does cost something, but it is nowhere near the
first thing to fix. Type erasure in one branch of a conditional is fine; a whole view hierarchy built
out of AnyView is a design worth revisiting for readability before performance.
Deep view hierarchies. Depth from modifiers is normal and largely free. The nesting is resolved at compile time.
Measuring the launch, not the frame
Two numbers worth watching that have nothing to do with body:
Cold launch. Anything on the path to first frame — a @State initialiser doing real work, a
model loading synchronously in init, a large @main scene graph — delays it. Move work into
.task so the first frame is drawn from placeholder state.
Memory from images. Image decoding is not free and a full-resolution photo in a thumbnail grid
holds the decoded bitmap, not the file. Downsample to the size actually being displayed; this is
routinely the difference between a smooth grid and a jettisoned app.
The rule that survives all of it: measure, change one thing, measure again. SwiftUI’s update
behaviour is unintuitive often enough that reasoning from first principles about what “should” be
faster is unreliable, and _printChanges costs ten seconds to try.