肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

"Closure cannot implicitly capture a mutating self parameter"问题的处理

Swift

struct中,如果我们在closure中使用self,就会得到Closure cannot implicitly capture a mutating self parameter的错误提示。比如:

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let closure = {
            self.bar = 50 // Closure cannot implicitly capture a mutating self parameter
        }
        
        closure()
    }
}

并且由于Foo的类型是struct,我们也没发在closure里添加截获列表。那么是不是就必须使用class了?答案是否定的。有两种方式可以解决这个问题。

方案一:为closure增加一个inout类型的参数

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let closure = { (s:inout Foo) -> () in
            s.bar = 50
        }
        
        closure(&self)
    }
}

根据inout类型的说明,我们知道,实际上这相当于增加了一个隐藏的临时变量,self被复制,然后在closure中使用,完成后,再复制回self。也就是说,这个方法有额外的内存开销。如果是struct较大的情形,这么做并不划算。

方案二:使用UnsafeMutablePointer<Pointee>

这次采用直接指针的方式对于struct来进行操作,采用指针的好处是self不会被多次复制,性能较高。缺点是你需要自行确定你的代码的安全。

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let selfPointer = UnsafeMutablePointer(&self)
        
        let closure = {
            selfPointer.pointee.bar = 50
        }
        
        closure()
    }
}

结论

Closure cannot implicitly capture a mutating self parameter错误的原因是在进出closure之后,self的一致性没办法得到保证,所以编译器默认不允许在structclosure中使用self。如果我们确定这么做是安全的,就可以通过上面的两种方式解决这个问题。其中,方法二的性能更好一些。

struct Foo {
    var bar = 10
    
    mutating func changeBar() {
        let closure = {
            self.bar = 50 // Closure cannot implicitly capture a mutating self parameter
        }
        
        closure()
    }
}

注意
这里可以记一下指针和swift变量之间的关系:
UnsafePointer对应let
UnsafeMutablePointer对应var
AutoreleasingUnsafeMutablePointer对应unowned UnsafeMutablePointer,用于inout的参数类型
UnsafeRawPointer对应let Any,raw系列都是对应相应的Any类型
UnsafeBufferPointernon-owning的类型(unowned),用于collectionelements, buffer系列均如此

Xcode中,项目的语言和翻译之间的关系

Xcode

系统选择语言的机制

如果想要卖出更多的程序,你的程序就必须支持多国语言。iOS和macOS在处理程序时,语言部分的模型是这样的:

  1. 用户可以设置多个他懂得的语言,语言的优先级别是从上到下。
  2. 系统打开某个程序时,按照用户设置的语言偏好,依次查找程序是否提供了该语言的界面,如果是,就加载该语言,并打开程序;如果不是,就加载程序员指定的默认语言。

举例:
假设小明的设定的语言偏好顺序为简体中文、繁体中文、英文,程序A提供的语言为繁体中文、英文(默认)。那么小明打开程序A时,程序A会显示繁体中文的界面。
假设雅克布系统设定的语言偏好为法文,那么他打开程序A时,由于程序A没有提供法文的翻译,且程序A的默认语言为英文,所以雅克布看到的程序A界面就是英文的。

中文开发者面对的问题

程序员面对的问题更复杂一些。传统来说,程序员一般选择英文进行开发,然后再翻译成中文和其它语言。这个流程是经过检验的。可是对于中文开发者来说,直接用中文开发界面,然后再翻译成英文或其它界面,在查看时会更加直观。不过,传统上一般认为,如果你直接用中文开发,那么遇到上面举例中的雅克布的情况,由于默认开发的语言已经是中文,雅克布可能会看到一个自己不懂的中文界面的情况。

小结:中文开发者面对的问题是:

  1. 要使用中文开发
  2. 当用户偏好的语言,程序未能提供时,默认显示英文的界面

要解决这个问题,首先得知道Xcode中语言和翻译相关的模型。

Xcode中的语言模型

  1. Xcode采用Base Internationalization的方式。默认情况下,Base Internationalization为英文。
  2. 用户可以在项目的Info.plist中指定程序成默认语言,默认是英文。
  3. 用户添加多种语言的翻译。

