如何将 NSUndoManager 与核心数据一起使用并保持用户界面和模型同步?
How to use NSUndoManager with coredata and keep user interace and model in sync?
核心数据开箱即用地支持 undo/redo。但它的行为出乎意料。
为了让我的用户界面与我的模型保持同步,我发送了通知。我的用户界面收到通知消息并更新受影响的视图。
@objc(Entity)
class Entity : NSManagedObject
{
var title : String? {
get {
self.willAccessValueForKey("title")
let text = self.primitiveValueForKey("title") as? String
self.didAccessValueForKey("title")
return text
}
set {
self.willChangeValueForKey("title")
self.setPrimitiveValue(newValue, forKey: "title")
self.didChangeValueForKey("title")
self.sendNotification(self, key:"title")
print("title did change: \(title)")
}
}
}
现在我想为应用程序添加 undo/redo 支持。核心数据有一个 NSUndoManager,所以我认为不需要额外的工作。或者至少不多。为了测试这个假设,我制作了一个带有两个 NSTextFields 和一个核心数据实体(恰如其分地命名为 Entity)的测试应用程序。
NSViewController 子类可以访问实体实例(恰当地命名为 testObject)。我通过 controlTextDidChange:.
观察每次击键更新 testObject
override func controlTextDidChange(obj: NSNotification)
{
guard let value = self.textField?.stringValue else { return }
self.testObject?.setValue(value, forKey: "title")
}
func valueDidChange(sender: Entity, key: String)
{
self.textField?.stringValue = sender.valueForKey("title") as? String ?? ""
}
managedObjectContent 和两个文本字段具有相同的 NSUndoManager(调试控制台中的相同指针)。
当我编辑 NSTextField 并执行 undo/redo 操作时,NSTextField 和底层 NSManagedObject 属性保持同步。不出所料。
但是当我将焦点(第一响应者)更改为第二个 NSTextField(没有任何编辑)和 undo/redo 操作时,第一个 NSTextField 被(正确)更新但底层 NSManagedObject 属性没有。 title 属性 永远不会被调用。
所以第一个 NSTextField 和 Entity 实例在 undo/redo 操作后具有不同的值。
更新底层核心数据实例而不是用户界面对我来说更有意义。这里出了什么问题?
旁注:因为我正在观察 NSManagedObject 的任何更改,并且因为 controlTextDidChange: 正在发送通知(因为它更新了 NSManagedObject),所以我收到了一个不必要的调用 valueDidChange。是否有避免这种情况的技巧或如何改进我的架构?
我做过类似的事情,我发现最有效的方法是将 UI 控制器代码(MVC 中的 C)分成两个单独的 "paths"。
通过侦听来自核心数据模型的通知来观察核心数据模型的变化 NSManagedObjectContextObjectsDidChangeNotification
如果变化影响控制器 UI 则过滤掉并相应地调整显示。 "path" 盲目地跟随 coreData 的变化,不需要与用户交互,也不需要撤消知识。
其他路径记录更改用户请求并相应地修改核心数据模型。例如,如果我们有一个步进控件和一个旁边带有数字的标签。用户点击步进器。然后控制器通过加一或减一来更新核心数据对象上的相关属性。这会使用核心数据模型自动生成撤消操作。如果用户更改影响核心数据中的多个 属性,则所有更改都包含在撤消分组中。然后对核心数据对象的这种更改将触发其他控制器路径更新所有 UI 事物(示例中的标签)。
现在撤销自动相反。通过在 MOC 撤消管理器上调用撤消,coreData 将恢复对对象的更改,这将再次触发第一个路径,并且 UI 自动跟随。
如果用户正在编辑文本字段,我通常不会费心逐键跟踪更改,而是仅在文本字段通知编辑结束时才捕获结果。使用这种方法,编辑后撤消会删除先前编辑会话中的所有更改,这通常是需要的。如果还需要在文本字段中撤消(例如,键入 aa 和 cmd-z 以撤消第二个 a),可以通过在编辑文本字段时向 window 提供另一个撤消管理器来实现 - 从而避免所有击键撤消在与核心数据操作相同的撤消堆栈中。
要记住的一件事是,coreData 有时会等待执行一些使事情看起来不同步的操作。在结束撤消分组之前在 MOC 上调用 -processPendingChanges
将解决此问题。
要考虑的另一件事是您要撤消的内容。您是否希望能够撤消用户键输入或撤消数据模型中的更改。我有时会发现两者,但不是同时发现,因此我发现多个撤消管理器很有用,如前所述。保留文档撤消管理器仅用于数据模型的更改,这是用户可能长期关心的事情。然后创建一个新的撤消管理器,并在用户处于编辑模式时使用它来跟踪各个按键。一旦用户通过离开文本字段或在对话框中按 OK 等确认他对整个编辑感到满意,就扔掉撤消管理器并获得编辑的最终结果,并使用文档将其填充到核心数据中撤销管理器。对我来说,这两种类型的撤消是根本不同的,不应在撤消堆栈中交织在一起。
下面是一些代码,首先是更改侦听器的示例(在收到 NSManagedObjectContextObjectsDidChangeNotification
:
后调用
-(void)coreDataObjectsUpdated:(NSNotification *)notif {
// Filter for relevant change dicts
NSPredicate *isSectorObject = [NSPredicate predicateWithFormat: @"className == %@", @"Sector"];
NSSet *set;
BOOL changes = NO;
set = [[notif.userInfo objectForKey:NSDeletedObjectsKey] filteredSetUsingPredicate:isSectorObject];
if (set.count > 0) {
changes = YES;
}
else {
set = [[notif.userInfo objectForKey:NSInsertedObjectsKey] filteredSetUsingPredicate:isSectorObject];
if (set.count > 0) {
changes = YES;
}
else {
set = [[notif.userInfo objectForKey:NSUpdatedObjectsKey] filteredSetUsingPredicate:isSectorObject];
if (set.count > 0) {
changes = YES;
}
}
}
if (changes) {
[self.sectorTable reloadData];
}
}
这是创建复合撤消操作的示例,编辑是在单独的 sheet 中完成的,并且此代码段将所有更改作为具有名称的单个可撤消操作移动到核心数据对象中。
-(IBAction) editCDObject:(id)sender{
NSManagedObject *stk = [self.objects objectAtIndex:self.objectTableView.clickedRow];
[self.editSheetController EditObject:stk attachToWindow:self.window completionHandler: ^(NSModalResponse returnCode){
if (returnCode == NSModalResponseOK) { // Write back the changes else do nothing
NSUndoManager *um = self.moc.undoManager;
[um beginUndoGrouping];
[um setActionName:[NSString stringWithFormat:@"Edit object"]];
stk.overrideName = self.editSheetController.overrideName;
stk.sector = self.editSheetController.sector;
[um endUndoGrouping];
}
}];
}
希望这给了一些想法。
核心数据开箱即用地支持 undo/redo。但它的行为出乎意料。
为了让我的用户界面与我的模型保持同步,我发送了通知。我的用户界面收到通知消息并更新受影响的视图。
@objc(Entity)
class Entity : NSManagedObject
{
var title : String? {
get {
self.willAccessValueForKey("title")
let text = self.primitiveValueForKey("title") as? String
self.didAccessValueForKey("title")
return text
}
set {
self.willChangeValueForKey("title")
self.setPrimitiveValue(newValue, forKey: "title")
self.didChangeValueForKey("title")
self.sendNotification(self, key:"title")
print("title did change: \(title)")
}
}
}
现在我想为应用程序添加 undo/redo 支持。核心数据有一个 NSUndoManager,所以我认为不需要额外的工作。或者至少不多。为了测试这个假设,我制作了一个带有两个 NSTextFields 和一个核心数据实体(恰如其分地命名为 Entity)的测试应用程序。
NSViewController 子类可以访问实体实例(恰当地命名为 testObject)。我通过 controlTextDidChange:.
观察每次击键更新 testObjectoverride func controlTextDidChange(obj: NSNotification)
{
guard let value = self.textField?.stringValue else { return }
self.testObject?.setValue(value, forKey: "title")
}
func valueDidChange(sender: Entity, key: String)
{
self.textField?.stringValue = sender.valueForKey("title") as? String ?? ""
}
managedObjectContent 和两个文本字段具有相同的 NSUndoManager(调试控制台中的相同指针)。
当我编辑 NSTextField 并执行 undo/redo 操作时,NSTextField 和底层 NSManagedObject 属性保持同步。不出所料。
但是当我将焦点(第一响应者)更改为第二个 NSTextField(没有任何编辑)和 undo/redo 操作时,第一个 NSTextField 被(正确)更新但底层 NSManagedObject 属性没有。 title 属性 永远不会被调用。
所以第一个 NSTextField 和 Entity 实例在 undo/redo 操作后具有不同的值。
更新底层核心数据实例而不是用户界面对我来说更有意义。这里出了什么问题?
旁注:因为我正在观察 NSManagedObject 的任何更改,并且因为 controlTextDidChange: 正在发送通知(因为它更新了 NSManagedObject),所以我收到了一个不必要的调用 valueDidChange。是否有避免这种情况的技巧或如何改进我的架构?
我做过类似的事情,我发现最有效的方法是将 UI 控制器代码(MVC 中的 C)分成两个单独的 "paths"。
通过侦听来自核心数据模型的通知来观察核心数据模型的变化 NSManagedObjectContextObjectsDidChangeNotification
如果变化影响控制器 UI 则过滤掉并相应地调整显示。 "path" 盲目地跟随 coreData 的变化,不需要与用户交互,也不需要撤消知识。
其他路径记录更改用户请求并相应地修改核心数据模型。例如,如果我们有一个步进控件和一个旁边带有数字的标签。用户点击步进器。然后控制器通过加一或减一来更新核心数据对象上的相关属性。这会使用核心数据模型自动生成撤消操作。如果用户更改影响核心数据中的多个 属性,则所有更改都包含在撤消分组中。然后对核心数据对象的这种更改将触发其他控制器路径更新所有 UI 事物(示例中的标签)。
现在撤销自动相反。通过在 MOC 撤消管理器上调用撤消,coreData 将恢复对对象的更改,这将再次触发第一个路径,并且 UI 自动跟随。
如果用户正在编辑文本字段,我通常不会费心逐键跟踪更改,而是仅在文本字段通知编辑结束时才捕获结果。使用这种方法,编辑后撤消会删除先前编辑会话中的所有更改,这通常是需要的。如果还需要在文本字段中撤消(例如,键入 aa 和 cmd-z 以撤消第二个 a),可以通过在编辑文本字段时向 window 提供另一个撤消管理器来实现 - 从而避免所有击键撤消在与核心数据操作相同的撤消堆栈中。
要记住的一件事是,coreData 有时会等待执行一些使事情看起来不同步的操作。在结束撤消分组之前在 MOC 上调用 -processPendingChanges
将解决此问题。
要考虑的另一件事是您要撤消的内容。您是否希望能够撤消用户键输入或撤消数据模型中的更改。我有时会发现两者,但不是同时发现,因此我发现多个撤消管理器很有用,如前所述。保留文档撤消管理器仅用于数据模型的更改,这是用户可能长期关心的事情。然后创建一个新的撤消管理器,并在用户处于编辑模式时使用它来跟踪各个按键。一旦用户通过离开文本字段或在对话框中按 OK 等确认他对整个编辑感到满意,就扔掉撤消管理器并获得编辑的最终结果,并使用文档将其填充到核心数据中撤销管理器。对我来说,这两种类型的撤消是根本不同的,不应在撤消堆栈中交织在一起。
下面是一些代码,首先是更改侦听器的示例(在收到 NSManagedObjectContextObjectsDidChangeNotification
:
-(void)coreDataObjectsUpdated:(NSNotification *)notif {
// Filter for relevant change dicts
NSPredicate *isSectorObject = [NSPredicate predicateWithFormat: @"className == %@", @"Sector"];
NSSet *set;
BOOL changes = NO;
set = [[notif.userInfo objectForKey:NSDeletedObjectsKey] filteredSetUsingPredicate:isSectorObject];
if (set.count > 0) {
changes = YES;
}
else {
set = [[notif.userInfo objectForKey:NSInsertedObjectsKey] filteredSetUsingPredicate:isSectorObject];
if (set.count > 0) {
changes = YES;
}
else {
set = [[notif.userInfo objectForKey:NSUpdatedObjectsKey] filteredSetUsingPredicate:isSectorObject];
if (set.count > 0) {
changes = YES;
}
}
}
if (changes) {
[self.sectorTable reloadData];
}
}
这是创建复合撤消操作的示例,编辑是在单独的 sheet 中完成的,并且此代码段将所有更改作为具有名称的单个可撤消操作移动到核心数据对象中。
-(IBAction) editCDObject:(id)sender{
NSManagedObject *stk = [self.objects objectAtIndex:self.objectTableView.clickedRow];
[self.editSheetController EditObject:stk attachToWindow:self.window completionHandler: ^(NSModalResponse returnCode){
if (returnCode == NSModalResponseOK) { // Write back the changes else do nothing
NSUndoManager *um = self.moc.undoManager;
[um beginUndoGrouping];
[um setActionName:[NSString stringWithFormat:@"Edit object"]];
stk.overrideName = self.editSheetController.overrideName;
stk.sector = self.editSheetController.sector;
[um endUndoGrouping];
}
}];
}
希望这给了一些想法。