肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

延迟处理的妙用

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不能正确释放而导致程序退出时崩溃。

URL的path相等,但是URL却可能不相等的问题

Swift

与预期的不同,URL即使path相同,URL也可能是不同的。因此,应避免使用init(fileURLWithPath: String, isDirectory: Bool, relativeTo: URL?),使用appendingPathComponent(_:isDirectory:)作为替代。

let url = URL(fileURLWithPath: "foo/bar", isDirectory: false)
let baseURL = url.deletingLastPathComponent()
let newURL = URL(fileURLWithPath: "bar", isDirectory: false, relativeTo: baseURL)
let testURL = baseURL.appendingPathComponent("bar")

print(url == newURL) // prints false
print(url.path == newURL.path) // prints true

print(url == testURL) // prints true
print(url.path == testURL.path) // prints true

获得Finder拖动来的文件URL

macOS

新项目需要获得从Finder拖动过来的文件URL。这篇文档是一篇总结。

根据Drag and Drop Programming Topics,拖放操作有拖动源和拖动目的地。Finder是源,我的App是目的地。

拖动的目的地必须是NSWindowNSView及其子类,我选择ViewController对应的view作为源。

注册UTI文件类型

首先要注册view可以从剪贴板获得的数据类型。由于历史的原因,这部分实际上比较混乱。

// MARK: - Dragging destination
required init?(coder: NSCoder) {
	super.init(coder: coder)
	
	// Dragging destination
	self.register(forDraggedTypes: ["public.file-url", "public.folder"]) // somehow, public.folder isn't used
}

避免使用剪贴板类型

虽然苹果的指南里使用的还是NSFilenamesPboardType,但是实际上在API文档里,这种方式已经是不建议使用的了。

Version-Notes
Pboard types will be deprecated in a future release. In macOS 10.6 and later you should replace any use of pboard types with UTIs, including the constants described in Types for Standard Data (macOS 10.6 and later).
版本注释
剪贴板类型在未来的版本会被反对。在macOS 10.6之后的版本你应该使用UTI替代剪贴板类型,即使是10.6引入的新版的也同样替代。
Types for Standard Data (OS X v10.5 and earlier)

UTI文件类型浅析

那么什么是UTI呢?其实就是苹果使用的文件类型系统。具体的可以看这个表格

几种类型的区别

比如我使用的是["public.file-url", "public.folder"],你可以在上面提到的表格里查到这两个常量。你也可以使用苹果定义的常量kUTTypeFileURLkUTTypeFolder作为替代,但是这两个常量是CFString的格式,所以使用时需要转换为String才可以。

由于历史的原因,苹果为了保证兼容性,在剪贴板的类型里会显示多个类型。比如foo.txt,从Finder拖动时,剪贴板里的类型是这样的。

public.file-url
CorePasteboardFlavorType 0x6675726C
dyn.ah62d4rv4gu8y6y4grf0gn5xbrzw1gydcr7u1e3cytf2gn
NSFilenamesPboardType
dyn.ah62d4rv4gu8yc6durvwwaznwmuuha2pxsvw0e55bsmwca7d3sbwu
Apple URL pasteboard type
com.apple.finder.node

可以看到,”public.file-url“和”Apple URL pasteboard type“是同时存在的。kUTTypeFileURL的值实际上对应的就是"public.file-url"。

(lldb) print kUTTypeFileURL
(CFString) $R0 = 0x00007fffc41bc6d0 {}
(lldb) print kUTTypeFileURL as String
(String) $R1 = "public.file-url"
(lldb) print NSFilenamesPboardType
(String) $R2 = "NSFilenamesPboardType"

拖动到目标时

override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
	let sourceDragMask = sender.draggingSourceOperationMask()
	let pb = sender.draggingPasteboard()
	
	if pb.types?.contains("public.file-url") == true {
		if sourceDragMask.contains(.generic) {
			return .generic
		}
	}
	
	return [] // NSDragOperationNone
}

获得剪切板的数据

