肇鑫的技术博客

业精于勤,荒于嬉

延迟处理的妙用

在某些特殊情况下,我们需要使用延迟处理技术来规避一些系统的bug或者设计不当之处。这些任务在不采用延迟处理的情形之下,一般是无法完成的。

延迟处理的方式

延迟处理可用的方式有很多种,我推荐使用以下两种方式。

Timer

open class func scheduledTimer(timeInterval ti: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool) -> Timer

@available(OSX 10.12, *)
open class func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Swift.Void) -> Timer

Timer是最简单易用的方式。缺点是10.12之前的Timer不支持block,写起来比较麻烦,需要额外占用一个实例方法。

注意
下面的示例使用的Timer,均使用非block的形式。

GCD

public func asyncAfter(deadline: DispatchTime, qos: DispatchQoS = default, flags: DispatchWorkItemFlags = default, execute work: @escaping @convention(block) () -> Swift.Void)

public func asyncAfter(wallDeadline: DispatchWallTime, qos: DispatchQoS = default, flags: DispatchWorkItemFlags = default, execute work: @escaping @convention(block) () -> Swift.Void)

public func asyncAfter(deadline: DispatchTime, execute: DispatchWorkItem)

public func asyncAfter(wallDeadline: DispatchWallTime, execute: DispatchWorkItem)

DispatchTimeDispatchWallTime的区别是,前者是系统的启动时间(不包含系统睡眠时间),后者是挂钟的时间(也就是你在系统栏里看到的当前时间)。

注意
一般情况下,TimerGCD的方式都是可以等价替换的。但是需要注意,Timer.scheduledTimer运行在RunLoop.current下,而不是main。而GCD,你可以指定是哪个队列。

延迟处理的技巧

规避UserDefaults的bug

当前的UserDefaults存在didChangeNotification在程序打开后意外发射的问题(rdar://28928098)。

考虑一下情形,在一个程序中,当你的偏好设置改变时,你希望视图控制器对应的视图也自动发生改变。一般这种情况下,需要偏好设置的控制器对于视图控制器保持一个弱的引用。当偏好发生改变时,偏好设置的控制器执行视图控制器的特定方法。特别的,如果你的程序先打开偏好设置,之后再打开一个视图控制器。此时,由于偏好设置是先打开的,而视图控制器是后打开的,必须视图控制器发送一个通知,由偏好设置的控制器接收这个通知,偏好设置的控制器才能正确的知道这个视图控制器。

但是由于上面提到的bug的存在。如果视图控制器直接发送通知,而同时系统又错误的发送了didChangeNotification通知,就会导致特定的方法会一直执行,造成程序锁死。代码示例如下:

// in AppDelegate.swift
let TableViewControllerDidAppear = NSNotification.Name("TableViewController did Appear")
// in PreferencesViewController.swift
class PreferencesViewController: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do view setup here.
        NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: UserDefaults.standard)
        NotificationCenter.default.addObserver(self, selector: #selector(tableViewDidAppear(noti:)), name: TableViewControllerDidAppear, object: nil)
    }
    
    override func viewWillDisappear() {
        super.viewWillDisappear()
        
        NotificationCenter.default.removeObserver(self, name: UserDefaults.didChangeNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: TableViewControllerDidAppear, object: nil)
    }
    
    weak var tableViewController:TableViewController?
    
    func userDefaultsDidChange() {
        guard let controller = tableViewController else { return }
        
        controller.run()
    }
    
    func tableViewDidAppear(noti:Notification) {
        if let controller = noti.object as? TableViewController {
            tableViewController = controller
        }
    }
}
// in TableViewController.swift
class TableViewController: NSViewController, NSTableViewDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do view setup here.
        run()
        NotificationCenter.default.post(name: TableViewControllerDidAppear, object: self)
    }
    
    func run() {
        // do things
    }
}

需要采用延迟的方式来发送这个通知。

Timer的方式

class TableViewController: NSViewController, NSTableViewDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do view setup here.
        run()
        Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(sendNotification), userInfo: nil, repeats: false)
    }

    func sendNotification() {
        NotificationCenter.default.post(name: TableViewControllerDidAppear, object: self)
    }
    
    func run() {
        // do things
    }
}

采用这种方式之后,即便UserDefaults发出错误的通知,但是由于它发出通知时,偏好设置控制器中弱引用的视图控制器为nil,因此不会造成锁死。

GCD的方式

class TableViewController: NSViewController, NSTableViewDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do view setup here.
        run()
        DispatchQueue.main.asyncAfter(wallDeadline: .now() + .milliseconds(100)) { [unowned self] () -> () in
            NotificationCenter.default.post(name: TableViewControllerDidAppear, object: self)
        }
    }
    
    func run() {
        // do things
    }
}

可以看出,GCD的版本更为简练。

注意
wallDeadline那里的计算方式很有趣,感兴趣的可以查一下。

规避Finder的不确定行为

大家知道我们可以使用AppDelegate

optional public func application(sender: NSApplication, openFiles filenames: [String])

方法来批量打开Finder中的文件。但是,在实际使用中,你会发现,本该在一个窗口一次打开所有选中文件,却自动分成了多批,在多个窗口里打开。

这是因为,Finder打开文件,会对文件进行分类,且这个分类和我们以为的不同,一般,我们会认为即便分类,也应该是相同的类型的分一类。但是Finder的分类不是这样的。它会根据额外的信息来分类,这个信息是根据文件的来源判断的。比如你使用Safari下载了一个txt的文本文件,Safari会自动为这个文本文件添加来源信息,例如“下载至自某某网页”之类的。而有的下载工具下载时不会添加这个信息。Finder将有和没有该信息的文件分为两组,这其实挺让人困惑的。

解决方案

var rawFilenames = [String]()
// instance counter
var timer:NSTimer! = nil

func application(sender: NSApplication, openFiles filenames: [String]) {
    if shouldQuitAppAfterConvert == nil {
        shouldQuitAppAfterConvert = true
    }
    if timer != nil {
        timer.invalidate()
    }
    self.rawFilenames += filenames
    timer = NSTimer.scheduledTimerWithTimeInterval(0.1, target: self, selector: #selector(prepareConvert), userInfo: nil, repeats: false)
}

func prepareConvert() {
    // do something
}

先设定一个Timer为延后0.1秒执行。此时如果func application(sender: NSApplication, openFiles filenames: [String])方法被再次调用,则取消之前的Timer,合并文件名,然后再设定一个新的Timer,这样,就不会造成多窗口的打开了。

总结

当系统运行代码的顺序不符合我们的预期时,可以使用延迟处理的技术来改变代码运行的顺序。

参考资料: