Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Use this skill to diagnose SwiftUI performance issues from code first, then requ
## Workflow

1. Classify the symptom: slow rendering, janky scrolling, high CPU, memory growth, hangs, or excessive view updates.
2. If code is available, start with a code-first review using `references/code-smells.md`.
2. If code is available, first read any repo-local SwiftUI performance guide that exists, then start the code-first review using `references/review-guide.md` and `references/code-smells.md`.
3. If code is not available, ask for the smallest useful slice: target view, data flow, reproduction steps, and deployment target.
4. If code review is inconclusive or runtime evidence is required, guide the user through profiling with `references/profiling-intake.md`.
5. Summarize likely causes, evidence, remediation, and validation steps using `references/report-template.md`.
Expand Down Expand Up @@ -43,8 +43,9 @@ Focus on:
- Layout thrash from complex hierarchies, `GeometryReader`, or preference chains.
- Large image decode or resize work on the main thread.
- Animation or transition work applied too broadly.
- Stored builder closures, broad action captures, and manual bindings that force avoidable recomputation.

Use `references/code-smells.md` for the detailed smell catalog and fix guidance.
Use `references/review-guide.md` for the full decision model and `references/code-smells.md` for a fast scan of common smells and fix guidance.

Provide:
- Likely root causes with code references.
Expand Down Expand Up @@ -74,11 +75,14 @@ Apply targeted fixes:
- Narrow state scope and reduce broad observation fan-out.
- Stabilize identities for `ForEach` and lists.
- Move heavy work out of `body` into derived state updated from inputs, model-layer precomputation, memoized helpers, or background preprocessing. Use `@State` only for view-owned state, not as an ad hoc cache for arbitrary computation.
- Prefer value-based modifiers over view-tree-breaking branches when only style or behavior changes.
- Keep one stable root view per `ForEach` element, and avoid `.id(...)` resets unless identity is the real feature.
- Avoid stored builder closures and manual `Binding(get:set:)` in hot paths when a stored child view or key-path binding would do.
- Use `equatable()` only when equality is cheaper than recomputing the subtree and the inputs are truly value-semantic.
- Downsample images before rendering.
- Reduce layout complexity or use fixed sizing where possible.

Use `references/code-smells.md` for examples, Observation-specific fan-out guidance, and remediation patterns.
Use `references/review-guide.md` for the full rule set and `references/code-smells.md` for examples, Observation-specific fan-out guidance, and remediation patterns.

## 6. Verify

Expand All @@ -96,6 +100,7 @@ Use `references/report-template.md` when formatting the final audit.

## References

