肇鑫的技术博客

业精于勤,荒于嬉

苹果文档NotificationCenter中removeObserver(_:),讨论的部分是错误的

苹果的文档,在讨论中说:

If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method. Otherwise, you should call this method or removeObserver(_:name:object:) before observer or any object specified in addObserver(forName:object:queue:using:) or addObserver(_:selector:name:object:) is deallocated.

这里很容易被认为是说,对于iOS 9.0和macOS 10.11以上的系统,开发者没有必要再手动移除观察器了。但是我测试的结果却并非如此。

小实验

view controllers 2

假设两个视图控制器的关系如上图所示。点击上面的视图控制器的Show按钮,会弹出下面的视图控制器,然后点击发送通知按钮,会发送一个通知。代码如下:

import Cocoa

class ViewController: NSViewController {
    static let foo = Notification.Name("foo")

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func postNotificationButtonClicked(_ sender: Any) {
        NotificationCenter.default.post(Notification(name: ViewController.foo))
    }
}
import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [unowned self] (_) in
            
            print("foo!")
            self.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

测试步骤

  1. 运行应用
  2. 点击Show按钮
  3. 点击Post Notification按钮
  4. 关闭弹出的窗口。此时控制台会显示V2视图控制器完全退出。
  5. 点击Post Notification按钮。
  6. Xcode提示应用崩溃,因为V2视图控制器已经从内存中销毁了。没有想应的实例。

小结

虽然苹果说对于iOS 9.0和macOS 10.11之后的系统,开发者不必手动移除观察器。但是实际上,如果不移除,那么该观察器就是一直存在的,并有可能造成程序崩溃。

这是因为,NotificationCenter.defaultNotificationCenter的静态属性,它一直在内存中存在。注册在它上面的观察器,因此也就一直在内存中存在。

解决

方法1

