diff --git a/swiftui-performance-audit/SKILL.md b/swiftui-performance-audit/SKILL.md index c28123f..7d1fdc7 100644 --- a/swiftui-performance-audit/SKILL.md +++ b/swiftui-performance-audit/SKILL.md @@ -5,184 +5,82 @@ description: Audit and improve SwiftUI runtime performance from code review and # SwiftUI Performance Audit -## Overview +## Quick start -Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps. +Use this skill to diagnose SwiftUI performance issues from code first, then request profiling evidence when code review alone cannot explain the symptoms. -## Workflow Decision Tree +## Workflow -- If the user provides code, start with "Code-First Review." -- If the user only describes symptoms, ask for minimal code/context, then do "Code-First Review." -- If code review is inconclusive, go to "Guide the User to Profile" and ask for a trace or screenshots. +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`. +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`. -## 1. Code-First Review +## 1. Intake Collect: -- Target view/feature code. -- Data flow: state, environment, observable models. -- Symptoms and reproduction steps. +- Target view or feature code. +- Symptoms and exact reproduction steps. +- Data flow: `@State`, `@Binding`, environment dependencies, and observable models. +- Whether the issue shows up on device or simulator, and whether it was observed in Debug or Release. + +Ask the user to classify the issue if possible: +- CPU spike or battery drain +- Janky scrolling or dropped frames +- High memory or image pressure +- Hangs or unresponsive interactions +- Excessive or unexpectedly broad view updates + +For the full profiling intake checklist, read `references/profiling-intake.md`. + +## 2. Code-First Review Focus on: -- View invalidation storms from broad state changes. -- Unstable identity in lists (`id` churn, `UUID()` per render). -- Top-level conditional view swapping (`if/else` returning different root branches). -- Heavy work in `body` (formatting, sorting, image decoding). -- Layout thrash (deep stacks, `GeometryReader`, preference chains). -- Large images without downsampling or resizing. -- Over-animated hierarchies (implicit animations on large trees). +- Invalidation storms from broad observation or environment reads. +- Unstable identity in lists and `ForEach`. +- Heavy derived work in `body` or view builders. +- 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. + +Use `references/code-smells.md` for the detailed smell catalog and fix guidance. Provide: - Likely root causes with code references. - Suggested fixes and refactors. - If needed, a minimal repro or instrumentation suggestion. -## 2. Guide the User to Profile +## 3. Guide the User to Profile -Explain how to collect data with Instruments: -- Use the SwiftUI template in Instruments (Release build). -- Reproduce the exact interaction (scroll, navigation, animation). -- Capture SwiftUI timeline and Time Profiler. -- Export or screenshot the relevant lanes and the call tree. - -Ask for: -- Trace export or screenshots of SwiftUI lanes + Time Profiler call tree. +If code review does not explain the issue, ask for runtime evidence: +- A trace export or screenshots of the SwiftUI timeline and Time Profiler call tree. - Device/OS/build configuration. +- The exact interaction being profiled. +- Before/after metrics if the user is comparing a change. -## 3. Analyze and Diagnose +Use `references/profiling-intake.md` for the exact checklist and collection steps. -Prioritize likely SwiftUI culprits: -- View invalidation storms from broad state changes. -- Unstable identity in lists (`id` churn, `UUID()` per render). -- Top-level conditional view swapping (`if/else` returning different root branches). -- Heavy work in `body` (formatting, sorting, image decoding). -- Layout thrash (deep stacks, `GeometryReader`, preference chains). -- Large images without downsampling or resizing. -- Over-animated hierarchies (implicit animations on large trees). +## 4. Analyze and Diagnose -Summarize findings with evidence from traces/logs. +- Map the evidence to the most likely category: invalidation, identity churn, layout thrash, main-thread work, image cost, or animation cost. +- Prioritize problems by impact, not by how easy they are to explain. +- Distinguish code-level suspicion from trace-backed evidence. +- Call out when profiling is still insufficient and what additional evidence would reduce uncertainty. -## 4. Remediate +## 5. Remediate Apply targeted fixes: -- Narrow state scope (`@State`/`@Observable` closer to leaf views). +- Narrow state scope and reduce broad observation fan-out. - Stabilize identities for `ForEach` and lists. -- Move heavy work out of `body` (precompute, cache, `@State`). -- Use `equatable()` or value wrappers for expensive subtrees. +- 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. +- 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. -## Common Code Smells (and Fixes) +Use `references/code-smells.md` for examples, Observation-specific fan-out guidance, and remediation patterns. -Look for these patterns during code review. - -### Expensive formatters in `body` - -```swift -var body: some View { - let number = NumberFormatter() // slow allocation - let measure = MeasurementFormatter() // slow allocation - Text(measure.string(from: .init(value: meters, unit: .meters))) -} -``` - -Prefer cached formatters in a model or a dedicated helper: - -```swift -final class DistanceFormatter { - static let shared = DistanceFormatter() - let number = NumberFormatter() - let measure = MeasurementFormatter() -} -``` - -### Computed properties that do heavy work - -```swift -var filtered: [Item] { - items.filter { $0.isEnabled } // runs on every body eval -} -``` - -Prefer precompute or cache on change: - -```swift -@State private var filtered: [Item] = [] -// update filtered when inputs change -``` - -### Sorting/filtering in `body` or `ForEach` - -```swift -List { - ForEach(items.sorted(by: sortRule)) { item in - Row(item) - } -} -``` - -Prefer sort once before view updates: - -```swift -let sortedItems = items.sorted(by: sortRule) -``` - -### Inline filtering in `ForEach` - -```swift -ForEach(items.filter { $0.isEnabled }) { item in - Row(item) -} -``` - -Prefer a prefiltered collection with stable identity. - -### Unstable identity - -```swift -ForEach(items, id: \.self) { item in - Row(item) -} -``` - -Avoid `id: \.self` for non-stable values; use a stable ID. - -### Top-level conditional view swapping - -```swift -var content: some View { - if isEditing { - editingView - } else { - readOnlyView - } -} -``` - -Prefer one stable base view and localize conditions to sections/modifiers (for example inside `toolbar`, row content, `overlay`, or `disabled`). This reduces root identity churn and helps SwiftUI diffing stay efficient. - -### Image decoding on the main thread - -```swift -Image(uiImage: UIImage(data: data)!) -``` - -Prefer decode/downsample off the main thread and store the result. - -### Broad dependencies in observable models - -```swift -@Observable class Model { - var items: [Item] = [] -} - -var body: some View { - Row(isFavorite: model.items.contains(item)) -} -``` - -Prefer granular view models or per-item state to reduce update fan-out. - -## 5. Verify +## 6. Verify Ask the user to re-run the same capture and compare with baseline metrics. Summarize the delta (CPU, frame drops, memory peak) if provided. @@ -194,9 +92,14 @@ Provide: - Top issues (ordered by impact). - Proposed fixes with estimated effort. +Use `references/report-template.md` when formatting the final audit. + ## References -Add Apple documentation and WWDC resources under `references/` as they are supplied by the user. +- 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` +- Add Apple documentation and WWDC resources under `references/` as they are supplied by the user. - Optimizing SwiftUI performance with Instruments: `references/optimizing-swiftui-performance-instruments.md` - Understanding and improving SwiftUI performance: `references/understanding-improving-swiftui-performance.md` - Understanding hangs in your app: `references/understanding-hangs-in-your-app.md` diff --git a/swiftui-performance-audit/references/code-smells.md b/swiftui-performance-audit/references/code-smells.md new file mode 100644 index 0000000..8d5a7bb --- /dev/null +++ b/swiftui-performance-audit/references/code-smells.md @@ -0,0 +1,150 @@ +# Common code smells and remediation patterns + +## Intent + +Use this reference during code-first review to map visible SwiftUI patterns to likely runtime costs and safer remediation guidance. + +## High-priority smells + +### Expensive formatters in `body` + +```swift +var body: some View { + let number = NumberFormatter() + let measure = MeasurementFormatter() + Text(measure.string(from: .init(value: meters, unit: .meters))) +} +``` + +Prefer cached formatters in a model or dedicated helper: + +```swift +final class DistanceFormatter { + static let shared = DistanceFormatter() + let number = NumberFormatter() + let measure = MeasurementFormatter() +} +``` + +### Heavy computed properties + +```swift +var filtered: [Item] { + items.filter { $0.isEnabled } +} +``` + +Prefer deriving this once per meaningful input change in a model/helper, or store derived view-owned state only when the view truly owns the transformation lifecycle. + +### Sorting or filtering inside `body` + +```swift +List { + ForEach(items.sorted(by: sortRule)) { item in + Row(item) + } +} +``` + +Prefer sorting before render work begins: + +```swift +let sortedItems = items.sorted(by: sortRule) +``` + +### Inline filtering inside `ForEach` + +```swift +ForEach(items.filter { $0.isEnabled }) { item in + Row(item) +} +``` + +Prefer a prefiltered collection with stable identity. + +### Unstable identity + +```swift +ForEach(items, id: \.self) { item in + Row(item) +} +``` + +Avoid `id: \.self` for non-stable values or collections that reorder. Use a stable domain identifier. + +### Top-level conditional view swapping + +```swift +var content: some View { + if isEditing { + editingView + } else { + readOnlyView + } +} +``` + +Prefer one stable base view and localize conditions to sections or modifiers. This reduces root identity churn and makes diffing cheaper. + +### Image decoding on the main thread + +```swift +Image(uiImage: UIImage(data: data)!) +``` + +Prefer decode and downsample work off the main thread, then store the processed image. + +## Observation fan-out + +### Broad `@Observable` reads on iOS 17+ + +```swift +@Observable final class Model { + var items: [Item] = [] +} + +var body: some View { + Row(isFavorite: model.items.contains(item)) +} +``` + +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. + +### Broad `ObservableObject` reads on iOS 16 and earlier + +```swift +final class Model: ObservableObject { + @Published var items: [Item] = [] +} +``` + +The same warning applies to legacy observation. Avoid having many descendants observe a large shared object when they only need one derived field. + +## Remediation notes + +### `@State` is not a generic cache + +Use `@State` for view-owned state and derived values that intentionally belong to the view lifecycle. Do not move arbitrary expensive computation into `@State` unless you also define when and why it updates. + +Better alternatives: +- precompute in the model or store +- update derived state in response to a specific input change +- memoize in a dedicated helper +- preprocess on a background task before rendering + +### `equatable()` is conditional guidance + +Use `equatable()` only when: +- equality is cheaper than recomputing the subtree, and +- the view inputs are value-semantic and stable enough for meaningful equality checks + +Do not apply `equatable()` as a blanket fix for all redraws. + +## Triage order + +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 diff --git a/swiftui-performance-audit/references/profiling-intake.md b/swiftui-performance-audit/references/profiling-intake.md new file mode 100644 index 0000000..39b6530 --- /dev/null +++ b/swiftui-performance-audit/references/profiling-intake.md @@ -0,0 +1,44 @@ +# Profiling intake and collection checklist + +## Intent + +Use this checklist when code review alone cannot explain the SwiftUI performance issue and you need runtime evidence from the user. + +## Ask for first + +- Exact symptom: CPU spike, dropped frames, memory growth, hangs, or excessive view updates. +- Exact interaction: scrolling, typing, initial load, navigation push/pop, animation, sheet presentation, or background refresh. +- Target device and OS version. +- Whether the issue was reproduced on a real device or only in Simulator. +- Build configuration: Debug or Release. +- Whether the user already has a baseline or before/after comparison. + +## Default profiling request + +Ask the user to: +- Run the app in a Release build when possible. +- 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. +- Export the trace or provide screenshots of the key SwiftUI lanes and the Time Profiler call tree. + +## Ask for these artifacts + +- Trace export or screenshots of the relevant SwiftUI lanes +- Time Profiler call tree screenshot or export +- Device/OS/build configuration +- A short note describing what action was happening at the time of the capture +- If memory is involved, the memory graph or Allocations data if available + +## When to ask for more + +- Ask for a second capture if the first run mixes multiple interactions. +- Ask for a before/after pair if the user has already tried a fix. +- Ask for a device capture if the issue only appears in Simulator or if scrolling smoothness matters. + +## Common traps + +- Debug builds can distort SwiftUI timing and allocation behavior. +- Simulator traces can miss device-only rendering or memory issues. +- Mixed interactions in one capture make attribution harder. +- Screenshots without the reproduction note are much harder to interpret. diff --git a/swiftui-performance-audit/references/report-template.md b/swiftui-performance-audit/references/report-template.md new file mode 100644 index 0000000..97982c7 --- /dev/null +++ b/swiftui-performance-audit/references/report-template.md @@ -0,0 +1,47 @@ +# Audit output template + +## Intent + +Use this structure when reporting SwiftUI performance findings so the user can quickly see the symptom, evidence, likely cause, and next validation step. + +## Template + +```markdown +## Summary + +[One short paragraph on the most likely bottleneck and whether the conclusion is code-backed or trace-backed.] + +## Findings + +1. [Issue title] + - Symptom: [what the user sees] + - Likely cause: [root cause] + - Evidence: [code reference or profiling evidence] + - Fix: [specific change] + - Validation: [what to measure after the fix] + +2. [Issue title] + - Symptom: ... + - Likely cause: ... + - Evidence: ... + - Fix: ... + - Validation: ... + +## Metrics + +| Metric | Before | After | Notes | +| --- | --- | --- | --- | +| CPU | [value] | [value] | [note] | +| Frame drops / hitching | [value] | [value] | [note] | +| Memory peak | [value] | [value] | [note] | + +## Next step + +[One concrete next action: apply a fix, capture a better trace, or validate on device.] +``` + +## Notes + +- Order findings by impact, not by file order. +- Say explicitly when a conclusion is still a hypothesis. +- If no metrics are available, omit the table and say what should be measured next.