记一个SwiftUI中LazyVStack包裹大数量View卡死的问题。
LazyVStack是一个懒加载的组件实现,是不是懒加载就可以展示大量的View呢?给个实践的结论, 在环境xcode14.3.1, iPhone 14 pro真机和模拟器里测试了,LazyVStack在包裹1000个View还好,超过2500个就卡的像老黄牛一样了。
设计思路
在尝试用AI辅助我写一个类抖音的APP(一)中探索了AI不能一下把APP的所有功能实现出来。 在尝试用AI辅助我写一个类抖音的APP(二)中探索和实现了核心的滑动翻页部分。
这一期我们探索一下,从后端加载的待滑动列表是什么样的方式。因为已有一些现成的展示列表,我们前期做简单点,不做千人千面。那能不能一下把一个大的展示列表例如1500个给到APP展示呢?自然而然就想到用到SwiftUI的懒加载LazyVStack。那么,开始吧。
首次遇到问题
一开始是用下面的代码在调试的时候,卡住了。还不清楚什么原因,于是去问问ChatGPT。
SwiftUI中,当skins.list为很大的数组时例如10000个元素,会初始化10000个SuitDetailView,有什么优化方案吗
struct ContentView: View {
@State private var currentPage = 0
@GestureState private var translation: CGFloat = 0
@StateObject var skins : SuitListModel = SuitListModel(sex: .boy)
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(skins.list, id: \.uuid) { s in
SuitDetailView(suit: s)//.environment(\.colorScheme, .dark)
.environmentObject(ImageModel(img: s.img, armpx: s.armpx))
.frame(height: screenHeight)
}
}}}}
ChatGPT4给了我答案,说是LazyVStack没有性能问题,指出是我的ImageModel会导致问题。
在SwiftUI中,LazyVStack已经是一个延迟加载的视图,它只会在元素即将出现在屏幕上时创建对应的视图。所以即使你的数组有10000个元素,也不会立即创建10000个SuitDetailView,只有当用户滚动到对应的位置时,才会创建对应的视图。
但是,你的代码中有一个可能的性能问题,即对每个SuitDetailView,你都创建了一个新的ImageModel。如果ImageModel的初始化过程很耗资源(例如,它需要从网络加载图片),这可能会导致性能下降。
为了解决这个问题,你可以考虑将ImageModel的创建移到SuitDetailView的内部,并使用@StateObject来管理它。这样,ImageModel只会在对应的SuitDetailView被创建时创建,而且在SuitDetailView的整个生命周期内,ImageModel会被保留下来。
这是一个修改后的示例:
struct SuitDetailView: View {
var suit: Suit
@StateObject var imageModel: ImageModel
init(suit: Suit) {
self.suit = suit
_imageModel = StateObject(wrappedValue: ImageModel(img: suit.img, armpx: suit.armpx))
}
var body: some View {
// your view code
}
}
在这个示例中,ImageModel只会在SuitDetailView被创建时创建,并且在SuitDetailView的生命周期内被保留。这样可以避免在滚动过程中不必要地创建和销毁ImageModel,从而提高性能。
排除ImageModel影响,直击LazyVStack
为了验证是不是ChatGPT说的那样,我把ImageModel去掉了。 我用这样的代码跑了一下
struct Skin3DView: View {
init(){
mtlog.info("Skin3DView init()")
}
...
}
struct ContentView: View {
@State private var currentPage = 0
@GestureState private var translation: CGFloat = 0
@StateObject var skins : SuitListModel = SuitListModel(sex: .boy)
var body: some View {
GeometryReader { geometry in
let screenHeight = geometry.size.height
let threshold = screenHeight * 0.1 //这里把0.2改成0.1更顺畅了
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(skins.list, id: \.uuid) { s in
Skin3DView()
}
}
...
}
skins.list有1815个item 显示如下
。。。
2023-08-17 17:44:52.204534+0800 tikmc[27402:2476013] [tikmc] Skin3DView init()
2023-08-17 17:44:52.204541+0800 tikmc[27402:2476013] [tikmc] Skin3DView init()
2023-08-17 17:44:52.204548+0800 tikmc[27402:2476013] [tikmc] Skin3DView init()
2023-08-17 17:44:52.204555+0800 tikmc[27402:2476013] [tikmc] Skin3DView init()
2023-08-17 17:44:52.204562+0800 tikmc[27402:2476013] [tikmc] Skin3DView init()
2023-08-17 17:44:52.348506+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
2023-08-17 17:44:52.348529+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
2023-08-17 17:44:52.348539+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
2023-08-17 17:44:52.348550+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
2023-08-17 17:44:52.348558+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
2023-08-17 17:44:52.348567+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
2023-08-17 17:44:52.348575+0800 tikmc[27402:2476013] [WindowServer] CADisplayTimingsControl too many requests.
。。。
上面的Skin3DView init() 有5445个, 除了后面reload skins.list占1815个, 前面有3630个init()。 也就是说,LazyVStack不会真的做到只对即将进入可视范围的View再init创建,看这个结果它是对列表里所有的View都调用了init。
运行的时候直接卡死了,滑动不了。后来发现不能滑动的原因是因为 Skin3DView里加载了3d模型, 经过测试同时最大值为32个Skin3DView,可以理解为32个SceneView
。
通过这部分测试也可以验证了LazyVStack并没有像预期的那样按需创建View里的内容。
真实的LazyVStack测试
LazyVStack中多少个View | 打印的View init次数 | 滑动一次打印的View init次数 | 14 pro真机性能 |
---|---|---|---|
1000 | 2000 | 2000 | 流畅,约30fps |
2500 | 5000 | 5000 | 明显卡顿,3fps |
5000 | 10000 | 10000 | 明显卡顿,1fps |
10000 | 20000 | 20000 | 明显卡顿,1fps |
100000 | 内存爆了 | 内存爆了 | 内存爆了 |
上面的结果让我很费解,不是说LazyVStack可以按需加载包裹在里面的View吗?有文章说SwiftUI的结构体View很轻量,创建结构体init了,不一定会获取body的值。试试代码验证一下。
//ContentView中的body
var body: some View {
GeometryReader { geometry in
let screenHeight = geometry.size.height
let threshold = screenHeight * 0.1 //这里把0.2改成0.1更顺畅了
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(1..<1000){ i in
Skin3DView(id: i)
.frame(height: screenHeight)
}
}
}
//Skin3DView代码
struct Skin3DView: View {
var id: Int
init(id: Int){
self.id = id
mtlog.info("Skin3DView init() \(id)")
}
var body: some View {
ZStack{
let _ = mtlog.info("Skin3DView body \(id)")
//添加背景,颜色随机
Rectangle()
.foregroundColor(Color.randomColor())
.edgesIgnoringSafeArea(.all)
}
}
}
结果让我大跌眼镜,完完整整地构造了一个View的全部。以下是打印的log。
2023-08-19 11:40:00.557758+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 1
2023-08-19 11:40:00.559627+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 1
2023-08-19 11:40:00.559707+0800 tikmc[83042:6996519] [tikmc] Skin3DView body 1
2023-08-19 11:40:00.578620+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 2
2023-08-19 11:40:00.578934+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 2
2023-08-19 11:40:00.579005+0800 tikmc[83042:6996519] [tikmc] Skin3DView body 2
。。。省略约3000行
2023-08-19 11:40:00.920815+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 998
2023-08-19 11:40:00.920917+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 998
2023-08-19 11:40:00.920956+0800 tikmc[83042:6996519] [tikmc] Skin3DView body 998
2023-08-19 11:40:00.921077+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 999
2023-08-19 11:40:00.921191+0800 tikmc[83042:6996519] [tikmc] Skin3DView init() 999
2023-08-19 11:40:00.921229+0800 tikmc[83042:6996519] [tikmc] Skin3DView body 999
这么看来LazyVStack和VStack在初始化包裹的View方面没什么区别啊。代码里把LazyVStack换成VStack试了一下。 VStack是1000个从头到尾init了一遍,再init第二遍, 然后再调佣body。而LazyVStack是1000个从头到尾一次两个init接着一个body。 VStack和LazyVStack滑动页面的时候都不会调用body, 但init调用跟上面一样,init两次,方式也是有区分。
结论
所以双向绑定的用法只是让我们更好地操作和展示数据,但并不能处理特别大的数据列表,会有性能问题。 那么处理这种大的数据列表,还是得编码实现这里的取数展示逻辑。
例如有10000个数据要展示,代码逻辑里不能将10000个数据全塞到填充LazyVStack的list里,而是先塞30个,等用户滑动到第25个,则再加载下一批30个,添加到list里。这样list会慢慢增加,不会一下增加便不会再初始化时耗时太久。另外要解决list达到一定数量时,滑动卡顿问题,这个放到下一期探索吧。