我在使用 UndoManager / NSUndoManager 处理异步或长时间运行的任务时遇到问题。我有一个可行的解决方案,但相当复杂 - 远远超过了对于一个相当常见的问题来说似乎合理的解决方案。我会将其作为答案发布,并希望有更好的答案。
问题1:
我的可撤消任务未在当前运行循环中完成。这样的任务可以是一个简短的操作,带有异步调用的回调。它也可以是一个长时间运行的操作,我可能会显示进度指示器,甚至提供取消选项。
问题2:
我的可撤消任务可能会失败或被取消。或者更糟糕的是,重做任务可能会失败。示例:我移动了一个文件,撤消后我发现该文件已从新位置消失。我不应该将重做任务放回到堆栈上。
想法1:
我可以在任务完成时进行撤消/重做注册。无法撤消尚未完成、已取消或已失败的操作。使用此设置,我无法将操作及其撤消操作正确配对:重做不起作用。示例:用户请求复制文件。在复制操作结束时,我向 UndoManager 注册该操作。用户选择撤消。我再次等待操作完成,然后向 UndoManager 注册。现在UndoManager并不知道刚刚完成的文件删除实际上是之前复制操作的逆操作。它不是为用户提供重做复制的选项,而是提供撤消删除的选项
想法2:
禁用自动撤消分组。我不明白如何通过长时间运行的操作来做到这一点。我希望对大多数其他任务进行自动分组。
我无法通过带有 asnyc 回调的简单操作来实现它。此抛出:“调用 endUndoGrouping 时没有匹配的开始”
let assets = PHAsset.fetchAssets(in: album, options: nil)
let parent = PHCollectionList.fetchCollectionListsContaining(album, options: nil).firstObject
if let undoManager = undoManager {
undoManager.groupsByEvent = false
undoManager.beginUndoGrouping()
let isUndoManagerOperation = undoManager.isUndoing || undoManager.isRedoing
let targetSelf = Controller.self as AnyObject
undoManager.registerUndo(withTarget: targetSelf) { [weak undoManager] targetSelf in
Controller.createAlbum(for: assets, title: album.localizedTitle, parent: parent, with: undoManager, completionHandler: nil)
}
if !isUndoManagerOperation {
undoManager.setActionName(NSLocalizedString("Delete Album", comment: "Undoable action: Delete Album"))
}
}
PHPhotoLibrary.shared().performChanges {
PHAssetCollectionChangeRequest.deleteAssetCollections(NSArray.init(object: album))
} completionHandler: { (success, error) in
DispatchQueue.main.async {
undoManager?.endUndoGrouping()
undoManager?.groupsByEvent = true
}
}
这是一个复杂的解决方案。它确实有效,但充其量只是一种创新的黑客技术。
基础知识:
我仅在长时间运行的任务完成后才向 NSUndoManager 注册。那么出现的问题是,对称撤消操作也是一个长时间运行的任务,并且也在完成后注册。 NSUndoManager 看到两个单独的操作,而不是(重新)执行/撤消对。
黑客1:
在操作开始时(初始操作或撤消操作),我检查 UndoManger 当前是否正在撤消或重做。然后它期望注册相反的操作。它期望当前的撤消操作与重做操作配对/平衡。我给它一个虚拟操作:
if (undoManager.undoing || undoManager.redoing) {
NSObject *dummy = [[NSObject alloc] init];
[undoManager registerUndoWithTarget:dummy selector:@selector(description) object:nil];
[undoManager performSelector:@selector(removeAllActionsWithTarget:)
withObject:dummy
afterDelay:0.0];
}
然后我从撤消堆栈中删除该操作。撤消堆栈现在处于合理/一致的状态。然而,我已经失去了重做我当前正在撤消的操作的能力。
黑客2:
当撤消任务完成时,我不能简单地向撤消管理器注册:任务开始时已经完成(并清除)。相反,我注册了一个任务,该任务除了再次向撤消管理器注册之外什么都不做。然后让撤消管理器撤消。想法:我假装执行原始操作,以便当撤消该操作时,我可以注册重做操作。
if (self.undoing) {
[[undoManager prepareWithInvocationTarget:[self class]] dummyTaskWithArguments:arguments];
[undoManager undo];
}
else {
[[undoManager prepareWithInvocationTarget:[self class]] taskWithArguments:arguments];
}
+ (void)dummyTaskWithArguments:(id)arguments
{
[[undoManager prepareWithInvocationTarget:[self class]] taskWithArguments:arguments];
}
由于 2021 年 11 月的解决方案不再有效,我想出了一个新的解决方案。
我现在在任务开始后立即向撤消管理器注册。同样,当操作被撤消时,我也立即向撤消管理器注册。
我创建了 HHUndoableTask 对象来跟踪操作的状态。后台任务可以检查操作是否已被取消。撤消操作可以检查任务是否已完成,从而准备撤消。
@implementation HHUndoableTask
NSString *const HHUndoableTaskRegisteredTasksKey = @"HHUndoableTask.registeredTasks";
- (NSMutableArray *)registeredTasksForUndoManager:(NSUndoManager *)undoManager
{
NSMutableArray *registeredTasks = [undoManager hh_associatedValueForKey:HHUndoableTaskRegisteredTasksKey];
if (registeredTasks == nil) {
registeredTasks = [NSMutableArray array];
[undoManager hh_setAssociatedValue:registeredTasks forKey:HHUndoableTaskRegisteredTasksKey];
}
return registeredTasks;
}
- (void)registerWithUndoManager:(NSUndoManager *)undoManager handler:(void (^)(HHUndoableTask *undoableTask))handler
{
NSMutableArray *registeredTasks = [self registeredTasksForUndoManager:undoManager];
[undoManager registerUndoWithTarget:self handler:handler];
[registeredTasks insertObject:self atIndex:0];
NSInteger levelsOfUndo = (undoManager.levelsOfUndo != 0) ? undoManager.levelsOfUndo : 100;
while ([registeredTasks count] > levelsOfUndo) {
HHUndoableTask *last = [registeredTasks lastObject];
[registeredTasks removeLastObject];
[undoManager removeAllActionsWithTarget:last];
}
}
- (void)removeFromUndoManager:(NSUndoManager *)undoManager
{
NSMutableArray *registeredTasks = [self registeredTasksForUndoManager:undoManager];
[undoManager removeAllActionsWithTarget:self];
[registeredTasks removeObject:self];
}
@end
这用于启动长时间运行的操作的代码中:
- (void)start
{
NSMutableDictionary *fileTagNames = [NSMutableDictionary dictionary];
HHUndoableTask *undoableTask = [[HHUndoableTask alloc] init];
undoableTask.data = fileTagNames;
self.undoableTask = undoableTask;
BOOL isUndoManagerOperation = undoManager.undoing || undoManager.isRedoing;
if (!isUndoManagerOperation) {
[undoManager setActionName:[NSString stringWithFormat:@"Tag %ld Files", [self.fileURLs count]]];
}
__weak NSUndoManager *weakUndoManager = undoManager;
[undoableTask registerWithUndoManager:undoManager handler:^(HHUndoableTask *undoableTask) {
NSDictionary *fileTagNames = undoableTask.data;
NSUndoManager *undoManager = weakUndoManager;
if ((![fileTagNames isKindOfClass:[NSDictionary class]]) || (undoManager == nil)) {
return;
}
undoableTask.undone = true;
if (!undoableTask.ready) {
return;
}
// TODO: Call reverse operation
}];
}
操作完成后:
- (void)stop
{
HHUndoableTask *undoableTask = self.undoableTask;
NSDictionary *fileTagNames = self.undoableTask.data;
if ([fileTagNames count] > 0) {
undoableTask.ready = !undoableTask.undone;
}
else {
dispatch_async(dispatch_get_main_queue(), ^(void) {
[undoableTask removeFromUndoManager:undoManager];
});
}
}