swiftui-performance by dpearson2699/swift-ios-skills
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-performance对 SwiftUI 视图性能进行端到端的审计,从性能分析和基线建立,到根本原因分析和具体的修复步骤。
收集:
重点关注:
id 频繁变动,每次渲染都使用 )。广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
UUID()if/else 返回不同的根分支)。body 中的繁重工作(格式化、排序、图片解码)。GeometryReader、偏好链)。提供:
解释如何使用 Instruments 收集数据:
要求提供:
优先考虑可能的 SwiftUI 问题根源:
id 频繁变动,每次渲染都使用 UUID())。if/else 返回不同的根分支)。body 中的繁重工作(格式化、排序、图片解码)。GeometryReader、偏好链)。根据追踪文件/日志中的证据总结发现。
应用针对性的修复:
@State/@Observable 更靠近叶子视图)。ForEach 和列表的标识。body(预计算、缓存、@State)。equatable() 或值包装器。在代码审查时寻找这些模式。
body 中开销大的格式化器var body: some View {
let number = NumberFormatter() // 缓慢的分配
let measure = MeasurementFormatter() // 缓慢的分配
Text(measure.string(from: .init(value: meters, unit: .meters)))
}
更推荐在模型或专用辅助类中使用缓存的格式化器:
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}
var filtered: [Item] {
items.filter { $0.isEnabled } // 每次 body 求值时都会运行
}
更推荐在变更时进行预计算或缓存:
@State private var filtered: [Item] = []
// 当输入改变时更新 filtered
body 或 ForEach 中进行排序/过滤// 不要:每次 body 求值时都进行排序或过滤
ForEach(items.sorted(by: sortRule)) { item in Row(item) }
ForEach(items.filter { $0.isEnabled }) { item in Row(item) }
更推荐使用具有稳定标识的预计算、缓存集合。在输入变更时更新,而不是在 body 中。
ForEach(items, id: \.self) { item in
Row(item)
}
避免对非稳定值使用 id: \.self;使用一个稳定的 ID。
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}
更推荐使用一个稳定的基础视图,并将条件局部化到部分/修饰符(例如在 toolbar、行内容、overlay 或 disabled 内部)。这可以减少根标识的变动,帮助 SwiftUI 的差异比较保持高效。
Image(uiImage: UIImage(data: data)!)
更推荐在主线程外解码/下采样并存储结果。
@Observable class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}
更推荐使用细粒度的视图模型或每个项目的状态,以减少更新的扇出范围。
请用户重新运行相同的捕获操作,并与基线指标进行比较。如果提供了数据,总结差异(CPU、掉帧、内存峰值)。
提供:
Instruments 附带一个专用的 SwiftUI 模板(在 Xcode 15+ / Instruments 15+ 中可用)。此模板提供:
body 被求值的次数。@State、@Binding 和 @Observable 属性变更。body 计算的标准 CPU 分析器。在 SwiftUI instrument 通道中,每一行代表一个视图类型。关键信号:
body 内部有开销大的计算(格式化、排序、图片处理)。在 Debug 构建版本中添加 Self._printChanges() 来精确记录是哪个属性触发了视图更新:
var body: some View {
#if DEBUG
let _ = Self._printChanges() // 打印:"MyView: @self, _count changed."
#endif
Text("Count: \(count)")
}
在提交到 App Store 前移除 _printChanges() —— 它是一个仅用于调试的 API。
当 Time Profiler 显示视图 body 中花费了大量时间时:
NumberFormatter()、DateFormatter())、集合操作(.sorted()、.filter())或图片解码。onChange、task 或预计算的 @State 中。SwiftUI 为每个视图分配一个标识,用于追踪其生命周期、状态和动画。
body 中的调用位置来区分视图。.id(_:) 修饰符或 ForEach(items, id: \.stableID) 来分配。// 结构标识:SwiftUI 通过位置知道这些是不同的视图
VStack {
Text("First") // 位置 0
Text("Second") // 位置 1
}
当视图的标识改变时,SwiftUI 将其视为一个新视图:
@State 被重置。onAppear 再次触发。当标识保持不变时,SwiftUI 原地更新现有视图,保留状态并提供平滑过渡。
AnyView 擦除了类型信息,迫使 SwiftUI 回退到效率较低的差异比较:
// 不要:AnyView 破坏了类型标识
func makeView(for item: Item) -> AnyView {
if item.isPremium {
return AnyView(PremiumRow(item: item))
} else {
return AnyView(StandardRow(item: item))
}
}
// 要:使用 @ViewBuilder 来保留结构标识
@ViewBuilder
func makeView(for item: Item) -> some View {
if item.isPremium {
PremiumRow(item: item)
} else {
StandardRow(item: item)
}
}
AnyView 还会阻止 SwiftUI 检测哪个分支发生了变化,导致整个子树被替换,而不是进行有针对性的更新。
.id() 修饰符分配显式标识。改变其值会销毁并重新创建视图:
// 不要:UUID() 每次渲染都改变,每次都销毁并重新创建视图
ScrollView {
LazyVStack {
ForEach(items) { item in
Row(item: item)
.id(UUID()) // 严重影响性能 —— 每次渲染都使用新标识
}
}
}
// 要:使用稳定的标识符
ForEach(items) { item in
Row(item: item)
.id(item.stableID) // 仅当项目实际改变时标识才改变
}
有意的 .id() 改变对于重置状态很有用(例如,.id(selectedTab) 在切换标签页时重置滚动位置)。
懒加载堆栈只为当前屏幕上可见的项目创建视图。屏幕外的项目在滚动到视图中之前不会被求值。
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
关键行为:
onAppear 触发。onDisappear 触发,但视图仍然存活。使用懒加载网格进行多列布局:
// 自适应:尽可能多的列,满足最小宽度
let columns = [GridItem(.adaptive(minimum: 150))]
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
}
}
}
// 固定:精确数量的等宽列
let fixedColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
]
| 场景 | 使用 |
|---|---|
| < 50 个项目 | VStack / HStack(即时加载即可) |
| 50-100 个项目 | 两者皆可;如果项目复杂,更推荐 Lazy |
100 个项目 |
LazyVStack/LazyHStack(性能必需) 始终可见的内容 |VStack(懒加载无益) 可滚动列表 |ScrollView内的LazyVStack,或List
重要: 不要在懒加载容器内嵌套 GeometryReader。它会强制进行即时测量,从而破坏懒加载。请改用 .onGeometryChange (iOS 18+)。
@Observable(Observation 框架,iOS 17+)在每个属性级别追踪属性访问。只有当视图在 body 中实际读取的属性发生变化时,视图才会重新求值:
@Observable class UserProfile {
var name: String = ""
var avatarURL: URL?
var biography: String = ""
}
// 此视图仅在 `name` 改变时重新渲染 —— 而不是在
// biography 或 avatarURL 改变时,因为它只读取 `name`
struct NameLabel: View {
let profile: UserProfile
var body: some View {
Text(profile.name)
}
}
这相对于 ObservableObject + @Published 是一个重大改进,后者在任何已发布属性改变时都会使所有观察视图失效。
如果一个视图在 body 中从 @Observable 模型读取了许多属性,那么当任何这些属性改变时,它都会重新渲染。将读取操作推送到子视图中以缩小作用域:
// 不要:在一个 body 中读取 name、email、avatar 和 settings
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
Text(model.name) // 追踪 name
Text(model.email) // 追踪 email
AsyncImage(url: model.avatar) // 追踪 avatar
SettingsForm(model.settings) // 追踪 settings
}
}
}
// 要:拆分成子视图,使每个只追踪其读取的内容
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
NameRow(model: model) // 只追踪 name
EmailRow(model: model) // 只追踪 email
AvatarView(model: model) // 只追踪 avatar
SettingsForm(model: model) // 只追踪 settings
}
}
}
在 @Observable 模型上使用计算属性来派生状态,而无需引入额外的存储属性来扩大观察作用域:
@Observable class ShoppingCart {
var items: [CartItem] = []
// 读取 `total` 的视图仅在 `items` 改变时重新渲染
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
}
@Observable 模型拆分成专注的模型,或使用计算属性/闭包来缩小观察作用域。GeometryReader。 GeometryReader 强制进行即时尺寸测量,从而破坏懒加载。更推荐使用 .onGeometryChange (iOS 18+) 或在懒加载容器外部进行测量。body 内部调用 DateFormatter() 或 NumberFormatter()。 创建这些实例开销很大。将它们设为静态或移到视图外部。Equatable 协议,然后对简单的值绑定变更使用 .animation(_:value:),或对更窄的修饰符作用域内的隐式动画使用 .animation(_:body:)。List。 使用 id: 或让项目遵循 Identifiable 协议,以便 SwiftUI 能够高效地进行差异比较,而不是重建整个列表。@State 包装对象。 为了 @State 而将简单值类型包装在类中,会破坏值语义。对结构体使用普通的 @State。MainActor。 文件读取、大型负载的 JSON 解析和图片解码应在主 Actor 外进行。使用 Task.detached 或自定义 Actor。body 内部没有 DateFormatter/NumberFormatter 分配Identifiable 项目或显式的 id:@Observable 模型仅暴露视图实际读取的属性MainActor 上执行(图片处理、解析)GeometryReader 不在 LazyVStack/LazyHStack/List 内部.animation(_:value:),或对更窄的修饰符作用域使用 .animation(_:body:)@Observable 视图模型是 @MainActor 隔离的;跨越并发边界的类型是 Sendable 的references/demystify-swiftui-performance-wwdc23.mdreferences/optimizing-swiftui-performance-instruments.mdreferences/understanding-hangs-in-your-app.mdreferences/understanding-improving-swiftui-performance.md每周安装量
396
代码仓库
GitHub Stars
269
首次出现
Mar 3, 2026
安全审计
安装于
codex393
amp390
gemini-cli390
kimi-cli390
cursor390
opencode390
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 change
body 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.
Structural identity (default): determined by the view's position in the view hierarchy. SwiftUI uses the call-site location in body to distinguish views.
Explicit identity : you assign with .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 |LazyVStackinsideScrollView, orList
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.GeometryReader inside ScrollView items. GeometryReader forces eager sizing and defeats lazy loading. Prefer .onGeometryChange (iOS 18+) or measure outside the lazy container.DateFormatter() or NumberFormatter() inside body. These are expensive to create. Make them static or move them outside the view.Equatable, then use for simple value-bound changes or for narrower modifier-scoped implicit animation.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/Listreferences/demystify-swiftui-performance-wwdc23.mdreferences/optimizing-swiftui-performance-instruments.mdreferences/understanding-hangs-in-your-app.mdreferences/understanding-improving-swiftui-performance.mdWeekly Installs
396
Repository
GitHub Stars
269
First Seen
Mar 3, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex393
amp390
gemini-cli390
kimi-cli390
cursor390
opencode390
ESLint迁移到Oxlint完整指南:JavaScript/TypeScript项目性能优化工具
1,200 周安装
.animation(_:value:).animation(_:body:)List without identifiers. Use id: or make items Identifiable so SwiftUI can diff efficiently instead of rebuilding the entire list.@State wrapper objects. Wrapping a simple value type in a class for @State defeats value semantics. Use plain @State with structs.MainActor with synchronous I/O. File reads, JSON parsing of large payloads, and image decoding should happen off the main actor. Use Task.detached or a custom actor..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