如何避免视图@State变化时触发@FetchRequest重复刷新?
我实现了一个包含CoreData对象列表(通过谓词和@FetchRequest填充)与工具栏的父视图:向下滚动列表时工具栏隐藏,向上滚动时显示;点击App标题栏回到顶部时,子视图列表会在首个元素显示时将firstElementIsVisible设为true。
当前代码可正常运行,但每当父视图的hideToolBar这个@State变量变化时,即使用于生成获取谓词的ViewModel参数未改变,也会触发新的FetchRequest,有时甚至引发无限循环。请问如何阻止这些不必要的请求?或者这是SwiftUI视图的固有行为?
父视图代码
struct MainViewTabItem: View, Equatable { // Equatable用于其他用途(标识标签页) @Environment(\.horizontalSizeClass) var horizontalSizeClass @StateObject var modelTab: MainViewTabItemViewModel // 包含搜索谓词的ViewModel @State var lastHandledMainViewScrollGestureTime: Date? @State var hideToolBar: Bool = false @State var firstElementIsVisible: Bool = true var body: some View { GeometryReader {geo in ZStack(alignment: .top) { VStack{ // MARK: Toolbar if !hideToolBar { MainViewToolbarView(modelTab: modelTab) } // MARK: - List (子视图) MainListView(modelMain: MainViewModel.shared, predicate: Card.predicate(searchText: modelTab.searchText, isPinned: modelTab.filterIsPinned), sortDescriptor: CardSort(sortType: modelTab.cardSortType, sortOrder: modelTab.cardSortOrder).sortDescriptor, firstElementIsVisible: $firstElementIsVisible.onChange(firstElementIsVisibleChanged)) .simultaneousGesture(DragGesture().onChanged({ gesture in // 滚动上下时隐藏/显示工具栏 if let lastHandledMainViewScrollGestureTime = lastHandledMainViewScrollGestureTime, gesture.time == lastHandledMainViewScrollGestureTime { // 已处理过该手势,不再处理 } let isScrollingDown = gesture.location.y > gesture.predictedEndLocation.y hideToolBar = isScrollingDown })) } } } } func firstElementIsVisibleChanged(to value: Bool) { if value { hideToolBar = false } }}
子视图(列表)代码
struct MainListView: View { @StateObject var modelMain: MainViewModel @FetchRequest( entity: Card.entity(), sortDescriptors: [ NSSortDescriptor(keyPath: \Card.dateCreated, ascending: false) ] ) private var result: FetchedResults<Card> @Binding var firstElementIsVisible: Bool init(modelMain: MainViewModel, predicate: NSPredicate?, sortDescriptor: NSSortDescriptor, firstElementIsVisible: Binding<Bool>) { _modelMain = StateObject(wrappedValue: modelMain) let fetchRequest = NSFetchRequest<Card>(entityName: Card.entity().name ?? "Card") fetchRequest.sortDescriptors = [sortDescriptor] if let predicate = predicate { fetchRequest.predicate = predicate } _result = FetchRequest(fetchRequest: fetchRequest) _firstElementIsVisible = firstElementIsVisible } var body: some View {ScrollViewReader { proxy in ScrollView{ LazyVStack { ForEach(result) { (card: Card) in Text(card.idNumber.description) .onAppear{ if let firstItem = result.first, firstItem.id == card.id { firstElementIsVisible = true } } } } } } }}
原因分析
问题出在MainListView的初始化逻辑上:当父视图的hideToolBar变化时,父视图会重新计算body,导致MainListView被重新初始化。每次初始化都会创建新的FetchRequest实例,哪怕谓词和排序条件没变,这就触发了不必要的CoreData查询。
修复步骤
让
MainListView遵循Equatable,避免不必要的重绘
给MainListView添加Equatable协议,只有当关键参数(谓词、排序描述符、firstElementIsVisible)变化时,才重新创建视图。struct MainListView: View, Equatable { static func == (lhs: MainListView, rhs: MainListView) -> Bool { // 比较核心依赖:谓词、排序描述符、firstElementIsVisible状态 guard let lhsPredicate = lhs.result.fetchRequest.predicate, let rhsPredicate = rhs.result.fetchRequest.predicate else { return lhs.result.fetchRequest.predicate == rhs.result.fetchRequest.predicate } return lhsPredicate == rhsPredicate && lhs.result.fetchRequest.sortDescriptors == rhs.result.fetchRequest.sortDescriptors && lhs.firstElementIsVisible == rhs.firstElementIsVisible } // 原有的属性和代码保持不变... }在父视图中使用
EquatableView包裹MainListView
这样SwiftUI会自动对比新旧视图实例,只有在不相等时才重新初始化。// 父视图body中替换原MainListView代码 EquatableView(content: MainListView(modelMain: MainViewModel.shared, predicate: Card.predicate(searchText: modelTab.searchText, isPinned: modelTab.filterIsPinned), sortDescriptor: CardSort(sortType: modelTab.cardSortType, sortOrder: modelTab.cardSortOrder).sortDescriptor, firstElementIsVisible: $firstElementIsVisible.onChange(firstElementIsVisibleChanged)))优化手势处理的重复触发问题
原手势代码中没有更新lastHandledMainViewScrollGestureTime,会导致同一手势多次触发修改hideToolBar,补充逻辑:.simultaneousGesture(DragGesture().onChanged({ gesture in if let lastTime = lastHandledMainViewScrollGestureTime, gesture.time == lastTime { return } lastHandledMainViewScrollGestureTime = gesture.time let isScrollingDown = gesture.location.y > gesture.predictedEndLocation.y hideToolBar = isScrollingDown }))可选:将FetchRequest逻辑移至ViewModel
在MainViewModel中维护当前的NSFetchRequest,视图直接引用,避免每次初始化都重新构建请求:// MainViewModel中添加 var currentFetchRequest: NSFetchRequest<Card> { let request = NSFetchRequest<Card>(entityName: "Card") request.sortDescriptors = [CardSort(sortType: cardSortType, sortOrder: cardSortOrder).sortDescriptor] request.predicate = Card.predicate(searchText: searchText, isPinned: filterIsPinned) return request }然后修改
MainListView的初始化方法:init(modelMain: MainViewModel, firstElementIsVisible: Binding<Bool>) { _modelMain = StateObject(wrappedValue: modelMain) _result = FetchRequest(fetchRequest: modelMain.currentFetchRequest) _firstElementIsVisible = firstElementIsVisible }
总结
这种情况不是SwiftUI的固有行为,而是视图没有正确实现Equatable导致的不必要重初始化。通过让子视图遵循Equatable并使用EquatableView包裹,可以避免无意义的FetchRequest重复执行,同时解决可能的无限循环问题。
内容的提问来源于stack exchange,提问作者Sebastian

