SwiftUI水平ScrollView滚动添加子视图的性能优化问题
问题分析与优化方案
针对你遇到的水平无限滚动追加数据时的跳帧问题,以下是具体的排查思路和优化方向:
1. 替换基于索引范围的ForEach,改用实际数据数组
你当前用0...fetcher.lastIndexToShow(section)作为ForEach数据源,且以索引作为id。这种方式会导致每次lastIndexToShow变化时,SwiftUI需要重新计算整个序列的视图差异,哪怕只是新增一个元素。改用实际的items数组作为数据源,结合Identifiable协议让SwiftUI精准识别新增项:
// 先确保你的Game类型遵循Identifiable struct Game: Identifiable { let id: String // 替换为你的唯一标识字段 // 其他属性 } // 修改ScrollView代码 ScrollView(.horizontal) { LazyHStack(spacing: 10) { ForEach(fetcher.items[section] ?? []) { item in Group { if let index = fetcher.items[section]?.firstIndex(of: item), index < 30 { ItemCard(item: item) } else { Rectangle() .frame(width: 400, height: 220) .opacity(1) .padding(10) } } .onAppear { // 仅在当前项是倒数第5个时触发加载,避免频繁请求 guard let items = fetcher.items[section], let currentIndex = items.firstIndex(of: item), currentIndex >= items.count - 5, !fetcher.isLoading[section] else { return } try? fetcher.fetchNewPageIfNeeded(section: section, index: currentIndex) } } // 添加加载状态占位,替代透明矩形填充 if fetcher.isLoading[section] == true { ProgressView() .frame(width: 400, height: 220) } } }
2. 批量加载数据,减少Published变量更新频率
你当前每次仅追加单个元素(append(contentsOf: [res.data[0]])),会导致items频繁触发Published更新,每次更新都会触发视图重绘。改成批量加载整页数据,大幅减少更新次数:
// 在Fetcher中添加加载状态标记 class Fetcher: ObservableObject { @Published var items: [String: [Game]] = [:] @Published var isLoading: [String: Bool] = [:] // 其他属性和方法 } // 修改fetchNextPage方法 func fetchNextPage(section: String) throws { // 避免重复请求 guard !fetcher.isLoading[section] else { return } fetcher.isLoading[section] = true Task { @MainActor in do { guard let res: ApiResponse<Game> = try await fetch(url: url) else { fetcher.isLoading[section] = false return } if !res.data.isEmpty { // 批量追加整页数据 items[section]?.append(contentsOf: res.data) } fetcher.isLoading[section] = false } catch { fetcher.isLoading[section] = false // 这里可以添加错误处理逻辑 } } }
3. 缩小ObservableObject的更新粒度
当前items是字典类型,某个section的数组更新时,SwiftUI会认为整个字典发生变化,可能触发不必要的父视图重绘。可以将每个section的数据单独包装成ObservableObject,缩小重绘范围:
// 单个section的数据模型 class SectionData: ObservableObject { @Published var items: [Game] = [] @Published var isLoading = false func fetchNextPage(url: URL) async throws { guard !isLoading else { return } isLoading = true defer { isLoading = false } guard let res: ApiResponse<Game> = try await fetch(url: url) else { return } if !res.data.isEmpty { items.append(contentsOf: res.data) } } } // 修改Fetcher class Fetcher: ObservableObject { @Published var sections: [String: SectionData] = [:] func sectionData(for section: String) -> SectionData { if let existing = sections[section] { return existing } let newData = SectionData() sections[section] = newData return newData } } // 使用示例 let sectionData = fetcher.sectionData(for: section) ScrollView(.horizontal) { LazyHStack(spacing: 10) { ForEach(sectionData.items) { item in Group { if let index = sectionData.items.firstIndex(of: item), index < 30 { ItemCard(item: item) } else { Rectangle() .frame(width: 400, height: 220) .opacity(1) .padding(10) } } .onAppear { guard let index = sectionData.items.firstIndex(of: item), index >= sectionData.items.count - 5, !sectionData.isLoading else { return } Task { try? await sectionData.fetchNextPage(url: yourTargetUrl) } } } if sectionData.isLoading { ProgressView() .frame(width: 400, height: 220) } } }
4. 移除冗余视图层级与任务触发
你当前给每个索引视图都添加了.task修饰符,滚动时会触发大量并发任务,增加主线程压力。改用.onAppear仅在特定条件下触发加载,同时移除不必要的ZStack(直接用Group包裹条件视图即可):
// 简化后的视图结构 ForEach(sectionData.items) { item in Group { // 条件渲染逻辑 } .onAppear { // 加载触发逻辑 } }
5. 手动维护滚动偏移(可选)
如果以上优化后仍有跳帧,可能是SwiftUI更新视图时重新计算了ScrollView内容尺寸导致偏移变化。可以通过GeometryReader和PreferenceKey手动保存并恢复滚动偏移:
@State private var scrollOffset: CGFloat = 0 // 定义偏好键 struct ScrollOffsetKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} } // 使用示例 ScrollView(.horizontal) { LazyHStack(spacing: 10) { // 内容视图 } .background( GeometryReader { proxy in Color.clear .preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("horizontalScroll")).minX) } ) } .coordinateSpace(name: "horizontalScroll") .onPreferenceChange(ScrollOffsetKey.self) { offset in scrollOffset = offset } .onReceive(sectionData.$items) { _ in // 数据更新后恢复偏移 DispatchQueue.main.async { // 结合ScrollViewReader实现精准滚动恢复 // scrollViewProxy.scrollTo(...) } }
内容的提问来源于stack exchange,提问作者ssantos
相关产品推荐
相关产品推荐

