UndoManager 运行 循环分组在不同的线程上下文中会受到怎样的影响?

How will UndoManager run loop grouping be affected in different threading contexts?

TLDR: 我想知道 UndoManager 基于 运行 循环的自动撤消分组在从后台线程使用时如何生效,以及我的最好的选择是这个。


我在自定义 Swift 框架中使用 UndoManager(以前称为 NSUndoManager),目标为 iOS 和 macOS。

在该框架内,大量工作发生在后台 GCD 串行队列上。我知道 UndoManager 会自动对每个 运行 循环周期的顶级注册撤消操作进行分组,但我不确定不同的线程情况会如何影响它。

我的问题:

在以下所有情况下,假设 methodCausingUndoRegistration()anotherMethodCausingUndoRegistration() 没什么特别的,并且从调用它们的线程中调用 UndoManager.registerUndo 而没有任何调度。

情况 1:在主线程上内联

// Assume this runs on main thread
methodCausingUndoRegistration()
// Other code here
anotherMethodCausingUndoRegistration()
// Also assume every other undo registration in this framework takes place inline on the main thread

我的理解:这就是 UndoManager 期望的使用方式。上面的两个撤消注册将发生在相同的 运行 循环周期中,因此被放置在相同的撤消组中。

情况二:主线程同步调度

// Assume this runs on an arbitrary background thread, possibly managed by GCD.
// It is guaranteed not to run on the main thread to prevent deadlock.
DispatchQueue.main.sync {
    methodCausingUndoRegistration()
}
// Other code here
DispatchQueue.main.sync {
    anotherMethodCausingUndoRegistration()
}

// Also assume every other undo registration in this framework takes place
// by syncing on main thread first as above

我的理解:显然,我不想在生产中使用这段代码,因为在大多数情况下同步调度不是一个好主意。但是,我怀疑根据时间考虑,这两个操作有可能被置于单独的 运行 循环中。

情况三:主线程异步调度

// Assume this runs from an unknown context. Might be the main thread, might not.
DispatchQueue.main.async {
    methodCausingUndoRegistration()
}
// Other code here
DispatchQueue.main.async {
    anotherMethodCausingUndoRegistration()
}

// Also assume every other undo registration in this framework takes place
// by asyncing on the main thread first as above

我的理解:尽管我希望它能产生与情况 1 相同的效果,但我怀疑这可能会导致与 情况 2[=64= 类似的未定义分组].

情况 4:后台线程上的单个异步调度

// Assume this runs from an unknown context. Might be the main thread, might not.
backgroundSerialDispatchQueue.async {
    methodCausingUndoRegistration()
    // Other code here
    anotherMethodCausingUndoRegistration()
}

// Also assume all other undo registrations take place
// via async on this same queue, and that undo operations
// that ought to be grouped together would be registered
// within the same async block.

我的理解:我真的希望这会像情况 1 一样起作用,只要 UndoManager 专门从同一个后台队列中使用。但是,我担心可能有一些因素导致分组未定义,特别是因为我不认为 GCD 队列(或它们的托管线程)总是(如果有的话)得到 运行 循环。

TLDR: 在后台线程中使用 UndoManager 时,最简单的选项是通过 groupsByEvent 禁用自动分组并手动执行. None 以上情况将按预期工作。如果你真的想在后台自动分组,你需要避免GCD。


我将添加一些背景来解释期望,然后根据我在 Xcode 游乐场所做的实验讨论每种情况下实际发生的情况。

自动撤消分组

Apple Cocoa Application Competencies for iOS 指南的 "Undo manager" 章节指出:

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.

通过将我们自己注册为 NotificationCenter 作为 NSUndoManagerDidOpenUndoGroupNSUndoManagerDidCloseUndoGroup 的观察者,在项目或 Playground 中很容易观察到这种行为。通过观察这些通知并将结果打印到控制台,包括 undoManager.levelsOfUndo,我们可以实时准确地了解分组的情况。

该指南还指出:

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...

这种语言表明主 运行 循环不是唯一 运行 循环 UndoManager 能够观察到的。那么,最有可能的是,UndoManager 观察到代表 CFRunLoop 实例发送的通知,该实例在记录第一个撤消操作并打开组时是当前实例。

GCD 和 运行 循环