在deinit中手动移除观察器。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200
    private var observer:NSObjectProtocol!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        observer = NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [unowned self] (_) in
            
            print("foo!")
            self.run()
        }
    }
    
    deinit {
        if let observer = self.observer {
            NotificationCenter.default.removeObserver(observer)
        }
        
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

方法2

使用[weak self]替代[unowned self]。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [weak self] (_) in
            
            print("foo!")
            self?.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

这个方法只能避免应用的崩溃。其余的代码还是会执行。如果你有涉及到存储之类的操作,这种方式可能并不适合。

方法3

NotificationCenter.default文档中,苹果说:

All system notifications sent to an app are posted to the default notification center. You can also post your own notifications there.

If your app uses notifications extensively, you may want to create and post to your own notification centers rather than posting only to the default notification center. When a notification is posted to a notification center, the notification center scans through the list of registered observers, which may slow down your app. By organizing notifications functionally around one or more notification centers, less work is done each time a notification is posted, which can improve performance throughout your app.

我尝试自建一个NotificationCenter的实例。这么做的原理是,既然类属性会一直存在,那么我们就不使用类属性,而是使用实例。实例应该在V2视图控制器销毁时自动销毁。

import Cocoa

class ViewController: NSViewController {
    static let foo = Notification.Name("foo")
    private weak var center:NotificationCenter? = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func postNotificationButtonClicked(_ sender: Any) {
        center?.post(Notification(name: ViewController.foo))
    }
    
    override func prepare(for segue: NSStoryboardSegue, sender: Any?) {
        if segue.identifier == "showV2Segue" {
            let v2 = segue.destinationController as! V2ViewController
            center = v2.center
        }
    }
}
import Cocoa

class V2ViewController: NSViewController {
    private let a = 200
    var center  = NotificationCenter()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        center.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [unowned self] (_) in
            
            print("foo!")
            self.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

我万万没想到,这个方案居然失败了。我本以为NotificationCenter的实例会自动释放。但是实际上并没有。查看苹果的文档。我发现这么一段:

The block is copied by the notification center and (the copy) held until the observer registration is removed.

块被复制到通知中心,(这个复制品)一直存在,知道观察器被移除。

因此,这个其实和closure导致的循环引用是类似的。

方法4

方法3失败了。不过苹果的说明,启发了我对于方法4的尝试。addObserver(_:selector:name:object:)是添加观察器的另外一个方法,它本身不复制块,而是发送消息给指定对象的函数。这个方式很Objective-C。因此,它需要@objc属性的函数。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    @objc fileprivate func extractedFunc() {
        print("foo!")
        self.run()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        NotificationCenter.default.addObserver(self, selector: #selector(extractedFunc), name: ViewController.foo, object: nil)
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

这个方法才是符合苹果文档的描述的添加方式。
不过,从原理上讲。这个能够生效的原因,是因为addObserver(_:selector:name:object:)的第一个参数,即observer,它是将整个对象作为观察器,然后在需要时发送消息(第二个参数selector)给观察器。因此,当观察器(一般是当前的视图控制器)自动销毁时,就相当于给nil发送了一个消息。这种方式在Objective-C中是允许的,什么事情也不会发生。从理论上讲,这个其实是一种更安全的方法2。

方法5

搞懂了方法4,我们现在可以改进方法2。方法5,改进了方法2。能够达到方法4类似的结果。不过我们要始终记得,无论是方法2还是方法5,只要不采用方法1的方式移除观察器,那么被复制的块就会一直在等待被执行。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [weak self] (_) in
            
            guard let strongSelf = self else { return }
            
            print("foo!")
            strongSelf.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

总结

综合苹果的文档和实际测试的结果。不难发现,苹果文档存在错误。总结如下:

  1. 对于addObserver(_:selector:name:object:)添加的观察器。如果是iOS 9.0,macOS 10.11以后的系统,无需用户手动移除观察器。
  2. 对于addObserver(forName:object:queue:using:)添加的观察器。需要使用方法1或方法2的方法来解决。推荐使用方法1。
  3. 对于removeObserver(_:)文档。苹果的讨论是错误的。
  4. addObserver(_:selector:name:object:)的优点是不用手动注销观察器,缺点是需要单独创建一个函数,并且该函数必须是@objc的动态函数。
  5. addObserver(forName:object:queue:using:)的优点是无需单独创建函数,运行代码与添加代码紧密相连。缺点是每个控制器都需要单独用类变量记录下来,并且需要手动移除。(建议在deinit函数中移除)
  6. 综上,我认为方法4的方案是目前代价最小的方案。方法1是最完善的方案。方法5是最偷懒的方案。

macOS下自定义文件类型(传统篇)

起因

最近开发macOS的应用,需要想Xcode那样打开.xcloc结尾的文件夹。如图,虽在在Finderzh-Hans.xcloc显示为文件夹,但是在Xcode打开它时,它却显示为一个文件(前面没有文件夹的三角形标志)。

Xcode open xcloc folde

在Xcode中的Info.plist里,xcloc文件是定义在Exported Type UTIs中的。因此,如果只是想达到和Xcode同样的效果,只需要将这段xml片段,复制到你自己的Info.plist里。

Xcode xclo

更进一步

Xcode的做法是只在它的导入中将.xcloc的文件夹视为文件,而在Finder中,.xcloc的文件夹还是文件夹的状态。但是,如果是想直接通过双击的方式打开.xcloc的文件夹,则需要Finder同样显示.xcloc的文件夹视为文件。

原理

macOS通过UTI来判断数据的类型,若UTI不存在,则通过扩展名判断。

Finder中我们见到的文件文件夹,从本质上讲,都是存储在电脑中的数据。不过为了管理的需要,我们将单独的数据称之为文件,将包含了其它数据的数据称之为文件夹
特别的,macOS允许我们将特定的文件夹,在Finder中显示为文件。通过在Info.plist中设定LSTypeIsPackage属性为真可以让Finder文件夹看作是文件
但是,这个属性

英文版

Show certain folder as a file as Xcode does

参考资料:

macOS中自定义应用名的坑(更新)

我们知道在Info.plist中添加CFBundleDisplayName可以更改应用的显示名称,也就是自定义应用名。iOS可以在Xcode的项目->目标->通用中直接设定。

iOS指定Display Name

macOS的目标没有这个设置,必须手动添加。我们直接在Info.plist中添加这个属性。然后在需要翻译的目标语言的InfoPlist.strings中翻译成对应的语言。

这里面有一个坑,导致了一些问题。实践中,在macOS中CFBundleDisplayName的值,必须是包名的最后一项,不然就不会生效。比如,你有一个叫应用叫"com.foo.Orange",然后直接CFBundleDisplayName设置为“橘子”,以为这样就不用翻译了。可你用Xcode编译之后,生成的应用却是“Orange.app”而不是你想要的“橘子.app”。

也就是说,在macOS中,你只能将CFBundleDisplayName设置成“Orange”,然后在翻译文件中翻译。

另外,在测试中我发现也可以将CFBundleDisplayName设置为“空”也能生效。除了设置为包名和空之外,其余的方式都会导致该设置无效。

实际上,这个坑属于bug,确切的说,是macOS中Finder的bug。因为你解包看的话,Xcode生成的文件都是没有问题的。因此只能认为是Finder在读取包时额外作出了错误的限制。我已经向苹果报了这个bug,就不知道苹果肯不肯修了。

收到苹果回复了,苹果认为这个是已知的特性

This is correct behaviour on macOS. Because users can rename apps, CFBundleDisplayName must match the file system name of the app bundle for localization to become active. This behaviour is documented.
See https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/plist/info/CFBundleDisplayName

苹果的文档是这么写的。

In macOS, before displaying a localized name for your bundle, the Finder compares the value of this key against the actual name of your bundle in the file system. If the two names match, the Finder proceeds to display the localized name from the appropriate InfoPlist.strings file of your bundle. If the names do not match, the Finder displays the file-system name.

如图,这实际上是两个路径

分析

  1. Xcode生成应用,使用的名称,是项目名称。
  2. Finder上的应用名(文件名),由于用户可以更改,可能与开发者当初设定的不一致。其判断规则是,Finder中应用的文件名,如果与应用开发语言中的CFBundleDisplayName一致,则显示翻译,否则不显示。

原理是搞清楚了。不过我觉得苹果还是有点儿蠢的。开发语言中的CFBundleDisplayName实际上,只是起到判断的作用,而并没有体现为Display Name。这是与其名字不符的。实际上,开发者并没有其它任何办法更改默认的显示名,只能更改项目名。

macOS应用修改默认名称的唯一方法

更改项目名是唯一的方法。
更改项目名

可以在Xcode如图的位置更改项目名。其它方式都只能更改目标语言的显示名,而不能更改默认语言的。