TLDR:当UndoManager
从后台线程工作时,最简单的选择是简单地禁用自动分组groupsByEvent
并手动完成。上述情况都不会按预期工作。如果你真的想在后台自动分组,你需要避免 GCD。
我将添加一些背景来解释期望,然后根据我在 Xcode Playground 中所做的实验讨论每种情况下实际发生的情况。
自动撤消分组
Apple 的Cocoa Application Competencies for iOS指南的“撤消管理器”一章指出:
NSUndoManager 通常在运行循环的一个周期内自动创建撤消组。第一次被要求在循环中记录撤消操作时,它会创建一个新组。然后,在循环结束时,它关闭组。您可以创建额外的嵌套撤消组。
通过将我们自己注册为andNotificationCenter
的观察者,在项目或 Playground 中很容易观察到这种行为。通过观察这些通知并将结果打印到控制台,包括,我们可以实时准确地看到分组发生了什么。NSUndoManagerDidOpenUndoGroup
NSUndoManagerDidCloseUndoGroup
undoManager.levelsOfUndo
该指南还指出:
撤消管理器收集在运行循环的单个周期内发生的所有撤消操作,例如应用程序的主事件循环......
这种语言表明主运行循环不是唯一UndoManager
能够观察的运行循环。那么,最有可能的是,UndoManager
观察到代表CFRunLoop
当前实例发送的通知,该实例在记录第一个撤消操作并打开组时处于当前状态。
GCD 和运行循环
尽管 Apple 平台上运行循环的一般规则是“每个线程一个运行循环”,但这条规则也有例外。具体来说,人们普遍认为 Grand Central Dispatch 不会总是(如果有的话)将 standard CFRunLoop
s 与它的调度队列或其相关线程一起使用。事实上,唯一似乎有关联的调度队列似乎CFRunLoop
是主队列。
Apple 的并发编程指南指出:
主调度队列是一个全局可用的串行队列,它在应用程序的主线程上执行任务。此队列与应用程序的运行循环(如果存在)一起使用,以将排队任务的执行与附加到运行循环的其他事件源的执行交错。
主应用程序线程并不总是有运行循环(例如命令行工具)是有道理的,但如果有,似乎可以保证 GCD 将与运行循环协调。其他调度队列似乎不存在此保证,并且似乎没有任何公共 API 或记录方式将任意调度队列(或其底层线程之一)与CFRunLoop
.
通过使用以下代码可以观察到这一点:
DispatchQueue.main.async {
print("Main", RunLoop.current.currentMode)
}
DispatchQueue.global().async {
print("Global", RunLoop.current.currentMode)
}
DispatchQueue(label: "").async {
print("Custom", RunLoop.current.currentMode)
}
// Outputs:
// Custom nil
// Global nil
// Main Optional(__C.RunLoopMode(_rawValue: kCFRunLoopDefaultMode))
状态的文档RunLoop.currentMode
:
此方法仅在接收器运行时返回当前输入模式;否则,它返回 nil。
由此,我们可以推断出全局和自定义调度队列并不总是(如果有的话)有自己的CFRunLoop
(这是背后的底层机制RunLoop
)。因此,除非我们正在调度到主队列,UndoManager
否则不会有活动RunLoop
可观察。这对于情况 4 及以后的情况很重要。
PlaygroundPage.current.needsIndefiniteExecution = true
现在,让我们使用 Playground(带有)和上面讨论的通知观察机制来观察这些情况。
情况一:主线程内联
这正是UndoManager
预期的使用方式(基于文档)。观察撤消通知显示正在创建一个撤消组,其中包含两个撤消。
情况2:主线程同步调度
在使用这种情况的简单测试中,我们在自己的组中获取每个撤消注册。因此,我们可以得出结论,这两个同步调度的块每个都发生在它们自己的运行循环周期中。这似乎总是调度同步在主队列上产生的行为。
情况 3:主线程上的异步调度
然而,当async
被使用时,一个简单的测试揭示了与情况 1 相同的行为。似乎因为两个块在任何一个有机会被运行循环实际运行之前都被分派到主线程,所以运行循环执行了两个块在同一个周期。因此,两个撤消注册都放在同一组中。
纯粹基于观察,这似乎在sync
和中引入了细微差别async
。因为sync
阻塞当前线程直到完成,运行循环必须在返回之前开始(和结束)一个循环。当然,然后,run loop 将无法在同一个循环中运行另一个块,因为当 run loop 开始并查找消息时它们不会在那里。但是,使用async
时,运行循环可能直到两个块都已排队时才开始,因为async
在工作完成之前返回。
基于这一观察,我们可以通过sleep(1)
在两个async
调用之间插入一个调用来模拟情况 3 中的情况 2。这样,运行循环就有机会在发送第二个块之前开始其循环。这确实会导致创建两个撤消组。
情况 4:后台线程上的单个异步调度
这就是事情变得有趣的地方。假设backgroundSerialDispatchQueue
是一个 GCD 自定义串行队列,在第一次 undo 注册之前会立即创建一个 undo group,但它永远不会关闭。如果我们考虑上面关于 GCD 和运行循环的讨论,这是有道理的。创建撤消组只是因为我们调用registerUndo
并且还没有顶级组。但是,它从未关闭,因为它从未收到有关运行循环结束其循环的通知。它从未收到该通知,因为后台 GCD 队列没有CFRunLoop
与它们相关联的功能性 s,因此UndoManager
可能一开始甚至无法观察到运行循环。
正确的方法
如果UndoManager
需要从后台线程使用,上述情况都不理想(除了第一种,不满足在后台触发的要求)。有两个选项似乎有效。两者都假设UndoManager
只会从相同的后台队列/线程中使用。毕竟UndoManager
不是线程安全的。
只是不要使用自动分组
这种基于运行循环的自动撤消分组可以很容易地通过undoManager.groupsByEvent
. 然后可以像这样实现手动分组:
undoManager.groupsByEvent = false
backgroundSerialDispatchQueue.async {
undoManager.beginUndoGrouping() // <--
methodCausingUndoRegistration()
// Other code here
anotherMethodCausingUndoRegistration()
undoManager.endUndoGrouping() // <--
}
这完全符合预期,将两个注册放在同一组中。
使用 Foundation 而不是 GCD
在我的生产代码中,我打算简单地关闭自动撤消分组并手动执行,但在调查UndoManager
.
我们之前发现UndoManager
无法观察自定义 GCD 队列,因为它们似乎没有关联CFRunLoop
的 s。但是,如果我们创建自己的Thread
并设置一个对应的,那会怎样RunLoop
。理论上,这应该可行,下面的代码演示了:
// Subclass NSObject so we can use performSelector to send a block to the thread
class Worker: NSObject {
let backgroundThread: Thread
let undoManager: UndoManager
override init() {
self.undoManager = UndoManager()
// Create a Thread to run a block
self.backgroundThread = Thread {
// We need to attach the run loop to at least one source so it has a reason to run.
// This is just a dummy Mach Port
NSMachPort().schedule(in: RunLoop.current, forMode: .commonModes) // Should be added for common or default mode
// This will keep our thread running because this call won't return
RunLoop.current.run()
}
super.init()
// Start the thread running
backgroundThread.start()
// Observe undo groups
registerForNotifications()
}
func registerForNotifications() {
NotificationCenter.default.addObserver(forName: Notification.Name.NSUndoManagerDidOpenUndoGroup, object: undoManager, queue: nil) { _ in
print("opening group at level \(self.undoManager.levelsOfUndo)")
}
NotificationCenter.default.addObserver(forName: Notification.Name.NSUndoManagerDidCloseUndoGroup, object: undoManager, queue: nil) { _ in
print("closing group at level \(self.undoManager.levelsOfUndo)")
}
}
func doWorkInBackground() {
perform(#selector(Worker.doWork), on: backgroundThread, with: nil, waitUntilDone: false)
}
// This function needs to be visible to the Objc runtime
@objc func doWork() {
registerUndo()
print("working on other things...")
sleep(1)
print("working on other things...")
print("working on other things...")
registerUndo()
}
func registerUndo() {
let target = Target()
print("registering undo")
undoManager.registerUndo(withTarget: target) { _ in }
}
class Target {}
}
let worker = Worker()
worker.doWorkInBackground()
正如预期的那样,输出表明两个撤消都放在同一个组中。UndoManager
能够观察到循环,因为Thread
使用的是RunLoop
,与 GCD 不同。
不过,老实说,坚持使用 GCD 并使用手动撤消分组可能更容易。