diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md index 73f2e4cc..25e4135f 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md @@ -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`. @@ -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. @@ -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 @@ -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` diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md index 8d5a7bb6..b5fe372a 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md @@ -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 @@ -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 @@ -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 @@ -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 = [] } 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 @@ -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: 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 @@ -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: @@ -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 diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md index 39b6530d..ff7c3900 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md @@ -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. @@ -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. diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md new file mode 100644 index 00000000..bd95b513 --- /dev/null +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -0,0 +1,240 @@ +# SwiftUI performance review guide + +Use this as the deep reference when auditing SwiftUI code. If the target repo already has a local SwiftUI performance guide, read that first and treat it as authoritative; use this document to fill gaps, not to override local conventions. + +## Mental model + +SwiftUI performance is mostly about three things: + +- **Identity**: how SwiftUI recognizes the same logical view across updates. +- **Lifetime**: where state storage lives for that identity. +- **Dependencies**: which values a view reads while evaluating `body`. + +Prefer designs that preserve stable identity, keep dependency surfaces narrow, and make every code path reachable from `body` cheap and side-effect free. + +## State and Observation + +Prefer Observation for new feature code when the deployment target allows it. + +- Use `@Observable` models for feature state. +- Own them with `@State` in the root view, pass them explicitly to children, and use `@Bindable` only where bindings are required. +- Prefer `@Environment(Type.self)` or explicit parameters over broad `@EnvironmentObject` usage. +- Split "god" observables when different parts of the screen read unrelated fields. +- Mark mutable non-render bookkeeping such as caches, task handles, cancellables, services, and lazy dependencies with `@ObservationIgnored` when they should not participate in invalidation. +- Do not mark immutable `let` dependencies with `@ObservationIgnored`; they are already outside mutable observed state. + +Observation is read-tracked. The important design move is not merely "use `@Observable`", but "make each leaf read only what it truly needs." If every row reads the same broad collection or root model, each row can depend on that whole value even when it only needs one derived fact. + +## Structural identity + +### Avoid `AnyView` in hot paths + +Flag `AnyView` in lists, large `ForEach` content, dynamic rows, and frequently updating surfaces. Prefer: + +- `@ViewBuilder` functions returning `some View` +- enums plus `switch` +- concrete wrapper views with stable structure + +Use `AnyView` only for narrow boundaries where the type erasure is the point. + +### Prefer value changes over tree swaps + +When state only changes style or behavior, keep one view type and vary values: + +```swift +Text(title) + .fontWeight(isHighlighted ? .bold : .regular) + .opacity(isDisabled ? 0.4 : 1) +``` + +Avoid helper modifiers such as `.if`, `.when`, or `.apply(if:)` when they branch between structurally different trees for a styling-only change. Those helpers still lower into `_ConditionalContent`, which can reset state, break animations, and increase work in repeated rows. + +Structural branches are fine when the UI truly changes shape, such as loading, loaded, and error states. If state must survive the swap, lift it above the branch. + +### Keep one root view per `ForEach` element + +For large collections, each element should yield a constant number of root views. + +- Prefer filtering before `ForEach` over returning "sometimes nothing". +- Avoid returning a variable number of sibling roots per element. +- If a condition must stay inside the row, wrap it in one stable container. + +### Treat `.id(...)` as a sharp tool + +Changing `.id(...)` resets state and prevents diffing across the old and new identity. + +- Flag `.id(UUID())` and other force-refresh patterns. +- Use explicit identity only when it encodes real stable data or drives a deliberate scroll/animation behavior. + +### Prefer native controls plus styles + +When a wrapper exists only to style `Button`, `Toggle`, `Label`, or another native control, prefer a `ButtonStyle`, `ToggleStyle`, or related style protocol. Wrapper views are still appropriate when they encode domain semantics, combine multiple controls, or add real behavior. + +## Cheap view evaluation + +Treat all code reachable from `body` as hot-path code: + +- `body` +- computed properties used by `body` +- helper methods called by `body` +- alternate "measured body" implementations + +View bodies run on the main thread, and synchronous work reached from `body` has to complete before SwiftUI can finish the update for the next frame. + +Flag: + +- sorting or filtering large collections during render +- formatter allocation +- parsing, image processing, JSON work, or synchronous I/O +- heavyweight layout calculations +- long-running work in initializers, `onAppear`, or `onChange` + +Prefer: + +- model-layer precomputation +- derived state updated from meaningful input changes +- cached helpers and formatter reuse +- background preprocessing before render + +`@State` is not a generic cache. Use it only when the derived value belongs to the view lifecycle and has a clear update contract. + +- Define which input changes refresh the state. +- Prefer model or store precomputation when the value is not view-owned. +- Prefer memoized helpers or background preprocessing when the work is expensive but not stateful UI. + +## Lists and large collections + +- Prefer `List` for table-like content with system affordances. +- For custom layouts with many repeated offscreen children, consider `ScrollView` plus lazy containers when list affordances are not needed. +- Prefer standard stacks for small content or when profiling does not show a lazy-container benefit. +- Avoid nested scroll containers such as `ScrollView { List { ... } }`. +- Use stable domain IDs rather than indices, mutable values, or per-render UUIDs. +- Be cautious with `ForEach(0..: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + } +} +``` + +Stored escaping builder closures make diffing harder and can drag parent dependencies into children. + +### Action closures + +Action closures are fine, but captures still matter. + +- Prefer method references when they reduce the capture set. +- Use capture lists when only a small subset is needed. +- In hot paths, flag actions that capture a large view model or broad state unnecessarily. + +### Manual bindings + +Prefer key-path bindings such as `$state` and `$model.property` over `Binding(get:set:)` where possible. + +Manual bindings store closures and are harder for SwiftUI to compare across updates. Strongly question them in list rows, text input, and other frequently updating controls. If one is necessary, isolate it in a small subview and document why a key-path binding was insufficient. + +## Images and animations + +- Decode and downsample large images before rendering. +- Avoid broad animation modifiers that cause a large subtree to animate for tiny state changes. +- Flag `.animation(..., value:)` or `withAnimation` usage that wraps a large container when only a small control, row, or transition should animate. Prefer SwiftUI's scoped `.animation(...) { content in ... }` modifier when the deployment target supports it; otherwise move the animation modifier to the smallest affected view. +- Prefer focused transitions over animating entire container hierarchies. + +## Profiling + +Use Instruments' SwiftUI template to inspect: + +- Update Groups +- Long View Body Updates +- Other Long Updates +- cause-and-effect graphs + +Use Update Groups to find when SwiftUI is doing work, Long View Body Updates to find slow `body` evaluation, and the cause-and-effect graph to compare the interaction's expected update fan-out with the actual views that were invalidated. If a tap should update two rows but the graph shows the whole list, narrow the dependencies before reaching for `.equatable()` or other local triage. + +When a PR materially changes a large scrolling surface, frequently updated screen, or complex animated area, add a `// PERF:` note when useful so future maintainers know how to reproduce and measure the sensitive path. + +## Review checklist + +Always ask: + +1. Is there `AnyView` in a hot path? +2. Is any code reachable from `body` doing heavy work? +3. Is `@State` being used as an ad hoc cache without a clear input-change contract? +4. Are list identities stable and unique? +5. Does each `ForEach` element produce one stable root? +6. Are parent views, geometry readers, preference chains, or environment writes causing broad invalidation? +7. Is Observation being used with narrow reads and correct ignored state? +8. Is heavy work tied to lifecycle modifiers or the main actor? +9. Are stored builder closures or broad action captures causing avoidable churn? +10. Are manual bindings used where key-path bindings would work? +11. Could similar branches be value-based modifiers instead of tree swaps? +12. Is `.equatable()` backed by real evidence rather than hope? +13. Is animation scoped to the smallest view that owns the visual change? diff --git a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/async-state.md b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/async-state.md index 4fe83137..a6e048c5 100644 --- a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/async-state.md +++ b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/async-state.md @@ -11,6 +11,7 @@ Use this pattern when a view loads data, reacts to changing input, or coordinate - Treat cancellation as a normal path for view-driven tasks. Check `Task.isCancelled` in longer flows and avoid surfacing cancellation as a user-facing error. - Debounce or coalesce user-driven async work such as search before it fans out into repeated requests. - Keep UI-facing models and mutations main-actor-safe; do background work in services, then publish the result back to UI state. +- Do not assume `.task` or `async` moves CPU-heavy synchronous work off the main actor. Put expensive parsing, decoding, formatting, or database work in a service/helper that does not touch actor-isolated UI state; in Swift 6.2+ use `@concurrent` when an async helper must explicitly hop off the caller's actor. ## Example: load on appear diff --git a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md index b574e73a..3f504606 100644 --- a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md +++ b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md @@ -2,15 +2,22 @@ ## Intent -Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation. +Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation. This page is a build-time guardrail, not a substitute for a full audit; when the task is specifically about performance, use the `swiftui-performance-audit` skill and its deeper review guide. ## Core rules - Give `ForEach` and list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate. +- Keep one stable root view per `ForEach` element; filter before iterating instead of making rows appear and disappear at the root. +- In lazy containers, keep row-lifetime `@State` on the stable root view returned from `ForEach`; nested child state can be recreated when offscreen content is rebuilt. - Keep expensive filtering, sorting, and formatting out of `body`; precompute or move it into a model/helper when it is not trivial. - Narrow observation scope so only the views that read changing state need to update. -- Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently. +- Use lazy containers for large repeated content when standard stacks load too many children; prefer standard stacks for small content or when profiling does not show a lazy benefit. - Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers. +- Prefer value-based modifiers over `.if`-style helpers when a condition changes only styling or behavior. +- Avoid `AnyView`, stored builder closures, and manual `Binding(get:set:)` in hot paths when concrete views, stored child views, or key-path bindings would do. +- Keep `GeometryReader`, preference chains, and high-volume environment writes tightly scoped so one hot signal does not wake an unrelated subtree. +- Use `@State` only for view-owned state, not as an ad hoc cache for arbitrary expensive computation. +- Scope animation to the smallest view that owns the visual change; prefer scoped `.animation(...) { content in ... }` modifiers when available over broad animation modifiers on large containers. ## Example: stable identity @@ -60,3 +67,7 @@ If the work is more expensive than a small derived property, move it into a mode - Recomputing heavy transforms every render - Observing a large object from many descendants when only one field matters - Building custom scroll containers when `List`, `LazyVStack`, or `LazyHGrid` would already solve the problem +- Using `.id(...)` as a force-refresh mechanism instead of a real identity boundary +- Reaching for `.equatable()` before composition and dependency scope have been fixed +- Publishing broad preference payloads or geometry changes on every tiny layout update +- Animating an entire container when only a small row, control, or transition changes diff --git a/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md b/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md index 181f94f9..d516eedc 100644 --- a/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md +++ b/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md @@ -130,6 +130,7 @@ private func reload(for searchText: String) async { - Avoid `body` or computed views that return completely different root branches via `if/else`. - Prefer a single stable base view with conditions inside sections/modifiers (`overlay`, `opacity`, `disabled`, `toolbar`, etc.). - Root-level branch swapping causes identity churn, broader invalidation, and extra recomputation. +- When the refactor is motivated by redraws, scroll hitches, or typing lag, also use the `swiftui-performance-audit` skill so the change is checked against the fuller performance rules around observation scope, `ForEach` identity, stored closures, and manual bindings. Prefer: @@ -189,6 +190,7 @@ init(dependency: Dependency) { 5. If a view model exists or is explicitly required, replace optional view models with a non-optional `@State` view model initialized in `init`. 6. Confirm Observation usage: `@State` for root `@Observable` models on iOS 17+, legacy wrappers only when the deployment target requires them. 7. Keep behavior intact: do not change layout or business logic unless requested. +8. If the refactor is performance-motivated, verify the result against the `swiftui-performance-audit` guidance rather than assuming a smaller file automatically became a faster view. ## Notes