You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI ScrollView结合SwiftData存照片时卡顿问题求助

SwiftUI照片列表滚动卡顿问题排查与优化

问题背景

实现了一个SwiftUI照片管理视图,核心功能包括:

  • 通过PhotosPicker选图并存储到SwiftData
  • 支持长按选中照片、批量选择/删除操作
  • 使用LazyVGrid在ScrollView中展示照片网格

当照片数量较多时,ScrollView出现滚动卡顿,暂未明确触发卡顿的具体阈值。

相关代码

ContentView

struct ContentView: View {
    
    @Environment(\.modelContext) private var context
    @Query private var photoItems: [PhotoItem]
    
    static private let numberOfColumns: Int = UIDevice.current.userInterfaceIdiom == .phone ? 3 : 7
    private var columns: [GridItem] = Array(repeating: GridItem(.flexible()), count: numberOfColumns)
    
    @State private var isPhotosPickerPresented: Bool = false
    @State private var photosPickerPickedItems: [PhotosPickerItem] = []
    
    @State private var isEditing: Bool = false
    @State private var allSelected: Bool = false
    @State private var numberOfSelectedItems: Int = 0
    
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(photoItems) { photoItem in
                    PhotoItemCellView(photoItem: photoItem)
                        .listRowSeparator(.hidden)
                        .listRowBackground(Color.clear)
                        .onTapGesture(perform: {
                            if isEditing {
                                photoItem.isSelected.toggle()
                                numberOfSelectedItems += photoItem.isSelected ? 1 : -1
                                return
                            }
                            print("open photo")
                        })
                        .simultaneousGesture(
                            LongPressGesture()
                                .onEnded({ _ in
                                    photoItem.isSelected = true
                                    numberOfSelectedItems += 1
                                })
                        )
                }
            }
        }
        
        .onChange(of: numberOfSelectedItems, { oldValue, newValue in
            isEditing = newValue > 0
            allSelected = newValue == photoItems.count
        })

        .onChange(of: photoItems, { oldValue, newValue in
            numberOfSelectedItems = newValue.filter({ $0.isSelected }).count
        })
        //MARK: - Photo Picker
        .photosPicker(isPresented: $isPhotosPickerPresented, selection: $photosPickerPickedItems, matching: .any(of: [.images]), preferredItemEncoding: .compatible, photoLibrary: .shared())
        .onChange(of: photosPickerPickedItems, { oldValue, newValue in
            guard !newValue.isEmpty else {return}
            for item in newValue {
                Task {
                    if let data = try? await item.loadTransferable(type: Data.self) {
                        let photoItem = PhotoItem(data: data)
                        savePhotoItem(photoItem)
                    }
                }
            }
            photosPickerPickedItems.removeAll()
        })
        //MARK: - Toolbar
        .toolbar {
            if isEditing {
                ToolbarItem(placement: .topBarTrailing){
                    Button {
                        deleteSelectedPhotos()
                    } label: {
                        Image(systemName: "trash")
                    }
                }
                
                ToolbarItem(placement: .topBarLeading){
                    Button {
                        toggleSelection(selected: !allSelected)
                    } label: {
                        Text(allSelected ? "Deselect all" : "Select all")
                    }
                }
            }
            
            ToolbarItem(placement: .topBarTrailing){
                Button {
                    isPhotosPickerPresented = true
                } label: {
                    Image(systemName: "photo.badge.plus.fill")
                }
            }
        }
        //MARK: - Content Unavailable
        .overlay {
            if photoItems.isEmpty {
                ContentUnavailableView {
                    Label("No Photos", systemImage: "photo.badge.plus")
                } description: {
                    Text("Start adding photos & videos to see your list.")
                } actions: {
                    Button {
                        isPhotosPickerPresented = true
                    } label: {
                        Text("Add Photos & Videos")
                    }
                }
                
            }
        }
        
    }
    
    private func savePhotoItem(_ item: PhotoItem) {
        context.insert(item)
    }
    
    private func deleteSelectedPhotos() {
        photoItems.filter(\.isSelected).forEach(context.delete)
    }
    
    private func toggleSelection(selected: Bool) {
        photoItems.forEach { $0.isSelected = selected }
        numberOfSelectedItems = selected ? photoItems.count : 0
    }
}

PhotoItemCellView

struct PhotoItemCellView: View {
    
    @Bindable var photoItem: PhotoItem
    @State private var image: UIImage = UIImage(systemName: "photo.artframe")!
    
    var body: some View {
        Image(uiImage: image)
            .resizable()
            .aspectRatio(1, contentMode: .fit)
            .overlay(alignment: .bottomTrailing) {
                if photoItem.isSelected {
                    Image(systemName: "checkmark.circle.fill")
                        .resizable()
                        .frame(width: 25, height: 25)
                        .foregroundStyle(.white, .green)
                        .padding([.bottom, .trailing], 8)
                }
            }
            .onAppear {
                if let loadedImage = UIImage(data: photoItem.data) {
                    image = loadedImage
                }
            }
    }
}

PhotoItem

@Model
class PhotoItem {
    let data: Data
    @Attribute(.ephemeral) var isSelected: Bool = false
    
    init(data: Data) {
        self.data = data
    }
}

临时优化发现

移除.toolbar修饰符后,ScrollView的滚动性能得到明显提升。

进一步优化建议

  • 图片异步解码与缓存:当前在onAppear中直接从Data加载UIImage,大图片会阻塞主线程。建议使用异步解码,配合ImageRenderer处理图片加载,避免主线程卡顿。
  • 减少视图刷新:当前numberOfSelectedItems的更新可能触发不必要的视图重绘,可考虑直接从photoItems计算选中数量,避免冗余状态维护。
  • Toolbar优化:尝试将Toolbar拆分为独立视图组件,或使用ToolbarItemGroup减少条件渲染的开销,避免每次状态变化都重建Toolbar。
  • SwiftData查询优化:给@Query添加排序规则,避免数据无序导致的视图频繁更新;同时考虑分页加载大量照片,减少一次性渲染的视图数量。

内容的提问来源于stack exchange,提问作者Sameh Salama

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 11:30:21