swiftui-performance — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited swiftui-performance (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Collect:
Focus on:
id churn, UUID() per render).if/else returning different root branches).body (formatting, sorting, image decoding).GeometryReader, preference chains).Provide:
Explain how to collect data with Instruments:
Ask for:
Prioritize likely SwiftUI culprits:
id churn, UUID() per render).if/else returning different root branches).body (formatting, sorting, image decoding).GeometryReader, preference chains).Summarize findings with evidence from traces/logs.
Apply targeted fixes:
@State/@Observable closer to leaf views).ForEach and lists.body (precompute, cache, @State).equatable() or value wrappers for expensive subtrees.Look for these patterns during code review.
bodyvar 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:
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}var filtered: [Item] {
items.filter { $0.isEnabled } // runs on every body eval
}Prefer precompute or cache on change:
@State private var filtered: [Item] = []
// update filtered when inputs changebody or ForEach// DON'T: sorts or filters on every body evaluation
ForEach(items.sorted(by: sortRule)) { item in Row(item) }
ForEach(items.filter { $0.isEnabled }) { item in Row(item) }Prefer precomputed, cached collections with stable identity. Update on input change, not in body.
ForEach(items, id: \.self) { item in
Row(item)
}Avoid id: \.self for non-stable values; use a stable ID.
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(uiImage: UIImage(data: data)!)Prefer decode/downsample off the main thread and store the result.
@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.
Ask the user to re-run the same capture and compare with baseline metrics. Summarize the delta (CPU, frame drops, memory peak) if provided.
Provide:
Instruments ships with a dedicated SwiftUI template (available in Xcode 15+ / Instruments 15+). This template provides:
body is evaluated.@State, @Binding, and @Observable property changes that trigger view updates.body computations.In the SwiftUI instrument lane, each row represents a view type. Key signals:
body (formatting, sorting, image work).Add Self._printChanges() in Debug builds to log exactly which property triggered a view update:
var body: some View {
#if DEBUG
let _ = Self._printChanges() // prints: "MyView: @self, _count changed."
#endif
Text("Count: \(count)")
}Remove _printChanges() before submitting to the App Store -- it is a debug-only API.
When Time Profiler shows significant time in a view's body:
NumberFormatter(), DateFormatter()), collection operations (.sorted(), .filter()), or image decoding.onChange, task, or precomputed @State.SwiftUI assigns every view an identity used to track its lifetime, state, and animations.
body to distinguish views..id(_:) modifier or ForEach(items, id: \.stableID).// Structural identity: SwiftUI knows these are different views by position
VStack {
Text("First") // position 0
Text("Second") // position 1
}When a view's identity changes, SwiftUI treats it as a new view:
@State is reset.onAppear fires again.When identity stays the same, SwiftUI updates the existing view in place, preserving state and providing smooth transitions.
AnyView erases type information, forcing SwiftUI to fall back to less efficient diffing:
// DON'T: AnyView destroys type identity
func makeView(for item: Item) -> AnyView {
if item.isPremium {
return AnyView(PremiumRow(item: item))
} else {
return AnyView(StandardRow(item: item))
}
}
// DO: use @ViewBuilder to preserve structural identity
@ViewBuilder
func makeView(for item: Item) -> some View {
if item.isPremium {
PremiumRow(item: item)
} else {
StandardRow(item: item)
}
}AnyView also prevents SwiftUI from detecting which branch changed, causing full subtree replacement instead of targeted updates.
The .id() modifier assigns explicit identity. Changing the value destroys and recreates the view:
// DON'T: UUID() changes every render, destroying and recreating the view each time
ScrollView {
LazyVStack {
ForEach(items) { item in
Row(item: item)
.id(UUID()) // kills performance -- new identity every render
}
}
}
// DO: use a stable identifier
ForEach(items) { item in
Row(item: item)
.id(item.stableID) // identity only changes when the item actually changes
}Intentional .id() change is useful for resetting state (e.g., .id(selectedTab) to reset a scroll position when switching tabs).
Lazy stacks only create views for items currently visible on screen. Off-screen items are not evaluated until scrolled into view.
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemRow(item: item)
}
}
}Key behaviors:
onAppear fires when the view first enters the visible area.onDisappear fires when it leaves, but the view is still alive.Use lazy grids for multi-column layouts:
// Adaptive: as many columns as fit with minimum width
let columns = [GridItem(.adaptive(minimum: 150))]
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
}
}
}
// Fixed: exact number of equal columns
let fixedColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
]| Scenario | Use |
|---|---|
| < 50 items | VStack / HStack (eager is fine) |
| 50-100 items | Either works; prefer Lazy if items are complex |
| > 100 items | LazyVStack / LazyHStack (required for performance) |
| Always-visible content | VStack (no benefit to lazy) |
| Scrollable lists | LazyVStack inside ScrollView, or List |
Important: Do not nest GeometryReader inside lazy containers. It forces eager measurement and defeats lazy loading. Use .onGeometryChange (iOS 18+) instead.
@Observable (Observation framework, iOS 17+) tracks property access at the per-property level. A view only re-evaluates when properties it actually read in body change:
@Observable class UserProfile {
var name: String = ""
var avatarURL: URL?
var biography: String = ""
}
// This view ONLY re-renders when `name` changes -- not when
// biography or avatarURL change, because it only reads `name`
struct NameLabel: View {
let profile: UserProfile
var body: some View {
Text(profile.name)
}
}This is a significant improvement over ObservableObject + @Published, which invalidates all observing views when any published property changes.
If a view reads many properties from an @Observable model in body, it re-renders when any of those properties change. Push reads into child views to narrow the scope:
// DON'T: reads name, email, avatar, and settings in one body
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
Text(model.name) // tracks name
Text(model.email) // tracks email
AsyncImage(url: model.avatar) // tracks avatar
SettingsForm(model.settings) // tracks settings
}
}
}
// DO: split into child views so each only tracks what it reads
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
NameRow(model: model) // only tracks name
EmailRow(model: model) // only tracks email
AvatarView(model: model) // only tracks avatar
SettingsForm(model: model) // only tracks settings
}
}
}Use computed properties on @Observable models to derive state without introducing extra stored properties that widen observation scope:
@Observable class ShoppingCart {
var items: [CartItem] = []
// Views reading `total` only re-render when `items` changes
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
}@Observable models into focused ones, or use computed properties/closures to narrow observation scope..onGeometryChange (iOS 18+) or measure outside the lazy container.Equatable, then use .animation(_:value:) for simple value-bound changes or .animation(_:body:) for narrower modifier-scoped implicit animation.id: or make items Identifiable so SwiftUI can diff efficiently instead of rebuilding the entire list.@State defeats value semantics. Use plain @State with structs.Task.detached or a custom actor.DateFormatter/NumberFormatter allocations inside bodyIdentifiable items or explicit id:@Observable models expose only the properties views actually readMainActor (image processing, parsing)GeometryReader is not inside a LazyVStack/LazyHStack/List.animation(_:value:) for value-bound changes or .animation(_:body:) for narrower modifier scope@Observable view models are @MainActor-isolated; types crossing concurrency boundaries are Sendable~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.