解决方案

  1. 新建一个Cocoa程序项目
  2. 在Finder中,找到你的项目的.xcodeproj文件,右键点击,选显示包内容
  3. 双击打开project.pbxproj文件
  4. 在打开的文件中,搜索developmentRegion,默认对应的值为English,将其改为zh-Hans,并保存。这里修改的是Base Internationalization的语言,Xcode中显示为Development Language.
  5. 回到新建的项目,打开项目中的Info.plist,查看Localization native development region项,默认应该是en。如果确认为en,则不用修改。这个项的键是CFBundleDevelopmentRegion。代表程序的默认界面语言。
  6. 点击项目文件,添加新语言Chinese(Simplified),你会看到简体中文后面的括号里写着Development Language。这代表设置成功了。

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

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

NSLocalizedString的特殊用法

Swift

一般情况下,我们使用NSLocalizedString来加工需要翻译的字符串,如:

let says = NSLocalizedString("Hello World!", comment: "hello world")

一般情况这样就够了。如果你的字符串里包含了变量,这个就不能用了。比如:

let count = 10
let says = NSLocalizedString("It runs \(count) times", comment: "run times")

says即使你翻译了,生成的程序也不能正确地显示。这是因为,目前版本的NSLocalizedString不支持Swift的这种用法。它先把says变成“It runs 10 times",然后查找是否有翻译与其匹配,显然是没有的。

在这里,我们需要使用StringlocalizedStringWithFormat方法。

let newSays = String.localizedStringWithFormat(NSLocalizedString("It runs %d times", comment: "new run times"), count)

然后就可以了。这么做很不Swift,但是,这个是目前唯一可用的办法。

延迟处理的妙用

macOS

在某些特殊情况下,我们需要使用延迟处理技术来规避一些系统的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

macOS

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()
}

参考资料

注册`UserDefaults.didChangeNotification`的技巧

Swift

UserDefaults.didChangeNotification应该在PreferencesViewController里进行注册,而不是在需要变化的ViewController里。因为如果是在后者注册,程序运行时,可能会出现意想不到的异常。即用户在没有打开设置的情况下,设置变了,而造成你的程序的异常。

今天下午排查了很久,最后发现是这个原因。

ProgressBar与进度

macOS

// MARK: - progress bar with file dealing process
private var counter:Int = 0

func process() {
    // get total

    // prepare alert
    let screenFrame = NSScreen.main()!.frame
    let window = NSWindow(contentRect: NSMakeRect(screenFrame.width / 2 - 140, screenFrame.height * 0.66 + 50, 280, 20),
                          styleMask: NSBorderlessWindowMask,
                          backing: .buffered, defer: false)
    window.isOpaque = false

    let alert = NSAlert()

    alert.addButton(withTitle: "OK")
    alert.buttons.first!.isHidden = true

    // prepare prgreessBar
    let progressBar = NSProgressIndicator(frame: NSMakeRect(0,0,296,20))
    progressBar.isIndeterminate = false
    progressBar.maxValue = 1000
    progressBar.doubleValue = 0
    let step = progressBar.maxValue / Double(total)
    alert.accessoryView = progressBar
    alert.messageText = "0/\(total)"
    alert.beginSheetModal(for: window) { [unowned self] (buttonId) in
        alert.window.orderOut(self)
    }

    let userInfo:[String:Any] = ["progress bar": progressBar, "step": step, "alert": alert, "total": total]

    // timer to update progress bar UI
    self.counter = 0
    let timer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(updateProgressbar(timer:)), userInfo: userInfo, repeats: true)

    DispatchQueue.global().async() { [unowned self] () -> () in
        func excute() {
            // MARK: - do works
            // for in loop 
            for _ in 0 ... total {
                // update counter
                self.counter += 1
            }
        }

        excute()

        DispatchQueue.main.async { [unowned self] () -> () in
            timer.invalidate()
            progressBar.doubleValue = 1000
            alert.messageText = "\(total)/\(total)"

            alert.buttons.first!.performClick(self)
        }
    }
}

func updateProgressbar(timer:Timer) {
    DispatchQueue.main.async { [unowned self] () -> () in
        if let dic = timer.userInfo as? [String:Any], let progressBar = dic["progress bar"] as? NSProgressIndicator, let step = dic["step"] as? Double, let alert = dic["alert"] as? NSAlert, let total = dic["total"] as? Int {
            progressBar.doubleValue = step * Double(self.counter)
            alert.messageText = "\(self.counter)/\(total)"
        }
    }
}

有趣的是,hidden的按钮在程序里是可以点击的。

另外,必须通过模拟点击的方式才能正确退出alert。采用其它方式有可能造成UI异常,甚至NSWindow不能正确释放而导致程序退出时崩溃。