TLDR: When working with UndoManager
from a background thread, the least complex option is to simply disable automatic grouping via groupsByEvent
and do it manually. None of the situations above will work as intended. If you really want automatic grouping in the background, you'd need to avoid GCD.
I'll add some background to explain expectations, then discuss what actually happens in each situation, based on experiments I did in an Xcode Playground.
Automatic Undo Grouping
The "Undo manager" chapter of Apple's Cocoa Application Competencies for iOS Guide states:
NSUndoManager normally creates undo groups automatically during a cycle of the run loop. The first time it is asked to record an undo operation in the cycle, it creates a new group. Then, at the end of the cycle, it closes the group. You can create additional, nested undo groups.
This behavior is easily observable in a project or Playground by registering ourself with NotificationCenter
as an observer of NSUndoManagerDidOpenUndoGroup
and NSUndoManagerDidCloseUndoGroup
. By observing these notification and printing results to the console including undoManager.levelsOfUndo
, we can see exactly what is going on with the grouping in real time.
The guide also states:
An undo manager collects all undo operations that occur within a single cycle of a run loop such as the application’s main event loop...
This language would indicate the main run loop is not the only run loop UndoManager
is capable of observing. Most likely, then, UndoManager
observes notifications that are sent on behalf of the CFRunLoop
instance that was current when the first undo operation was recorded and the group was opened.
GCD and Run Loops
Even though the general rule for run loops on Apple platforms is 'one run loop per thread', there are exceptions to this rule. Specifically, it is generally accepted that Grand Central Dispatch will not always (if ever) use standard CFRunLoop
s with its dispatch queues or their associated threads. In fact, the only dispatch queue that seems to have an associated CFRunLoop
seems to be the main queue.
Apple's Concurrency Programming Guide states:
The main dispatch queue is a globally available serial queue that executes tasks on the application’s main thread. This queue works with the application’s run loop (if one is present) to interleave the execution of queued tasks with the execution of other event sources attached to the run loop.
It makes sense that the main application thread would not always have a run loop (e.g. command line tools), but if it does, it seems it is guaranteed that GCD will coordinate with the run loop. This guarantee does not appear to be present for other dispatch queues, and there does not appear to be any public API or documented way of associated an arbitrary dispatch queue (or one of its underlying threads) with a CFRunLoop
.
This is observable by using the following code:
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))
The documentation for RunLoop.currentMode
states:
This method returns the current input mode only while the receiver is running; otherwise, it returns nil.
From this, we can deduce that Global and Custom dispatch queues don't always (if ever) have their own CFRunLoop
(which is the underlying mechanism behind RunLoop
). So, unless we are dispatching to the main queue, UndoManager
won't have an active RunLoop
to observe. This will be important for Situation 4 and beyond.
Now, let's observe each of these situations using a Playground (with PlaygroundPage.current.needsIndefiniteExecution = true
) and the notification-observing mechanism discussed above.
Situation 1: Inline on Main Thread
This is exactly how UndoManager
expects to be used (based on the documentation). Observing the undo notifications shows a single undo group being created with both undos inside.
Situation 2: Synchronous Dispatch on Main Thread
In a simple test using this situation, we get each of the undo registrations in its own group. We can therefore conclude that those two synchronously-dispatched blocks each took place in their own run loop cycle. This appears to always be the behavior dispatch sync produces on the main queue.
Situation 3: Asynchronous Dispatch on Main Thread
However, when async
is used instead, a simple test reveals the same behavior as Situation 1. It seems that because both blocks were dispatched to the main thread before either had a chance to actually be run by the run loop, the run loop performed both blocks in the same cycle. Both undo registrations were therefore placed in the same group.
Based purely on observation, this appears to introduces a subtle difference in sync
and async
. Because sync
blocks the current thread until done, the run loop must begin (and end) a cycle before returning. Of course, then, the run loop would not be able to run the other block in that same cycle because they would not have been there when the run loop started and looked for messages. With async
, however, the run loop likely didn't happen to start until both blocks were already queued, since async
returns before the work is done.
Based on this observation, we can simulate situation 2 inside situation 3 by inserting a sleep(1)
call between the two async
calls. This way, The run loop has a chance to begin its cycle before the second block is ever sent. This indeed causes two undo groups to be created.
Situation 4: Single Asynchronous Dispatch on Background Thread
This is where things get interesting. Assuming backgroundSerialDispatchQueue
is a GCD custom serial queue, a single undo group is created immediately before the first undo registration, but it is never closed. If we think about our discussion above about GCD and run loops, this makes sense. An undo group is created simply because we called registerUndo
and there was no top-level group yet. However, it was never closed because it never got a notification about the run loop ending its cycle. It never got that notification because background GCD queues don't get functional CFRunLoop
s associated with them, so UndoManager
was likely never even able to observe the run loop in the first place.
The Correct Approach
If using UndoManager
from a background thread is necessary, none of the above situations are ideal (other than the first, which does not meet the requirement of being triggered in the background). There are two options that seem to work. Both assume that UndoManager
will only be used from the same background queue/thread. After all, UndoManager
is not thread safe.
Just Don't Use Automatic Grouping
This automatic undo grouping based on run loops may easily be turned off via undoManager.groupsByEvent
. Then manual grouping may be achieved like so:
undoManager.groupsByEvent = false
backgroundSerialDispatchQueue.async {
undoManager.beginUndoGrouping() // <--
methodCausingUndoRegistration()
// Other code here
anotherMethodCausingUndoRegistration()
undoManager.endUndoGrouping() // <--
}
This works exactly as intended, placing both registrations in the same group.
Use Foundation Instead of GCD
In my production code, I intend to simply turn off automatic undo grouping and do it manually, but I did discover an alternative while investigating the behavior of UndoManager
.
We discovered earlier that UndoManager
was unable to observe custom GCD queues because they did not appear to have associated CFRunLoop
s. But what if we created our own Thread
and set up a corresponding RunLoop
instead. In theory, this should work, and the code below demonstrates:
// 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()
As expected, the output indicates that both undos are placed in the same group. UndoManager
was able to observe the cycles because the Thread
was using a RunLoop
, unlike GCD.
Honestly, though, it's probably easier to stick with GCD and use manual undo grouping.