如何在不同的线程上下文中影响UndoManager运行循环分组?

时间:2017-12-27 07:55:34

标签: ios swift macos grand-central-dispatch runloop

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

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

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

我的问题:

  • 以下情况会对UndoManager已注册撤消操作的运行循环分组产生什么影响?
  • 假设所有需要撤消注册的更改将在单个后台串行调度队列中进行,哪种情况(情况1除外,这是不可行的)是理想的提供自然分组?

在以下所有情况下,假设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期望使用的方式。上述两个撤消注册都将在同一个运行循环周期中进行,因此可以放在同一个撤销组中。

情况2:主线程上的同步调度

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

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

情况3:主线程上的异步调度

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

情况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队列(或它们的托管线程)总是(如果有的话)得到运行循环。

1 个答案:

答案 0 :(得分:4)

TLDR:从后台线程使用UndoManager时,最简单的选项是通过groupsByEvent禁用自动分组并手动执行。上述情况都不会按预期工作。如果您真的想在后台自动分组,则需要避免使用GCD。

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

自动撤消分组

"撤消经理" Apple的Cocoa Application Competencies for iOS指南章节:

  

NSUndoManager通常在运行循环的循环期间自动创建撤消组。第一次要求它在循环中记录撤消操作时,它会创建一个新组。然后,在周期结束时,它关闭该组。您可以创建其他嵌套撤消组。

通过NotificationCenter作为NSUndoManagerDidOpenUndoGroupNSUndoManagerDidCloseUndoGroup的观察者注册自己,可以在项目或游乐场中轻松观察到此行为。通过观察这些通知并将结果打印到包括undoManager.levelsOfUndo的控制台,我们可以实时查看分组的具体信息。

该指南还指出:

  

撤消管理器收集在运行循环的单个循环内发生的所有撤消操作,例如应用程序的主事件循环......

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

GCD和Run Loops

尽管Apple平台上的运行循环的一般规则是每个线程有一个运行循环,但此规则有例外。具体而言,人们普遍认为Grand Central Dispatch不会总是(如果有的话)使用标准CFRunLoop及其调度队列或其关联的线程。实际上,似乎只有一个关联的CFRunLoop的调度队列似乎是主队列。

Apple的Concurrency Programming Guide州:

  

主调度队列是一个全局可用的串行队列,它在应用程序的主线程上执行任务。此队列与应用程序的运行循环(如果存在)一起工作,以将排队任务的执行与附加到运行循环的其他事件源的执行交错​​。

主应用程序线程并不总是有一个运行循环(例如命令行工具),但如果确实如此,似乎可以保证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及其后的情况非常重要。

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

情况1:在主线程上内联

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

情况2:主线程上的同步调度

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

情况3:主线程上的异步调度

但是,当使用async代替时,一个简单的测试会显示与情况1相同的行为。似乎因为两个块都被分派到主线程,然后才有机会实际运行循环,运行循环在同一循环中执行两个块。因此,两个撤销注册都放在同一组中。

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

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

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

这是事情变得有趣的地方。假设backgroundSerialDispatchQueue是GCD自定义串行队列,则会在第一次撤消注册之前立即创建单个撤消组,但它永远不会关闭。如果我们考虑上面关于GCD和运行循环的讨论,这是有道理的。创建撤消组只是因为我们调用了registerUndo并且还没有顶级组。但是,它从未关闭过,因为它从未收到有关结束循环的运行循环的通知。它从来没有收到通知,因为后台GCD队列没有获得与它们相关联的功能CFRunLoop,所以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。但是,如果我们创建了自己的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()

正如预期的那样,输出表明两个undos都放在同一个组中。 UndoManager能够观察周期,因为Thread使用的是RunLoop,与GCD不同。

老实说,使用GCD并使用手动撤消分组可能更容易。