肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

NSPressGestureRecognizer在模态时失效问题的解决

macOS

最近在使用NSPressGestureRecognizer处理长按的时候发现了问题。如果弹出的视图控制器,是采用的show方式,则一切正常。但如果是使用modal的方式,则无法识别长按。

代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = NSPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)
        self.view.addSubview(view)
    }

    @objc func longPress(_ sender:Any) {
        print("long")
    }
}

分析

我自己弄了半天,没能找到解决方案,于是跑到SO上问。NSPressGestureRecognizer doesn't work in modal ViewController。一觉睡醒,发现已经有人回答了。

Interesting. What seems to be happening is that the recognizer state never changes from possible to "began" in the modal example.

class Recognizer: NSPressGestureRecognizer {

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        self.state = .began
    }

}

解答的代码虽然不完整,但是至少提供了一个方向。按照解答的思路,我重新实现了NSPressGestureRecognizer

模拟的目标是实现在show方式下同样的状态改变。即在正常长按的情况下,依次实现possible、began、end。使用一个Timer,在满足长按时间的情况下,发送began,并且在用户抬起鼠标且有began的情况下,发送end。

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private weak var timer:Timer? = nil
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        timer = Timer.scheduledTimer(withTimeInterval: minimumPressDuration, repeats: false) { (timer) in
            defer {
                timer.invalidate()
            }

            DispatchQueue.main.async {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        }

        super.mouseUp(with: event)
    }

    override func reset() {
        timer?.invalidate()
        super.reset()
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

运行之后,发现我实现的代码,和苹果原本的NSPressGestureRecognizer,效果完全一样。也就是说,我的代码同样有模态方式下,无法识别长按的问题。

再次思考,我发现问题出现Timer上,在模态运行的视图控制器,Timer不会执行。我猜,苹果大概也是代码中使用了Timer,才会有同样的问题。

解决

最终的方案是不使用Timer,增加一个是否取消的参数进行判断。代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        hasCancelled = false

        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(minimumPressDuration * 1000))) {
            if !self.hasCancelled {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        } else {
            self.hasCancelled = true
        }

        super.mouseUp(with: event)
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

总结

  1. Timer在模态时会失效。我们在使用时需要小心。
  2. 上面的代码只处理了鼠标左键,如果想处理其它按键的长按,也可以用同样的方式进行处理。

参考

NSPressGestureRecognizer doesn't work in modal ViewController

NSCollectionView的选中与恢复

macOS

在使用NSCollectionView中发现,如果有选中的项目,那么在重新加载后,选中的项目可能会出现随机的错位。如图:

seleted ite

已经选中了中间的项目,点击底部的Reload按钮后,选中的背景跑到右边的项目上去了。

seleted item reload

代码如下:

import Cocoa

class ViewController: NSViewController {
    @IBOutlet weak var collectionView: NSCollectionView!
    
    private var sources:[String] = [
        "step one",
        "step two",
        "step three",
        "step four",
        "step five",
        "step six",
        "step seven",
        "step eight"
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewItem()
    }
    
    private func registerCollectionViewItem() {
        collectionView.register(Item.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Item"))
    }

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

    @IBAction func reloadButtonClicked(_ sender: Any) {
        collectionView.reloadData()
    }
}

extension ViewController:NSCollectionViewDataSource {
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return sources.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
        item.textField?.stringValue = self.sources[indexPath.item]
         
        return item
    }
}

extension ViewController:NSCollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set<IndexPath>) {
        print("select")
    }
    
    func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set<IndexPath>) {
        print("deselect")
    }
}

注意:上面提到,跑偏的是“选中的背景”,而“非选中的状态”。因为在collectionView.reloadData()之后,所有的选中状态都清空了。

@IBAction func reloadButtonClicked(_ sender: Any) {
    print(collectionView.selectionIndexPaths.count) // print 1
    
    collectionView.reloadData()
    
    print(collectionView.selectionIndexPaths.count) // print 0
}

根据苹果文档,重新加载的过程是

