深入探索 SwiftUI 的惰性堆栈与滚动
Dive into lazy stacks and scrolling with SwiftUI
2026年6月10日
一句话判断
这场 Session 最值得关注的一件事是:Apple 终于把 Lazy Stack(惰性堆栈)的“黑盒”估算机制和预取行为扒光了给你看,明确告诉你为什么长列表滚动会掉帧,以及为什么在 onAppear 里干活是性能杀手。
这场 Session 讲了什么
平时我们用 LazyVStack 和 LazyHStack 觉得挺省事,不用像 List 那样受限于系统样式。但一遇到复杂信息流、动态高度或者需要代码控制滚动位置时,经常遇到滚动条乱跳、快速滑动掉帧的玄学问题。
这次 Apple 把底牌亮了:Lazy Stack 根本不计算所有子视图的大小,它靠“猜”(Estimation)。它只渲染屏幕内的视图,屏幕外的高度是基于已渲染视图的平均值估算出来的。同时,为了避免快速滑动卡顿,它引入了预取(Prefetching)机制,在视图上屏前提前做部分渲染。
这对做图文信息流、电商商品列表或者任何需要精准控制滚动的开发者影响最大。搞懂了这套机制,你就能明白为什么你的列表越滚越卡,以及怎么通过调整状态管理来榨干 SwiftUI 的性能。
值得深挖的点
估算机制的代价:为什么你的滚动条会乱跳?
Lazy Stack 最大的 trade-off 就是用“估算”换取“初始化速度”。因为不加载屏幕外的视图,它不知道总高度,只能根据前面渲染过的视图平均高度来猜。当你滚到列表底部,或者屏幕旋转导致视图高度变化时,真实高度和估算高度产生偏差,Lazy Stack 就会在滚动过程中动态修正 content offset(内容偏移量)。这就是为什么有时候你往下滚,列表会突然往上“跳”一下。
Apple 给出的解法很务实:尽量让子视图高度可预测。如果你在 ForEach 里用 if 条件判断来决定渲染什么视图,或者叶子节点的高度随数据动态剧烈变化,Lazy Stack 的估算就会彻底崩溃。对于横向滚动的 LazyHStack,它甚至不知道最大高度是多少,只能拿第一个子视图的高度当标准。遇到这种场景,别指望 Lazy Stack 能自动完美对齐,老老实实给子视图设定固定高度,或者在数据模型层提前计算好尺寸,才是正道。
预取机制与 onAppear 的致命冲突
这是全场最值钱的避坑指南。很多同学习惯在 onAppear 里发网络请求、解析数据或者做复杂的 UI 初始化。但在 Lazy Stack 里,这是个巨大的性能陷阱。
Lazy Stack 有预取机制,会在子视图即将滑入屏幕前,提前进行部分渲染工作(Partial render work)以避免上屏瞬间的掉帧(Hitches)。如果你把重逻辑放在 onAppear 里,预取就白做了,因为 onAppear 只有在视图真正上屏时才会触发。结果就是,预取阶段视图是个空壳,上屏瞬间 onAppear 触发,主线程被阻塞,直接掉帧。Apple 明确建议:把视图的设置工作移到 init 里,把需要持久化的状态移到外部的 Model 对象或 Binding 中。视图 struct 只是无情的渲染机器,别让它承担生命周期管理的重任。
代码片段
1. 抛弃绝对偏移,拥抱可见性追踪
场景:判断是否需要显示“回到顶部”或“跳转到特定区域”的悬浮按钮。
struct ContentView: View {
@State private var isScrollToShowcaseVisible = false
var body: some View {
ScrollView {
LazyVStack {
ForEach(steps) { step in
StepView(step: step)
}
Showcase()
}
}
// 监听特定 ID 的视图可见性变化,而不是死磕 Y 轴绝对坐标
.onScrollTargetVisibilityChange(
idType: Step.ID.self,
threshold: 0.8 // 80% 可见时触发
) { visibleIDs in
// 根据当前可见的 ID 集合决定 UI 状态
isScrollToShowcaseVisible = shouldShowButton(visibleIDs: visibleIDs)
}
}
}
坑:千万别用 onScrollGeometryChange 去监听 contentOffset.y <= 100 这种绝对数值。Lazy Stack 的总高度和 offset 是动态估算的,绝对数值在列表动态增删或高度变化时会完全失效。
2. 状态外置与初始化前置
场景:在列表项加载时准备数据,避免上屏卡顿。
struct StepView: View {
let step: Step
// 将复杂计算或状态放在外部 Model 中
@StateObject private var viewModel: StepViewModel
// 在 init 中完成基础设置,而不是等 onAppear
init(step: Step) {
self.step = step
// 预取阶段就会执行 init,提前准备好轻量级数据
_viewModel = StateObject(wrappedValue: StepViewModel(step: step))
}
var body: some View {
VStack {
// 渲染逻辑
Text(viewModel.title)
}
// 仅用于触发不可中断的副作用(如埋点),绝不用于数据准备
.onAppear {
viewModel.trackImpression()
}
}
}
坑:在 onAppear 里做数据解析或网络请求。Lazy Stack 的预取机制会在视图上屏前调用 init 并做部分渲染,把重活放在 init 或外部 Model 中,才能让预取真正生效,消除滑动白块。
最佳实践
已有项目迁移时,第一步是全局搜索 LazyVStack 和 LazyHStack 内部的 onAppear。把里面的数据加载、图片解码、复杂计算全部抽离到 ViewModel 的 init 或异步任务中。第二步,检查 ForEach 内部是否存在 if/else 条件渲染。如果有,尽量将条件判断上移到数据层,在传入 ForEach 前就过滤好数据,保证 ForEach 内部是 1 对 1 的稳定视图映射。
新项目采用时,牢记 Lazy Stack 的“首项决定论”。对于 LazyHStack,它的高度由第一个子视图决定;对于 LazyVStack,它的宽度由第一个子视图决定。在设计 UI 时,确保第一个渲染的视图具有代表性的高度或宽度,或者直接给容器和子视图指定明确的 frame,避免后续视图因尺寸不一被意外裁切。
还有什么值得关注
- 嵌套 Lazy Stack 的性能红利:在垂直的
LazyVStack里嵌套横向的LazyHStack(比如图片瀑布流),不仅布局合理,还能因为用户不一定会滑动横向列表而节省大量初始渲染开销。 - 程序化滚动支持屏幕外目标:使用
ScrollPositionbinding 滚动到特定 ID 时,即使目标视图还在屏幕外(未被加载),Lazy Stack 也能基于估算位置平滑滚过去,不用再做复杂的“先加载再滚动”的 Hack。 - 自定义 Layout 的降维打击:当你发现
ForEach里的动态子视图数量或onGeometryChange让程序化滚动变得极其卡顿时,Apple 暗示这时候放弃 Lazy Stack,手写一个自定义Layout往往是更丝滑的解法。