Dive into lazy stacks and scrolling with SwiftUI
SwiftUI & UI Frameworks 进阶 21m

深入探索 SwiftUI 的惰性堆栈与滚动

Dive into lazy stacks and scrolling with SwiftUI

2026年6月10日

在 Apple 官方观看视频

一句话判断

这场 Session 最值得关注的一件事是:Apple 终于把 Lazy Stack(惰性堆栈)的“黑盒”估算机制和预取行为扒光了给你看,明确告诉你为什么长列表滚动会掉帧,以及为什么在 onAppear 里干活是性能杀手。

这场 Session 讲了什么

平时我们用 LazyVStackLazyHStack 觉得挺省事,不用像 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 中,才能让预取真正生效,消除滑动白块。

最佳实践

已有项目迁移时,第一步是全局搜索 LazyVStackLazyHStack 内部的 onAppear。把里面的数据加载、图片解码、复杂计算全部抽离到 ViewModel 的 init 或异步任务中。第二步,检查 ForEach 内部是否存在 if/else 条件渲染。如果有,尽量将条件判断上移到数据层,在传入 ForEach 前就过滤好数据,保证 ForEach 内部是 1 对 1 的稳定视图映射。

新项目采用时,牢记 Lazy Stack 的“首项决定论”。对于 LazyHStack,它的高度由第一个子视图决定;对于 LazyVStack,它的宽度由第一个子视图决定。在设计 UI 时,确保第一个渲染的视图具有代表性的高度或宽度,或者直接给容器和子视图指定明确的 frame,避免后续视图因尺寸不一被意外裁切。

还有什么值得关注

  • 嵌套 Lazy Stack 的性能红利:在垂直的 LazyVStack 里嵌套横向的 LazyHStack(比如图片瀑布流),不仅布局合理,还能因为用户不一定会滑动横向列表而节省大量初始渲染开销。
  • 程序化滚动支持屏幕外目标:使用 ScrollPosition binding 滚动到特定 ID 时,即使目标视图还在屏幕外(未被加载),Lazy Stack 也能基于估算位置平滑滚过去,不用再做复杂的“先加载再滚动”的 Hack。
  • 自定义 Layout 的降维打击:当你发现 ForEach 里的动态子视图数量或 onGeometryChange 让程序化滚动变得极其卡顿时,Apple 暗示这时候放弃 Lazy Stack,手写一个自定义 Layout 往往是更丝滑的解法。
SwiftUI LazyVStack ScrollView Performance Layout