了解SwiftUI中的LazyVStack和LazyHStack是什么 — 惰性堆栈用于在iOS、macOS、watchOS和tvOS上高效渲染可滚动的列表、网格和轮播。与普通的VStack和HStack不同,惰性堆栈仅在元素进入可见区域时创建,这在大数据集工作时显著降低了内存消耗。惰性堆栈的架构基于Layout协议,并通过ForEach和ScrollView进行标识集成。
要点
LazyVStack和LazyHStack — SwiftUI中的布局容器,仅在需要时创建和显示子视图,即当它们在可滚动区域中变得可见时。LazyVStack垂直排列元素(从上到下),LazyHStack水平排列元素(从左到右)。
两个堆栈由Apple在SwiftUI 2.0(iOS 14、macOS 11、watchOS 7、tvOS 14)中与LazyVGrid和LazyHGrid一起引入。在惰性堆栈出现之前,开发人员被迫通过UIViewRepresentable使用UITableView和UICollectionView来高效处理大型列表。LazyVStack消除了这一需求,提供了带有自动惰性加载的原生SwiftUI接口。
根据Apple WWDC Session 10031(2020)的数据,惰性堆栈使用延迟视图创建机制:SwiftUI存储源数据(例如模型数组),并在屏幕渲染之前直接创建视图实例。滚动时,堆栈重用已创建的视图,避免新的分配 — 这减少了内存分配器和Swift垃圾回收器的负载。
要使用惰性堆栈,必须将它们放在ScrollView内部 — 没有滚动,超出屏幕边界的元素将被直接裁剪,而不是惰性创建。
LazyVStack中的惰性加载机制基于几何:SwiftUI跟踪每个子视图相对于ScrollView容器的位置。当元素穿过可见区域的边界时(带有几个点的较小缓冲区),系统调用其初始化程序并渲染内容。当元素离开屏幕时,SwiftUI会销毁该视图,但如果标记为可保留,则通过@State保留状态。
这种方法与VStack不同,在VStack中,所有子视图在容器初始化时立即创建,无论其可见性如何。对于包含10,000个元素的列表,VStack将在内存中创建10,000个视图实例,而LazyVStack — 仅创建屏幕上能容纳的实例(通常为8–15个)。
LazyVStack接受三个配置参数:alignment(HorizontalAlignment — leading、center、trailing)、spacing(CGFloat — 元素间距)和pinnedViews(PinnedScrollableViews — 固定部分标题)。LazyHStack使用相同的参数,但alignment接受VerticalAlignment(top、center、bottom)。
LazyVStack和VStack之间的主要区别 — 创建子元素的策略。VStack(急切堆栈)在渲染时计算所有子视图的大小和位置,这使其不适合大型动态列表。LazyVStack(惰性堆栈)将创建延迟到元素变得可见时。
让我们以包含1000行文本的列表为例比较行为。VStack会立即将所有1000行加载到内存中,调用每一行的初始化程序并为其分配内存。这会导致在较弱的设备(iPhone SE、iPad mini)上性能下降和屏幕启动时间增加。LazyVStack仅加载可见的10–12行,在滚动时创建其余行。
实际测试(使用Xcode Instruments,Allocations配置文件)显示:在iPhone 12 mini上,包含LazyVStack的5000个元素的列表消耗3–5 MB内存,而具有相同内容的VStack — 150–250 MB,多出50倍。LazyVStack的初始渲染时间约为~50 ms,而相同设备上VStack约为~800 ms。
为静态或短列表(最多10–15个元素)选择VStack,为任何动态或可能较长的列表选择LazyVStack。如果您不确定列表的最大大小,Apple建议默认使用LazyVStack。
VStack仍然是静态界面的最佳选择:个人资料屏幕、登录表单、产品卡 — 其中元素数量已知且不超过10–15个。VStack在首次渲染此类数量的元素时工作更快,因为它不会将资源浪费在几何跟踪和惰性加载上。此外,VStack在ScrollView外部也能正常工作(例如,在ZStack或Group内部),而LazyVStack没有ScrollView就失去了意义。
惰性堆栈最适合元素数量大或不可预测的场景:社交媒体信息流、产品目录、聊天列表、媒体文件库、事件日志、具有数千条记录的管理面板。
具体使用案例:消息应用中的消息列表(数万条消息)、图库应用中的图片轮播、具有无限加载的新闻信息流、在线商店中的订单列表。LazyHStack特别适用于水平轮播 — 如Instagram中的Stories或促销横幅。
禁忌:具有元素出现动画的界面(惰性堆栈不支持没有额外逻辑的元素删除状态之间的过渡)、所有元素应同时可见的情况(短复选框列表)以及需要精确控制单元格重用的情况(在这种情况下,List或Table可能更合适)。
基本示例以最小内存消耗显示1000个元素。关键元素:ScrollView作为滚动容器,LazyVStack用于惰性加载,ForEach带标识符用于数据迭代。
import SwiftUI
struct LazyListExample: View {
let items = Array(0..<1000)
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items, id: \.self) { index in
Text("Element #\(index)")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
}
}
代码创建一个ScrollView,内部放置了LazyVStack,元素间距为8pt。ForEach遍历items数组并为每个索引创建Text。由于惰性加载,从1000个元素中,同时只有可见的10–12个在内存中。
示例演示了将元素分组到带有固定标题的部分中,类似于iOS通讯录中的效果。Section定义标题和内容,pinnedViews: .sectionHeaders在滚动时将标题固定在屏幕顶部。
import SwiftUI
struct SectionedList: View {
let cities = ["莫斯科", "伦敦", "东京", "纽约", "巴黎"]
let countries = ["俄罗斯", "英国", "日本", "美国", "法国"]
var body: some View {
ScrollView {
LazyVStack(pinnedViews: .sectionHeaders) {
Section(header: Text("城市").font(.title).bold()) {
ForEach(cities, id: \.self) { city in
Text(city).padding(8)
}
}
Section(header: Text("国家").font(.title).bold()) {
ForEach(countries, id: \.self) { country in
Text(country).padding(8)
}
}
}
}
}
}
固定的标题(.sectionHeaders)的行为类似于UITableView中的section headers:滚动部分时,标题"粘"在屏幕的上边缘,直到整个部分消失,然后被下一个部分的标题替换。pinnedViews可以组合使用:同时使用.sectionHeaders和.sectionFooters。
LazyHStack用于水平滚动 — 图片轮播、水平类别列表。alignment: .top参数将元素对齐到上边缘。
import SwiftUI
struct HorizontalCarousel: View {
let colors: [Color] = [.red, .blue, .green, .orange, .purple, .pink]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16, alignment: .top) {
ForEach(0..<100, id: \.self) { index in
RoundedRectangle(cornerRadius: 12)
.fill(colors[index % colors.count])
.frame(width: 150, height: 200)
.overlay(Text("\(index + 1)").foregroundColor(.white).bold())
}
}
.padding(.horizontal)
}
.frame(height: 220)
}
}
代码创建一个带LazyHStack的水平ScrollView。从100个矩形中,同时显示2–3个(取决于屏幕宽度和元素大小)。向左滚动时,新元素惰性加载。容器高度固定(220pt)以避免水平滚动中的无限高度。
PinnedScrollableViews — LazyVStack和LazyHStack的配置选项,管理滚动时部分标题和页脚的固定。支持两个值:sectionHeaders(标题粘到容器开头)和sectionFooters(页脚粘到末尾)。
固定视图机制仅在嵌套在LazyVStack中的Section容器内工作。每个部分都有标题和/或页脚,自动获得粘附行为。SwiftUI跟踪每个部分相对于ScrollView边界的位置,并在部分之间转换时切换固定元素的可见性。
重要:pinnedViews增加了布局计算的复杂性,因为SwiftUI必须不断重新计算当前固定的是哪个标题。仅当功能确实需要时才使用pinnedViews — 对于没有部分的简单列表,最好省略此参数。Apple在其文档(Human Interface Guidelines,2024)中建议对字母索引和按日期分组使用固定标题。
正确使用标识符 — LazyVStack性能的最重要因素。ForEach中的每个元素必须具有稳定的唯一id。将\.self与原始类型(Int、String)一起使用是允许的,但对于数据模型,请始终实现Identifiable协议。不稳定的id(例如,每次都生成的UUID)会迫使SwiftUI在每次更新时重新创建所有视图。
避免在堆栈每个元素的body内部进行繁重计算。如果元素包含复杂的布局或数据处理 — 将逻辑移至具有自己的惰性加载的单独视图结构中。当元素数据未更改时,使用EquatableView防止不必要的重绘。
对于LazyVStack中的图像,必须使用异步加载(AsyncImage)或通过Kingfisher/Nuke进行缓存。屏幕上出现的每个元素不应同步加载图像 — 这将导致滚动卡顿(jank)。根据WWDC Session 10031的数据,预取的最佳缓冲区大小为当前位置前后3–5个屏幕。
通过Xcode Instruments的SwiftUI配置文件测量性能。注意以下指标:body评估次数、分配和帧率(FPS)。目标值:滚动时FPS > 55,单个元素的渲染时间< 1 ms。
常见问题
List提供内置功能:滑动编辑(swipeActions)、通过.onDelete删除、通过.onMove移动、分组样式.insetGrouped。LazyVStack是较低级别的工具,没有内置的编辑手势支持。List内部使用LazyVStack,但添加了原生的iOS表格样式。如果您需要自定义单元格设计并且不需要内置编辑 — 选择LazyVStack。如果您需要swipeActions、.onDelete和与@FetchRequest配合使用 — 请使用List。
惰性堆栈使用预取 — SwiftUI以小提前量(预取缓冲区)创建元素,以确保滚动平滑。缓冲区大小会根据滚动速度和设备性能自动调整。根据Apple的分析数据,预取缓冲区通常为滚动方向上的1–3个屏幕。如果您发现创建了太多不可见元素,请检查是否有每次都生成的标识符或在视图初始化程序中有繁重计算。
是的,但有局限性。将LazyVStack嵌套在VStack中没有意义 — 外部VStack会立即创建内部LazyVStack的所有元素,取消惰性加载。将VStack嵌套在LazyVStack中是允许的,不会破坏惰性机制。将LazyVStack嵌套在另一个LazyVStack中对于嵌套部分是可接受的,但要监控性能:每个级别都会增加几何跟踪的开销。
SwiftUI不为LazyVStack提供内置的分隔符。手动添加它们:在ForEach中的每个元素之后放置Divider(),或在每个元素上使用.overlay(Divider(), alignment: .bottom)修饰符。对于自定义分隔符,绘制Rectangle().frame(height: 1).foregroundColor(.gray.opacity(0.3))。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。