override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
    let pb = sender.draggingPasteboard()
    
    if pb.types?.contains("public.file-url") == true {
        let urls = pb.readObjects(forClasses: [NSURL.self]) as! [URL]
        
        if let controller = nextResponder as? ViewController {
            controller.urls = urls
            
            return true
        }
    }
    
    return false
}

NSURL与URL

这里必须使用NSURL而不能使用URL,因为前者实现了剪切板读取协议,而后者没有。

剪切板读取协议

NSPasteboardReading协议。

以下类实现了该协议。由于这个协议继承至NSObjectProtocol,只有NSObjectNSProxy的子类才能实现该协议。
NSAttributedString
NSColor
NSFilePromiseReceiver
NSImage
NSPasteboardItem
NSSound
NSString
NSTextStorage
NSURL

iOS app与watchOS app数据同步

iOS

保存的数据库采用realm。手表部分的app具备与iOS端相同的功能。

基础部分

数据保存在用户的Document里。升级app数据不会消失,卸载app数据会消失。

同步算法

以iOS端为核心。watchOS发起同步请求。

首次启动

WCSession的生命周期最为标准。每次WCSessionDelegatesession(_:activationDidCompleteWith:error:)的运行被认为是一次首次启动。

  • iOS:设定需要同步数据库needSyncDatabase = true。在同步数据库完成之前,不会发送新的同步信息。
// MARK: - WatchConnectivity
extension AppDelegate:WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if transfer?.isTransferring == true { transfer?.cancel() }
        
        needSyncDatabase = true
        transfer = session.transferUserInfo(["needSyncDatabase":needSyncDatabase])
    }
    
    func sessionDidBecomeInactive(_ session: WCSession) {
        
    }
    
    func sessionDidDeactivate(_ session: WCSession) {
        
    }
}
  • watchOS:设定同步数据库,手表端有两种情况。
    • WCSession首次启动时主动同步。
    • 接收到iOS发来的同步请求时同步。
// MARK: -WCSessionDelegate
extension ExtensionDelegate:WCSessionDelegate {
    func shouldSyncDatabase() -> Bool {
        return needSyncDatabase && !isSyncingDatabase
    }
    
    func willSyncDatabase() {
        isSyncingDatabase = true
    }
    
    func didSyncDatabase() {
        needSyncDatabase = false
        isSyncingDatabase = false
    }
    
    func syncDatabase() {
        guard shouldSyncDatabase() else { return }
        willSyncDatabase()
        // sync database
        didSyncDatabase()
    }
    
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        syncDatabase()
    }
    
    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        if let value = userInfo["needSyncDatabase"] as? Bool {
            needSyncDatabase = value
            syncDatabase()
        }
    }
}

运行中

  • iOS
    • 当数据库改变时,发送需要同步的数据。
    • 接收需要同步的数据,存入数据库。

思路

iOS -> watchOS 同步

iOS开发存储策略

iOS

程序沙盒

  • 需要相同代码签名和包名
  • Home目录:NSHomeDirectory()
  • 临时文件目录:NSTemporaryDirectory(),在home目录外,但在沙盒里
  • iOS的沙盒内,包含程序自身

程序组容器目录

  • 需要相同开发团队
  • 位置:~/Library/Group Containers/<application-group-id>
  • 调用:FileManagercontainerURL(forSecurityApplicationGroupIdentifier:)方法

使用策略

  • 需要长期保存的数据存在Home目录
  • 需要共享的数据存在程序组容器目录

思考

单独将数据保存在程序组容器目录是否安全?
**安全。**因为只有相同开发团队的人才能访问。而且可以简化开发。
**不安全。**因为一个app的数据有被其它app删除的风险。另外,是否应该限制这部分数据,使只有需要分享的数据才方到这里。
**结论。**我个人更倾向于安全。因为团队内部的人应该被认为是可信的。但是这么做的确会存在过多分享的问题。因此,我认为还是不要把主数据库放在这边,而是将其作为辅助数据库更好。至于说需要额外处理的代码问题。我相信,从长期看,这部分代码必然是必要的。