SwiftUI:如何为计算属性实现@State等效功能以修复导航异常?
问题背景
现有SwiftUI代码中,ServicesView使用普通存储属性elements作为List数据源时,会出现异常:当App进入非活跃状态且已展示YearView时,YearView会被弹出导航栈。将elements改为@State修饰的存储属性后,问题消失,但实际需求中elements需要是基于@FetchRequest获取的Month数据分组生成的计算属性,而@State无法修饰计算属性。
原基础代码:
struct Element: Identifiable { let id = UUID() let text: String init(text: String) { self.text = text } } struct ServicesView: View { @FetchRequest(sortDescriptors: [SortDescriptor(\.date, order: .reverse)], animation: .easeIn) private var results: FetchedResults<Month> var elements = [Element(text: "a"), Element(text: "b")] var body: some View { NavigationView { List(elements) { element in NavigationLink { YearView(months: []) } label: { Text("abc") } } .navigationTitle("Service") } } }
实际需求的分组计算属性代码:
struct Group: Identifiable { // 替代上述Element let months: [Month] var id = UUID() var descriptiveYear: String { months.first?.descriptiveYear ?? "" } init(months: [Month]) { self.months = months } } private var elements: [Group] { var groups = [[Month]]() var months = [Month]() results.forEach { month in guard months.isEmpty else { if month.currentYear == months.first?.currentYear { months.append(month) } else { groups.append(months) months = [month] } return } months.append(month) } return groups.map { Group(months: $0) } }
问题根源
普通计算属性每次View刷新都会重新执行分组逻辑,生成全新的Group数组,每个Group的UUID也会重新生成。List依赖元素的id来跟踪列表项状态,NavigationLink的导航栈状态也和列表项的id绑定。频繁变化的id会导致NavigationView认为列表项已被替换,从而重置导航栈,弹出YearView。而@State存储属性会保留数组的引用,不会每次刷新都重新生成元素,id保持稳定,因此导航栈状态正常。
解决方案
方案1:用@StateObject封装分组逻辑
创建一个ObservableObject类,负责监听results的变化,处理分组并维护稳定的groups数组,只有当分组内容真正变化时才更新,避免不必要的元素重建。
class GroupViewModel: ObservableObject { @Published var groups: [Group] = [] func updateGroups(with results: FetchedResults<Month>) { var tempGroups = [[Month]]() var currentMonths = [Month]() results.forEach { month in guard !currentMonths.isEmpty else { currentMonths.append(month) return } if month.currentYear == currentMonths.first?.currentYear { currentMonths.append(month) } else { tempGroups.append(currentMonths) currentMonths = [month] } } if !currentMonths.isEmpty { tempGroups.append(currentMonths) } // 仅当分组内容变化时更新,避免不必要的刷新 let newGroups = tempGroups.map { Group(months: $0) } if newGroups != groups { groups = newGroups } } } struct ServicesView: View { @FetchRequest(sortDescriptors: [SortDescriptor(\.date, order: .reverse)], animation: .easeIn) private var results: FetchedResults<Month> @StateObject private var groupVM = GroupViewModel() var body: some View { NavigationView { List(groupVM.groups) { group in NavigationLink { YearView(months: group.months) } label: { Text(group.descriptiveYear) } } .navigationTitle("Service") .onChange(of: results) { newResults in groupVM.updateGroups(with: newResults) } } .onAppear { groupVM.updateGroups(with: results) } } }
方案2:让Group的id基于稳定的分组标识
修改Group的id实现,用分组内的稳定特征(比如年份)作为id,而不是每次生成新的UUID。这样即使计算属性重新生成Group,同一分组的id也保持不变,NavigationLink的标识稳定。
struct Group: Identifiable { let months: [Month] // 用分组的年份作为id,确保同一分组的id稳定不变 var id: String { months.first?.currentYear ?? UUID().uuidString } var descriptiveYear: String { months.first?.descriptiveYear ?? "" } init(months: [Month]) { self.months = months } } // 原计算属性逻辑保持不变 private var elements: [Group] { var groups = [[Month]]() var months = [Month]() results.forEach { month in guard months.isEmpty else { if month.currentYear == months.first?.currentYear { months.append(month) } else { groups.append(months) months = [month] } return } months.append(month) } if !months.isEmpty { groups.append(months) } return groups.map { Group(months: $0) } }
方案3:用@Memoized缓存计算属性结果(Swift 5.9+)
如果项目使用Swift 5.9及以上版本,可以使用@Memoized宏缓存计算属性的结果,避免每次View刷新都重新执行分组逻辑,保持Group数组的稳定性。
// 需要导入必要的宏库 import SwiftSyntaxMacros import SwiftCompilerPlugin @Memoized private var elements: [Group] { var groups = [[Month]]() var months = [Month]() results.forEach { month in guard months.isEmpty else { if month.currentYear == months.first?.currentYear { months.append(month) } else { groups.append(months) months = [month] } return } months.append(month) } if !months.isEmpty { groups.append(months) } return groups.map { Group(months: $0) } }
方案对比
- 方案1:逻辑清晰,完全控制更新时机,适合复杂分组场景,唯一缺点是需要额外编写ViewModel类。
- 方案2:代码改动最小,仅修改
Group的id实现,适合分组有稳定标识的简单场景。 - 方案3:对原有代码入侵性最低,但依赖Swift版本,适合使用最新Swift环境的项目。
内容的提问来源于stack exchange,提问作者kunass2