Call this method when the data in your data source object changes or when you want to force the collection view to update its contents. When you call this method, the collection view discards any currently visible items and views and redisplays them.

也就是说,在数据源改变的时候,重新加载。而如果我们的数据源没有包含选中的信息,大多数情况,我们不会将选中信息保存到数据源,那么重新加载之后,选中的状态就消失了。

分析

既然状态是清空的,为什么还会有错位显示“选中的背景”的问题呢?这是因为,苹果在重新加载的时候,为了效率,会重复使用之前创建的项目,同时还采用了并行的处理方式进行生成,因此,会导致显示背景的错乱。

解决思路有两个,一个是手动恢复选择。一个是将选中状态写到数据源。

方法一:手动恢复选择

思路:先保存选中的数据,然后重新加载,最后恢复。代码如下:

@IBAction func reloadButtonClicked(_ sender: Any) {
    let indexPaths = collectionView.selectionIndexPaths
    
    collectionView.reloadData()
    
    collectionView.selectItems(at: indexPaths, scrollPosition: .init())
}

func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
    
    let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
    item.textField?.stringValue = self.sources[indexPath.item]
    
    item.highlightState = .none
     
    return item
}

注意看一下第14行,即item.highlightState = .none,因为在应用中恢复选中,并不会激发选中的状态改变,所以我们必须手动更新选中状态。这里相应的代码如下。

import Cocoa

class Item: NSCollectionViewItem {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
        self.highlightState = .none
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}

那么问题来了,我们的思路是先重新加载,之后恢复选中。既然是这样的顺序,为什么在重新加载的时候,项目会是已经选中的状态呢?不应该是重新加载完成,才选中的吗?这是因为,所有的重新加载,都不是立即重新加载的。而是先恢复状态,然后在下一个事件循环才加载。所以实际的运行类似如下的伪代码:

public func reloadData() {
    // 恢复状态
    DispatchQueue.main.async {
        // 重新加载
    }
}

因此,实际运行的顺序是:

  1. 恢复状态
  2. 恢复选中
  3. 重新加载

这样在实际的重新加载中,项目本身已经是选中的状态了。

另外一个小问题

我们知道,在macOS中,我们可以使用快捷键来进行一些常用的操作。比如利用cmd+a,我们可以进行全部选中。但是如果现在我们使用全部选中,虽然在控制台,我们看到的确是选中了。但是实际上,选中的背景并没有应用。这是因为NSCollectionViewItem.highlightState是针对鼠标操作的,直接用键盘操作,没有鼠标操作的过程,因此背景颜色就没有改变。

分析

highlightState没有改变的,但是选中状态改变了。所以我们在选中状态改变之后,进行背景颜色的改变。代码如下:

import Cocoa

class Item: NSCollectionViewItem {
    
    override var isSelected: Bool {
        didSet {
            self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
        self.isSelected = false
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                break
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
    
    let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
    item.textField?.stringValue = self.sources[indexPath.item]
     
    return item
}

这里,我们移除了.none状态对于背景颜色的改变,而是采用isSelecteddidSet。并且在viewDidLoad()中恢复状态。这样,我们就不必在collectionView(_:itemForRepresentedObjectAt:)恢复状态了。

方法二:将选中状态写到数据源

单纯的将选中状态写入到数据源很简单,就不详细叙述了。这里介绍的是在不将选中状态写入到数据源的情况下,利用中间对象来进行处理的办法。

我们创建了一个中间对象StateObject<Element>来保存真正的数据和是否选中的状态。这样,就可以不必在真正的数据源中记录选中信息了。

class ViewController: NSViewController {
    @IBOutlet weak var collectionView: NSCollectionView!
    
    private var sources:[String] = [
        "step one",
        "step two",
        "step three",
        "step four",
        "step five",
        "step six",
        "step seven",
        "step eight"
    ]
    
    lazy private var elements:[StateObject<String>] = self.sources.map { StateObject($0) }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewItem()
    }
    
    private func registerCollectionViewItem() {
        collectionView.register(Item.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Item"))
    }

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

    @IBAction func reloadButtonClicked(_ sender: Any) {
        collectionView.reloadData()
    }
}

extension ViewController:NSCollectionViewDataSource {
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return elements.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        
        let dataObject = elements[indexPath.item]
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
        item.textField?.stringValue = dataObject.object
        
        if dataObject.isSelected {
            item.isSelected = true
            collectionView.selectionIndexPaths.insert(indexPath)
        } else {
            item.isSelected = false
        }
         
        return item
    }
}

extension ViewController:NSCollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set<IndexPath>) {
        print("select")
        
