SwiftUI视图身份为何受@StateObject成员变量值影响?
在示例中,SwiftUI视图的身份依赖于其@StateObject成员变量的值,这不符合预期——如果视图未设置显式身份,应该基于类型和视图层级位置拥有结构身份。当前ItemPopupView的身份受其itemsWrapper成员影响,需要理解该问题并修改视图,让itemsWrapper不再影响视图身份。
注意:最小可复现示例中itemsWrapper这个StateObject并未实际使用,仅用于定位身份问题。
复现步骤
- 创建名为
TestApp的Core Data模型,包含Item实体,该实体有updateTime(Date类型)属性。 - 运行应用
- 点击列表中的行
- 在弹出的表单中点击“Edit”按钮
- 在打开的下一个表单中点击“Save”按钮
- 观察控制台打印
ItemPopupView: @self changed. - 注释掉
ItemPopupView的init()中的_itemsWrapper =代码行 - 重新运行应用,重复点击行、Edit、Save操作
- 观察控制台不再打印
ItemPopupView: @self changed.
原代码
import SwiftUI import CoreData @main struct TestAppApp: App { @StateObject private var manager: DataManager = DataManager() var body: some Scene { WindowGroup { ItemListContentView() .environmentObject(manager) .environment(\.managedObjectContext, manager.container.viewContext) .onAppear() { let item = Item(context: manager.container.viewContext) item.updateTime = Date() try? manager.container.viewContext.save() } } } } struct ChildContextAndObject<Object: NSManagedObject>: Identifiable { let id = UUID() let childContext: NSManagedObjectContext var childObject: Object? init(withExistingObject object: Object?, in parentContext: NSManagedObjectContext ) { self.childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) childContext.parent = parentContext self.childObject = childContext.object(with: object!.objectID) as! Object } } class DataManager: NSObject, ObservableObject { private var containerImpl: NSPersistentContainer? = nil var container: NSPersistentContainer { if containerImpl == nil { containerImpl = NSPersistentContainer(name: "TestApp") containerImpl!.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) containerImpl!.viewContext.automaticallyMergesChangesFromParent = true } return containerImpl! } } struct ItemPopupItem: Identifiable { let id = UUID() var item: Item? } struct ItemListContentView: View { @FetchRequest(sortDescriptors: []) private var items: FetchedResults<Item> @State var popupItem : ItemPopupItem? var body: some View { List { ForEach(items) { item in Text("\(item.updateTime!)") .onTapGesture { popupItem = ItemPopupItem(item: item) } } } .overlay( EmptyView() .sheet(item: $popupItem) { popupItem in ItemPopupView(popupItem: popupItem) } ) } } class ItemsWrapper : ObservableObject { public var items : [Item] = [] init() { } init(items: [Item]) { self.items = items } } struct ItemPopupView: View { @Environment(\.managedObjectContext) private var viewContext var popupItem : ItemPopupItem @StateObject var itemsWrapper = ItemsWrapper() @State public var updateOperation: ChildContextAndObject<Item>? init(popupItem : ItemPopupItem) { self.popupItem = popupItem _itemsWrapper = StateObject(wrappedValue: ItemsWrapper(items: [popupItem.item!])) // effects identity } var body: some View { let _ = Self._printChanges() VStack { Button("Edit") { updateOperation = ChildContextAndObject(withExistingObject: popupItem.item!, in: viewContext) } Text("\(popupItem.item!.updateTime!)") } .overlay( EmptyView() .sheet(item: $updateOperation) { updateOperation in EditItemView(item: updateOperation.childObject!) .environment(\.managedObjectContext, updateOperation.childContext) } ) } } struct EditItemView: View { @Environment(\.managedObjectContext) private var viewContext @Environment(\.dismiss) var dismiss @ObservedObject var item : Item var body: some View { Button("Save") { print("save") item.updateTime = Date() try? viewContext.save() try? viewContext.parent!.save() dismiss() } } }
问题原因分析
SwiftUI视图的结构身份默认由视图类型和所有存储属性的相等性共同决定。当你在init中初始化@StateObject的底层存储(_itemsWrapper)时,每次ItemPopupView初始化都会创建一个全新的ItemsWrapper实例——而ItemsWrapper是引用类型,默认的相等性判断是比较对象地址,这就导致每次创建的ItemPopupView实例,其_itemsWrapper存储的对象地址都不一样,进而让SwiftUI认为这是一个全新的视图实例,触发@self changed的打印。
当你注释掉init中的_itemsWrapper赋值时,使用的是默认的ItemsWrapper()初始化,此时所有ItemPopupView实例的itemsWrapper初始值都是空数组的ItemsWrapper,但因为@StateObject的特性,SwiftUI会为每个视图实例关联唯一的对象,不过此时视图的存储属性中,popupItem是唯一的标识,而_itemsWrapper的默认初始化不会引入额外的可变身份因素,所以视图的结构身份稳定。
解决方案
要让itemsWrapper不影响视图身份,核心是让ItemPopupView的存储属性中,_itemsWrapper不参与视图结构身份的计算。有三种可靠的实现方式:
方式1:延迟初始化ItemsWrapper的内容
移除init中的_itemsWrapper赋值,在需要使用itemsWrapper时再设置初始值:
struct ItemPopupView: View { @Environment(\.managedObjectContext) private var viewContext var popupItem : ItemPopupItem @StateObject var itemsWrapper = ItemsWrapper() @State public var updateOperation: ChildContextAndObject<Item>? // 移除自定义init中的_itemsWrapper赋值 init(popupItem : ItemPopupItem) { self.popupItem = popupItem } var body: some View { let _ = Self._printChanges() VStack { Button("Edit") { updateOperation = ChildContextAndObject(withExistingObject: popupItem.item!, in: viewContext) // 首次使用时设置itemsWrapper的内容 if itemsWrapper.items.isEmpty { itemsWrapper.items = [popupItem.item!] } } Text("\(popupItem.item!.updateTime!)") } .overlay( EmptyView() .sheet(item: $updateOperation) { updateOperation in EditItemView(item: updateOperation.childObject!) .environment(\.managedObjectContext, updateOperation.childContext) } ) } }
方式2:将ItemsWrapper改为值类型
如果ItemsWrapper不需要是ObservableObject(示例中也没用到它的发布能力),可以直接改成结构体:
// 改为结构体,值类型 struct ItemsWrapper { public var items : [Item] = [] init() { } init(items: [Item]) { self.items = items } } struct ItemPopupView: View { @Environment(\.managedObjectContext) private var viewContext var popupItem : ItemPopupItem // 改为@State,因为是值类型 @State var itemsWrapper: ItemsWrapper init(popupItem : ItemPopupItem) { self.popupItem = popupItem self._itemsWrapper = State(initialValue: ItemsWrapper(items: [popupItem.item!])) } var body: some View { let _ = Self._printChanges() VStack { Button("Edit") { updateOperation = ChildContextAndObject(withExistingObject: popupItem.item!, in: viewContext) } Text("\(popupItem.item!.updateTime!)") } .overlay( EmptyView() .sheet(item: $updateOperation) { updateOperation in EditItemView(item: updateOperation.childObject!) .environment(\.managedObjectContext, updateOperation.childContext) } ) } }
方式3:显式指定视图身份(推荐复杂场景)
如果必须保留@StateObject的引用类型,也可以通过.id()修饰符显式指定视图的身份,让SwiftUI忽略存储属性的变化:
// 在ItemListContentView中调用ItemPopupView时添加.id() .sheet(item: $popupItem) { popupItem in ItemPopupView(popupItem: popupItem) .id(popupItem.id) // 显式用popupItem的id作为视图身份 }
这样无论ItemPopupView的内部存储属性怎么变,SwiftUI都会认为是同一个视图实例,不会触发@self changed。
内容的提问来源于stack exchange,提问作者John Cashew