尽管 Apple 平台上 运行 循环的一般规则是 'one run loop per thread',但此规则也有例外。具体来说,人们普遍认为 Grand Central Dispatch 不会总是(如果有的话)使用标准 CFRunLoops 及其调度队列或其关联线程。事实上,唯一似乎有关联 CFRunLoop 的调度队列似乎是主队列。

Apple 的 Concurrency Programming Guide 声明:

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.

主应用程序线程不会总是有一个 运行 循环(例如命令行工具)是有道理的,但如果有,似乎可以保证 GCD 将与 [=145] 协调=] 循环。对于其他调度队列似乎不存在此保证,并且似乎没有任何 public 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 的文档指出:

This method returns the current input mode only while the receiver is running; otherwise, it returns nil.

由此,我们可以推断出全局和自定义调度队列并不总是(如果有的话)有自己的 CFRunLoop(这是 RunLoop 背后的底层机制)。因此,除非我们分派到主队列,否则 UndoManager 不会有活动的 RunLoop 可以观察。这对于情况 4 及以后的情况很重要。


现在,让我们使用 Playground(使用 PlaygroundPage.current.needsIndefiniteExecution = true)和上面讨论的通知观察机制来观察这些情况。

情况 1:在主线程上内联

这正是 UndoManager 预期的使用方式(基于文档)。观察撤消通知显示正在创建一个撤消组,其中包含两个撤消。

情况二:主线程同步调度

在使用这种情况的简单测试中,我们在自己的组中获取每个撤消注册。因此我们可以得出结论,这两个同步调度的块每个都发生在它们自己的 运行 循环周期中。这似乎总是在主队列上产生的行为调度同步。

情况三:主线程异步调度

然而,当使用 async 时,一个简单的测试揭示了与情况 1 相同的行为。这似乎是因为两个块都在有机会实际 运行 通过 运行 循环,运行 循环在同一个循环中执行两个块。因此,两个撤消注册都放在同一组中。

纯粹根据观察,这似乎在 syncasync 中引入了细微差别。因为 sync 阻塞当前线程直到完成,所以 运行 循环必须在返回之前开始(和结束)一个循环。当然,运行 循环将无法 运行 同一循环中的另一个块,因为当 运行 循环开始并查找消息时它们不会在那里。但是,对于 async,运行 循环可能直到两个块都已经排队才开始,因为 async returns 在工作完成之前。

基于此观察,我们可以通过在两个 async 调用之间插入一个 sleep(1) 调用来模拟情况 3 中的情况 2。这样,运行 循环就有机会在发送第二个块之前开始其循环。这确实会导致创建两个撤消组。

情况 4:后台线程上的单个异步调度

这就是事情变得有趣的地方。假设 backgroundSerialDispatchQueue 是一个 GCD 自定义串行队列,在第一个撤消注册之前立即创建一个撤消组,但它永远不会关闭。如果我们考虑上面关于 GCD 和 运行 循环的讨论,这是有道理的。撤消组的创建仅仅是因为我们调用了 registerUndo 并且还没有顶级组。但是,它从未关闭,因为它从未收到有关 运行 循环结束其循环的通知。它从来没有收到通知,因为后台 GCD 队列没有获得与其关联的功能性 CFRunLoop,因此 UndoManager 可能甚至无法首先观察到 运行 循环。

正确的方法

如果需要在后台线程中使用UndoManager,上述情况中的none是最理想的(除了第一种,不满足在后台触发的要求)。有两个选项似乎有效。两者都假设 UndoManager 只会从相同的背景 queue/thread 中使用。毕竟UndoManager不是线程安全的

只是不要使用自动分组

这种基于 运行 循环的自动撤消分组可以通过 undoManager.groupsByEvent 轻松关闭。然后可以像这样实现手动分组:

undoManager.groupsByEvent = false

backgroundSerialDispatchQueue.async {
    undoManager.beginUndoGrouping() // <--
    methodCausingUndoRegistration()
    // Other code here
    anotherMethodCausingUndoRegistration()
    undoManager.endUndoGrouping() // <--
}

这完全符合预期,将两个注册放在同一个组中。

使用 Foundation 而不是 GCD

在我的生产代码中,我打算简单地关闭自动撤消分组并手动执行它,但我在调查 UndoManager.

的行为时确实发现了一个替代方案

我们早些时候发现 UndoManager 无法观察自定义 GCD 队列,因为它们似乎没有关联的 CFRunLoop。但是,如果我们创建自己的 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 并使用手动撤消分组可能更容易。