肇鑫的技术博客

业精于勤,荒于嬉

macOS中菜单的处理,以及发送邮件

macOS中菜单的处理

基本原理

在macOS中,菜单项是通过NSResponder来进行传递的。根据的NSResponder文档

NSResponder is an abstract class that forms the basis of event and command processing in AppKit. The core classes—NSApplication, NSWindow, and NSView—inherit from NSResponder, as must any class that handles events.

也就是说,NSApplication, NSWindow, NSView都继承了NSResponder.实际使用中,还需要考虑它们的控制器,即NSWindowControllerNSViewController,以及NSApplication的代理NSApplicationDelegate。结论如下:

  1. 如果你需要同样名字的菜单在不同的情况下,有不同的结果。那么就在NSWindow, NSView或它们的控制器里实现。
  2. 如果你希望菜单的功能一致,就要在NSApplicationNSApplicationDelegate里实现。
  3. 菜单项被点击时,系统会在当前的first responder里进行查找action,找不到就到上一层responder里查找,直到找到或者全部responder查完为止。即顺序为当前视图->当前视图控制器->父视图->父视图控制器->…->当前窗口->当前窗口控制器->当前程序->当前程序代理

发送邮件

让用户通过邮件与开发者联系是常见的功能。代码如下:

//MARK: - Help Menu
extension NSApplication {
    @IBAction func contactDeveloper(_ sender: Any) {
        let mailAddress = "your email address"
        let mailBody = NSLocalizedString("Please use Chinese or English in your mail, if you can.", comment: "mail body")
        let service = NSSharingService(named: NSSharingServiceNameComposeEmail)!
        service.recipients = [mailAddress]
        service.perform(withItems: [mailBody])
    }
}

上面的代码放在AppDelegate.swift的最下面即可。打开故事板,假设你程序的菜单里Help菜单下,有一个叫“Contact Developer”的菜单项,鼠标右键点击这个菜单项,然后拖动它到First Responder,在弹出菜单中选contactDeveloper:就可以了。

menu_action

延迟处理的妙用

在某些特殊情况下,我们需要使用延迟处理技术来规避一些系统的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,这样,就不会造成多窗口的打开了。

总结

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

参考资料:

NSProgressIndicator in spinstyle

NSProgressIndicator采用spinstyle时,如果还有后续的操作,系统会优先执行后续操作,在操作结束后,才运行状态条。这是我们所不希望的。此时需要暂时停止RunLoop.main,待状态条开始运行后,再进行后续的工作。

func userDefaultsDidChange() {
    guard let controller = tableViewController else { return }
    
    let progressIndicator = { () -> NSProgressIndicator in
        let frame = view.frame
        let x = (frame.width - 50) / 2
        let y = (frame.height - 50) / 2
        let piFrame = NSMakeRect(x, y, 50, 50)
        let pi = NSProgressIndicator(frame: piFrame)
        pi.style = .spinningStyle
        
        return pi
    }()
    
    view.addSubview(progressIndicator)
    progressIndicator.startAnimation(self)
    
    RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.001))
    
    controller.run()
    
    progressIndicator.stopAnimation(self)
    progressIndicator.removeFromSuperview()
}

参考资料