- Full SwiftUI performance review guide: `references/review-guide.md`
- Profiling intake and collection checklist: `references/profiling-intake.md`
- Common code smells and remediation patterns: `references/code-smells.md`
- Audit output template: `references/report-template.md`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ final class DistanceFormatter {
}
```

Formatter allocation and string formatting in `body` both run during view update work. Precompute display strings before rendering when the calculation is expensive or repeated across rows.

### Heavy computed properties

```swift
Expand Down Expand Up @@ -62,6 +64,20 @@ ForEach(items.filter { $0.isEnabled }) { item in

Prefer a prefiltered collection with stable identity.

### Eager stacks with large repeated content

```swift
ScrollView {
VStack {
ForEach(items) { item in
Row(item)
}
}
}
```

Standard stacks load their child view hierarchy immediately. For large repeated content, profile whether `List`, `LazyVStack`, `LazyHStack`, or a lazy grid avoids unnecessary initial view creation. Keep standard stacks for small content or when lazy layout does not improve the measured path.

### Unstable identity

```swift
Expand All @@ -86,6 +102,42 @@ var content: some View {

Prefer one stable base view and localize conditions to sections or modifiers. This reduces root identity churn and makes diffing cheaper.

### View-tree-breaking styling helpers

```swift
Text(title)
.if(isHighlighted) { $0.bold() }
```

If the condition only changes style or behavior, prefer a value-based modifier:

```swift
Text(title)
.fontWeight(isHighlighted ? .bold : .regular)
```

Custom `.if`-style helpers often swap structural identity even when the visual change looks small.

### Variable root counts inside `ForEach`

```swift
ForEach(items) { item in
if item.isVisible {
Row(item)
}
}
```

Prefer filtering before iteration, or keep one stable root view per element if the condition must stay inside the row.

### Force-refresh identity

```swift
content.id(UUID())
```

Treat `.id(...)` as an identity tool, not a refresh hammer. Changing it resets state and defeats diffing.

### Image decoding on the main thread

```swift
Expand All @@ -100,15 +152,43 @@ Prefer decode and downsample work off the main thread, then store the processed

```swift
@Observable final class Model {
var items: [Item] = []
var favoriteIDs: Set<Item.ID> = []
}

var body: some View {
Row(isFavorite: model.items.contains(item))
Row(isFavorite: model.favoriteIDs.contains(item.id))
}
```

If many views read the same broad collection or root model, small changes can fan out into wide invalidation. Prefer narrower derived inputs, smaller observable surfaces, or per-item state closer to the leaf views.
If many views read the same broad collection or root model, small changes can fan out into wide invalidation. This still applies when the collection read is hidden behind a helper called from `body`. Prefer passing a derived value such as `isFavorite`, using smaller observable surfaces, or moving per-item state closer to the leaf views.

### High-volume environment writes

```swift
.environment(\.scrollOffset, scrollOffset)
```

Rapidly changing environment values wake every environment-reading descendant. Prefer a stable observable reference in the environment and mutate the one field that interested leaves read.

Even skipped body updates can have check cost after an environment value changes, so do not put high-frequency geometry, timer, scroll, or collection values directly in the environment.

### Wide geometry readers

Large subtrees under `GeometryReader` or `ScrollViewReader` can react to layout changes they do not care about. Keep reader scope tight and move unrelated stateful content outside.

### Broad preference chains

`PreferenceKey`, `anchorPreference`, `overlayPreferenceValue`, and `onPreferenceChange` can move layout data across a wide subtree. Keep payloads small and stable, and publish them from the measured view rather than from a broad container.

### Geometry-driven state loops

```swift
.onPreferenceChange(FramePreferenceKey.self) { frame in
selectedFrame = frame
}
```

If the state write changes layout, SwiftUI can emit another measurement and repeat the cycle. Compare against the previous value, threshold noisy values, or move the state boundary closer to the measured view.

### Broad `ObservableObject` reads on iOS 16 and earlier

Expand All @@ -120,6 +200,72 @@ final class Model: ObservableObject {

The same warning applies to legacy observation. Avoid having many descendants observe a large shared object when they only need one derived field.

## Closure and binding smells

### Stored builder closures

```swift
struct Container<Content: View>: View {
let content: () -> Content

var body: some View {
content()
}
}
```

Prefer evaluating a non-escaping builder in `init` and storing the built view. Stored closures are harder for SwiftUI to compare and can capture more parent state than intended.

### Broad action captures

```swift
Button("Retry") {
viewModel.retry()
}
```

In hot paths, prefer a method reference or a narrower capture when that avoids dragging large, frequently changing state into the closure value.

### Manual bindings in hot paths

```swift
Toggle("Enabled", isOn: Binding(
get: { model.isEnabled },
set: { model.isEnabled = $0 }
))
```

Prefer `$model.isEnabled` when a key-path binding exists. Manual bindings store closures and are harder to diff between updates.

## Animation smells

### Broad container animations

```swift
VStack {
Header()
List(items) { item in
Row(item)
}
}
.animation(.default, value: selection)
```

Broad animation modifiers can animate unrelated child updates and add layout work across a large subtree. Apply animation to the smallest view that owns the visual change, such as the row, control, or transition.

When the animation belongs to a subset of a larger view and the deployment target supports it, prefer SwiftUI's scoped animation modifier:

```swift
List(items) { item in
Row(item)
.animation(.default) { content in
content.opacity(item.id == selection ? 1 : 0.5)
}
}
```

Only the modifiers applied inside the closure inherit the animation. On older deployment targets, move the animation modifier to the smallest affected view.

## Remediation notes

### `@State` is not a generic cache
Expand All @@ -132,6 +278,8 @@ Better alternatives:
- memoize in a dedicated helper
- preprocess on a background task before rendering

If the value is derived from inputs, document or encode the exact input-change path that refreshes it.

### `equatable()` is conditional guidance

Use `equatable()` only when:
Expand All @@ -145,6 +293,7 @@ Do not apply `equatable()` as a blanket fix for all redraws.
When multiple smells appear together, prioritize in this order:
1. Broad invalidation and observation fan-out
2. Unstable identity and list churn
3. Main-thread work during render
4. Image decode or resize cost
5. Layout and animation complexity
3. Main-thread work during render or lifecycle modifiers
4. Stored closures or manual bindings in hot paths
5. Image decode or resize cost
6. Layout and animation complexity
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Use this checklist when code review alone cannot explain the SwiftUI performance

Ask the user to:
- Run the app in a Release build when possible.
- Prefer a real-device capture for final performance decisions; treat Simulator traces as exploratory.
- Use the SwiftUI Instruments template.
- Reproduce the exact problematic interaction only long enough to capture the issue.
- Capture the SwiftUI timeline and Time Profiler together.
Expand All @@ -39,6 +40,6 @@ Ask the user to:
## Common traps

- Debug builds can distort SwiftUI timing and allocation behavior.
- Simulator traces can miss device-only rendering or memory issues.
- Simulator traces can miss device-only rendering, memory, thermal, and scrolling behavior.
- Mixed interactions in one capture make attribution harder.
- Screenshots without the reproduction note are much harder to interpret.
Loading