NSManagedObjectContextObjectsDidChange通知为何仅在托管对象被访问后触发?
NSManagedObjectContextObjectsDidChange 通知触发异常排查与解决 问题描述
我遇到了一个奇怪的问题,由于类代码过于庞大无法全部贴出,我将说明关键部分:
我有一个视图控制器,包含viewDidLoad()方法:
class MyClass { ... override func viewDidLoad() { super.viewDidLoad() // mainViewContext是AppDelegate的persistentContainer传入的viewContext NotificationCenter.default.addObserver(self, selector: #selector(test(notif:)), name: Notification.Name.NSManagedObjectContextObjectsDidChange, object: mainViewContext) } ... @objc func test(notif: Notification) { print("I got called") } ... }
我添加了一个观察者来监听CoreData对象的变更,但该通知并非总能触发。例如在添加观察者后的viewDidLoad()中执行以下代码时:
self.appDelegate.persistentContainer.performBackgroundTask({ (privateContext) in // FileBrowserElement是我创建的托管对象 let folder = privateContext.object(with: App.rootFolderObjectID) as! FileBrowserElement // 随机数确保每次名称都变化(保存时有可变更内容) folder.name = "newFolderName" + String(Int.random(between: 0, and: 90000)) do { try privateContext.save() } catch let error as NSError { print("Error: " + error.debugDescription) } })
通知可以正常触发,test(notif:)方法会被调用。但将代码替换为以下内容后:
self.appDelegate.persistentContainer.performBackgroundTask({ (privateContext) in // 这是我另一种托管对象类型 let folder = privateContext.object(with: App.templateRackObjectID) as! Rack folder.name = "newFolderName" + String(Int.random(between: 0, and: 90000)) do { try privateContext.save() } catch let error as NSError { print("Error: " + error.debugDescription) } })
通知不再触发,test(notif:)方法未被调用。我仅修改了要操作的托管对象,却出现这种差异,完全不知道原因。
(我已在AppDelegate中设置persistentContainer.viewContext.automaticallyMergesChangesFromParent = true)
难道该通知仅针对特定类型的托管对象触发?我是否遗漏了什么?我不知如何调试,已经尝试了数小时,这极其奇怪。
**更新:**我尝试用一个测试项目复现该问题,奇怪的是两种托管对象都无法触发通知。
**更新2(发现线索):**这实在太奇怪了,我的一个猜测得到了验证。
似乎该通知仅在提前加载并访问过要修改的托管对象的属性后才会触发。我在启动performBackgroundTask前添加了以下代码:
// 我添加了类属性`private var rack: Rack!`,然后在viewDidLoad的后台任务前执行 rack = mainViewContext.object(with: App.templateRackObjectID) as! Rack _ = rack.name
现在通知可以正常触发了,但这是为什么?为何必须提前访问该对象?通知应该始终触发才对。如有想法请告知。
非常感谢您的帮助 :)
问题分析与解决方案
这是个典型的CoreData上下文合并和通知触发逻辑的问题,我来帮你拆解下:
核心原因:NSManagedObjectContextObjectsDidChange 通知的触发条件
这个通知只会在当前上下文(也就是你监听的mainViewContext)中实际发生了对象变更合并时才会触发,而不是只要后台上下文保存就一定会触发。
当你在后台上下文修改并保存对象后,CoreData的父上下文(这里是persistentContainer的viewContext的父上下文,也就是NSPersistentStoreCoordinator的上下文)会接收到变更,但viewContext是否会把这些变更合并进来,并且触发通知,取决于几个点:
viewContext是否已经加载了该对象(也就是该对象存在于viewContext的缓存中)- 或者,你是否在
viewContext中注册了对该对象的监听(比如通过NSFetchedResultsController)
为什么提前访问属性就会触发通知?
当你在mainViewContext中执行rack = mainViewContext.object(with: ...)并访问rack.name时,你实际上做了两件事:
- 把
Rack对象从持久化存储加载到了mainViewContext的缓存中 - 触发了CoreData对该对象的“注册”,这样当后台上下文的变更合并到父上下文后,
viewContext会自动感知到这个对象的变化,合并变更,然后触发NSManagedObjectContextObjectsDidChange通知
而如果你没有提前加载这个对象,viewContext根本不知道这个对象的存在,所以即使后台上下文修改了它,viewContext不会主动去加载并合并这个变更,自然也就不会触发通知。
解决办法
你有几个选项来解决这个问题,根据你的需求选择:
1. 使用NSFetchedResultsController(推荐)
如果你是在UI中展示Rack对象,用NSFetchedResultsController来管理数据。它会自动注册对相关对象的监听,不管对象是否已经加载,当后台变更合并后,NSFetchedResultsController会收到通知,并且你可以通过它的代理方法更新UI,同时也会触发viewContext的NSManagedObjectContextObjectsDidChange通知。
2. 手动合并变更并触发通知
如果你不需要展示这些对象,只是需要知道有变更,可以在后台上下文保存后,手动通知viewContext去合并变更:
self.appDelegate.persistentContainer.performBackgroundTask({ (privateContext) in let folder = privateContext.object(with: App.templateRackObjectID) as! Rack folder.name = "newFolderName" + String(Int.random(between: 0, and: 90000)) do { try privateContext.save() // 手动通知主上下文合并变更 DispatchQueue.main.async { self.mainViewContext.mergeChanges(fromContextDidSave: privateContext) } } catch let error as NSError { print("Error: " + error.debugDescription) } })
不过注意,automaticallyMergesChangesFromParent = true本来应该自动处理这个,但前提是viewContext知道要合并的对象存在。手动调用mergeChanges可以强制让viewContext处理变更,即使对象还没加载。
3. 提前预加载需要监听的对象
就像你在更新2中做的那样,提前在viewContext中加载对象并访问属性,让viewContext缓存该对象,这样后续的变更就会触发通知。这种方法适合对象数量不多的场景。
4. 监听更上层的变更通知(不推荐)
如果你需要监听所有CoreData对象的变更,不管viewContext是否加载过它们,可以监听NSPersistentStoreCoordinatorStoresDidChangeNotification或者直接监听父上下文的变更,但这样会收到大量无关通知,需要自己过滤,性能可能受影响。
关于测试项目的问题
你提到测试项目中两种对象都无法触发通知,这很可能是因为测试项目中viewContext都没有加载过这两个对象,所以后台变更后,viewContext没有合并变更,自然不会触发通知。你可以在测试项目中对另一种对象也做同样的提前加载操作,应该就能看到通知触发了。
希望这些解释和方案能帮到你,要是还有疑问随时问!
内容的提问来源于stack exchange,提问作者SwiftedMind