        indexPaths.forEach {
            elements[$0.item].isSelected = true
        }
    }
    
    func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set<IndexPath>) {
        print("deselect")
        
        indexPaths.forEach {
            elements[$0.item].isSelected = false
        }
    }
}

class StateObject<Element> {
    var isSelected:Bool
    var object:Element
    
    init(_ object:Element) {
        self.object = object
        isSelected = false
    }
}
import Cocoa

class Item: NSCollectionViewItem {
    
    override var isSelected: Bool {
        didSet {
            self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                break
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}

总结

  1. 项目的选中,从鼠标点击角度看,一个分成三步:
    1. 项目原始状态,此时的NSCollectionViewItem.HighlightStatenone
    2. 鼠标按压,此时的NSCollectionViewItem.HighlightStateforSelection
    3. 鼠标放开,此时的NSCollectionViewItem.HighlightStatenone
  2. 而如果之前有选中的项目,并且只允许单选的话,那么已选中项目,对应上面2的是forDeselection
  3. 我们可以通过状态none结合isSelected来判断应该设置何种的背景颜色。但是这种方式不如直接在isSelecteddidSet中设置更为方便。这是因为,只有鼠标交互才会改变NSCollectionViewItem.HighlightState,而无论何种交互方式,都会改变isSelected,后者才是确认项目是否已经选中的方式。
  4. 每次重新加载,NSCollectionView的状态都会立即重置,并且在下一个主线程周期进行实际加载。
  5. 我们可以通过暂存并手动恢复的方式来恢复选择,也可以直接将选中状态直接写入到数据源。如果后者不方便,我们可以创建中间对象,来进行处理。
  6. 相对于手动恢复的方式,使用中间对象来进行处理,更加自然,代码也更加集中,更方便今后的修改。

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

macOS

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

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是最偷懒的方案。

closure导致的循环引用

Swift

最近在开发macOS版的咕唧2,遇到了视图控制器没有释放的问题。花了些时间进行调试。结果总结如下:

我们知道,如果存在循环引用,就会导致类无法释放。因此,我们通常会使用weak var delegate的方式来避免循环引用。

这里不谈一般的循环引用,谈谈closure的循环引用。

closure的循环引用

view controllers

如图,点击上面视图控制器的Next按钮,会自动弹出下面的视图控制器。代码如下:

import Cocoa

class ViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

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

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

class V2ViewController: NSViewController {
    private let a = 100
    private var foo:Foo!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        foo = Foo(doStaff)
        foo.run()
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    func doStaff() {
        print(self.a)
    }
}

class Foo {
    private(set) var bar:() -> ()
    
    init(_ bar:@escaping () -> ()) {
        self.bar = bar
    }
    
    deinit {
        print("Foo deinit.")
    }
    
    func run() {
        bar()
    }
}

运行并关闭弹出的视图控制器,控制台没有输出deinit的打印。存在内存泄漏。

分析

这是因为Foo中的bar,引用了V2ViewControllerdoStaff。而doStaff引用了self

解决

对于closure,我们不能使用weak,只能使用其它的方式。

方法1

调用完成后手动释放。

func run() {
    bar()
    bar = {}
}

方法2

还是手动释放,不过将bar定义为(()->())?,即Opitional

class Foo {
    private(set) var bar:(() -> ())?
    
    init(_ bar:@escaping () -> ()) {
        self.bar = bar
    }
    
    deinit {
        print("Foo deinit.")
    }
    
    func run() {
        bar?()
        bar = nil
    }
}

方法3

更改调用的方式,不直接分配函数,而是增加一层closure

override func viewDidLoad() {
    super.viewDidLoad()
    
    foo = Foo({ [unowned self] in
        self.doStaff()
    })
    
    foo.run()
}

通常我们认为直接分配和增加一层是等价的。但是这里我们看到,实际上,二者并不完全等价。

结论

综合起来,我认为方法3的解决方案,修改代码的成本是最低的。因为它是直接从源头解决了问题。

iOS每次升级后,苹果表表盘通知额度归零问题的处理

watchOS

我是苹果表的老用户了。有多老?初代苹果表可以预购的第一天,我就预定了一只42mm的不锈钢苹果表。除了2018年苹果表电池把屏幕拱开,维修返厂的那几天,我天天都戴着它。

我写的应用,有一个叫Stand Minus的,提交到了苹果商店后,被审核的工作人员认为功能太少给拒绝上架了。我觉得它现有功能“增一分则肥,减一分则瘦”,拒绝修改。所以这个应用我就自己这一个用户。今天我要说的就是和这个Stand Minus相关的内容。

感谢git的存在,我们可以知道,Stand Minus是我在2017年1月31日最初创建的。

stand_minus_initiation

Stand Minus有两种工作方式,根据当前表盘是否有Stand Minus的小部件来判断。

发现问题

根据苹果的文档,当iOSwatchOS发送消息的时候,可以使用WCSession,其中最好用的方法是transferCurrentComplicationUserInfo(_:)。这个方法,同时还能唤醒手表端扩展的后台。因为能唤醒后台,会导致额外的耗电,所以苹果规定每天唤醒次数最多只能有50次,超过就自动变成不能唤醒后台的transferCurrentComplicationUserInfo(_:)调用。

一天有24小时,50次限制约等于每小时2次。我们可以使用remainingComplicationUserInfoTransfers属性来获得剩余的次数。

Stand Minus设计就是每小时发送两次,一天50次肯定是用不光的。但在实际使用中,有时候表盘小部件并没能及时更新。通过手表端查看我发现,提示剩余次数为0。

zero_count_remain

这个问题不常见,但是有时也会出。经过多次观察,我最终发现,这个问题和iOS升级有关,每次iOS升级完成之后,剩余次数都会归零。解决的办法很简单,额外再重启一次手机,然后就好了。

尝试解决问题

时光荏苒,到了2019年,因为升级到iOS 13 beta之后,我一直使用的推送OneSignal失效了。不得不重新折腾起一直用得好好的Stand Minus。同时,我尝试测试究竟是什么原因导致了次数归零。

我将代码

remainCountsLabel.text = String(session.remainingComplicationUserInfoTransfers)

改成了

remainCountsLabel.text = {
    guard session.activationState == .activated else {
        return "Session状态不是.activated。"
    }
    
    guard session.isWatchAppInstalled else {
        return String("对应手表应用正在安装中……")
    }
    
    guard session.isComplicationEnabled else {
        return "错误:手表当前表盘未安装Stand-的小部件。"
    }
    
    return String(session.remainingComplicationUserInfoTransfers)
}()

然后等待iOS的更新再测试。我最终发现了,导致错误的原因是,虽然手表的表盘上有Stand Minus的小部件,但是session.isComplicationEnabled返回的是否。而对于当前表盘没有对应小部件的情况下,session.remainingComplicationUserInfoTransfers返回0。

no_complication_erro

找到了更确切的原因,就可以解决问题了。我找了不用重启iPhone的办法。先切换手表的表盘到另外的表盘,然后再切回来,然后再点击Stand Minus的表盘小部件,打开手表应用。

经过上面的步骤,再重新打开手机上的应用查看,就会显示正确的剩余次数了。

最终解决方案

每次iOS升级之后,都做一遍切表盘,切表盘,开应用的操作。不算麻烦,但是也挺讨厌的。有没有有什么更好的办法呢?

我用的苹果表初代,在2018年的watchOS 5就不能更新了,我曾经猜测苹果应该早就修复了这个问题。我今年卖了苹果表5,手表到手之后,我发现苹果表5还是有这个问题。苹果怎么回事?这都watchOS 6了啊。

我尝试绕过这个问题。观察代码,手机向手表发送消息的核心代码如下:

if session.activationState == .activated && session.isPaired && session.isComplicationEnabled {
    session.transferCurrentComplicationUserInfo(userInfo)
}

即当一切正常,且当前表盘存在对应小部件的时候,发送能够唤醒后台的消息。已知出错的是session.isComplicationEnabled,那么可否移除它,直接发送唤醒后台的消息呢?

阅读文档,苹果是这么说的:

Call this method when you have new data to send to your complication. Your WatchKit extension can use the data to replace or extend its current timeline entries.

This method can only be called while the session is active—that is, the activationState property is set to WCSessionActivationState.activated. Calling this method for an inactive or deactivated session is a programmer error.

原来,调用transferCurrentComplicationUserInfo(_:)方法,只要求WCSession为活跃就可以,其余的都是可以忽略的。

于是我删掉了session.isComplicationEnabled的检测。这相当于主动忽略了系统出错的部分。

no_complication_but_still_send

不过,因为不再检测当前表盘是否存在小部件,相对于Stand Minus的原本架构,在不使用表盘小部件的情况下,手表的额外耗电理论上会多一些。

其它

我这边能做的就是这些。API的错误,最终还是需要苹果来解决。

参考资料

脱离CocoaPods 1.8.0的trunk

今早升级pods的时候,看到1.8.0的CocoaPods正式版出了。于是升级了。之后pod install的时候,被强制安装了新的源trunk.

使用时,我发现,pod repo update的界面变了,不仅没有了之前的进度和速度,还变慢了。我搜索了一下trunk,都是说和个人建立自己的源有关,于是我迅速提交了一个故障报告。CocoaPods 1.8.0 added 'trunk' to my repo though I don't use 'trunk' #9190

很快收到了回复,说这个是1.8.0的新功能,采用cdn替换了原本的master。通俗说,就是用分布各地的服务器来替换了原本的GitHub的源。理论上讲,使用了cdn之后,如果你的附近有源服务器,就会加速你更新pods的速度。

但实际上我这里反而重回龟速了。

CocoaPods 1.8希望你这么做

删除掉原本的master

千万别这么做!

pod repo remove master

只要你这么做了,以后的操作就都是基于trunk的了。

如何脱离trunk

如果你已经有了trunk

pod repo remove trunk

如果你已经删掉了master,采用此命令恢复

git clone https://github.com/CocoaPods/Specs.git ~/.cocoapods/repos/master

最重要的一步

打开你每个项目的Podfile,在最顶部添加

source 'https://github.com/CocoaPods/Specs.git'

trunk的方式已经成为了新的默认。如果还想使用旧的方式,就必须每个Podfile顶部都指定源。

两个脚本

为已有的Podfile添加源

#!/bin/bash
echo -e "source 'https://github.com/CocoaPods/Specs.git'\n" | cat - Podfile > temp && mv temp Podfile

新建Podfile

#!/bin/bash
pod init
echo -e "source 'https://github.com/CocoaPods/Specs.git'\n" | cat - Podfile > temp && mv temp Podfile

此外

如果你对于自己的网络有信心,可以测试一下使用cdn的网速。满意的话,也可以按照官方的指导做,删掉master。这样的好处就是不用每个Podfile都需要指定源了。

在终端使用代理

如果你的代理包含socks5,可以在~/.bashrc文件中添加两行。

alias proxy='export all_proxy=socks5://127.0.0.1:1086'
alias unproxy='unset all_proxy'

这样就可以在终端中使用proxy开启代理,可以极大地加速终端下访问GitHub的速度。

参考资料

Ubuntu服务器Livepatch安装及问题解决

Ubuntu

Ubuntu在14.04以后,支持更新内核之后动态更新的技术。但是需要激活。步骤如下:

  1. https://auth.livepatch.canonical.com申请一个token。
  2. 然后安装命令操作。

问题

执行第二行命令的时候,遇到了错误。

2019/08/30 01:46:55 error executing enable: cannot enable machine: this machine ID is already enabled with a different key or is non-unique. Either "sudo canonical-livepatch disable" on the other machine, or regenerate a unique /etc/machine-id on this machine with "sudo rm /etc/machine-id /var/lib/dbus/machine-id && sudo systemd-machine-id-setup": {"error": "Conflicting machine-id"}

解决

rm -f /etc/machine-id
rm /var/lib/dbus/machine-id
dbus-uuidgen --ensure=/etc/machine-id
dbus-uuidgen --ensure

> 注意:上面的操作有风险,需要先备份你的系统。

然后再执行第二行的命令。

参考资料

开发者备份和恢复Realm数据库

RealmSwift

最近在研究带参数的捷径。因为是SiriKit相关,需要把Realm放在应用组里。这样就不能通过Xcode下载容器了。几天时间里,已经有了两次不小心删除掉数据库,而不得不通过备份恢复手机的经历。我开始研究一下如何导出和恢复数据。

思路

因为目的只是为了方便开发,而不是交给用户操作,过程简便,代码容易是重点。

首先想到的是通过长按某个按钮实现导出,然后出了问题就将导出的数据库放在应用里,应用启动时检测是否有数据,如果有,就替换,然后再正常启动。

实现

具体实现的时候,遇到了一个问题,分享出来的URL,并没有写成文件,而是写成了纯文本。

@objc private func exportRealm(_ sender:Any) {
    let sourceURL = Realm.Configuration.defaultConfiguration.fileURL!
    
    // copy realm to cache and rename it to current date and time
    let fm = FileManager.default
    let filename = { () -> String in
        let df = DateFormatter()
        df.dateFormat = "yyyy.MM.dd_HH.mm.ss"
        return "any_counter_2.backup.at.\(df.string(from: Date())).realm"
    }()
    let cacheFolderURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
    let destinationURL = URL(fileURLWithPath: filename, isDirectory: false, relativeTo: cacheFolderURL)
    
    do {
        try fm.copyItem(at: sourceURL, to: destinationURL)
    } catch {
        return
    }

    // export
    let activityViewController = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil)
    activityViewController.completionWithItemsHandler = { (_, isCompleted, _, activityError) -> Void in
        guard activityError == nil || (activityError! as NSError).code == 3072 else { // Error Domain=NSCocoaErrorDomain Code=3072 \"操作已被取消。\"
            fatalError("\(activityError!)")
        }

        // clear cache after success
        try? fm.removeItem(at: destinationURL)
    }
    present(activityViewController, animated: true, completion: nil)
}

这个问题花费了我不少时间去寻找解决方案。最后我突发奇想解决了。实际上,就是和URL有关。将12行

let destinationURL = URL(fileURLWithPath: filename, isDirectory: false, relativeTo: cacheFolderURL)

替换为

let destinationURL = URL(fileURLWithPath: cacheFolderURL.path + "/" + filename, isDirectory: false)

,问题解决。

类似的事情很早就遇到过,比如

import Foundation

let folder = "/foo"
let file = "bar"

let folderURL = URL(fileURLWithPath: folder, isDirectory: true)
let u1 = URL(fileURLWithPath: file, isDirectory: false, relativeTo: folderURL)
print(u1.path) // /foo/bar

let u2 = URL(fileURLWithPath: folder + "/" + file, isDirectory: false)
print(u2.path) // /foo/bar

print(u1 == u2) // false
print(u1.path == u2.path) // true

即两个path相同的URL,仅仅是因为构造方法的不同,它们就不相等。这其实是很不直观的,人们在没遇到之前,可能就会认为path相等的两个URL就是相等的,但是实际上系统认为不是。

这里也是这样,通过relativeTo构造的URL,iOS系统仅能将它存成一个纯文本。但是直接构造的URL就没有这个问题。

我虽然不认同,但是只能记住。