肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

AVMovie始终显示视频时长为0问题的分析、解决

macOS

推特仅支持0.5秒到140秒之内长度的视频,因此上传前要先检验视频的长度。测试时,我发现,Finder分享的视频时长始终为0。

我查询了一下,最初看到的说法是,AVAssetAVMovie的父类)的加载是异步的,不能够立即获得时长,而需要用键值观察的方法,注册通知来观察duration这个属性何时变化。昨晚看到这里,我就睡了。

今天再看,发现有协议AVAsynchronousKeyValueLoading,可以用来异步处理键值。方法loadValuesAsynchronously(forKeys:completionHandler:)会在加载完成后执行completionHandler。这样就不用我们自己手动注册键值观察的通知了。

但是,我测试这个方法不好用。Finder传来的视频,无论是否使用这个异步方法,时长都始终为0。

我不用Finder,采用照片应用进行调试,发现无论是不是用这个异步方法,都能获得正确的时长。

结合上一篇Finder分享视频到共享扩展的一些限制的小结。我产生了新的想法,经过测试,我的猜想是正确的。

那就是,AVMovie在读取URL的时候,会正确获得时长,如果是Data的话,则不可以。想测试也很简单,把URL变成Data,再创建AVMovie;或者把Data写入到URL,再创建AVMovie。就可以证实了。

我不清楚这个是苹果API的错误,还是特性。但是这个至少是没有注明的。

Finder分享视频到共享扩展的一些限制的小结

macOS

通常,我们使用NSItemProviderloadItem(forTypeIdentifier:options:completionHandler:)方法来加载对应类型的对象。但是Finder提供的信息有问题。不能使用推荐的类型来获得想要的对象。

比如分享mp4文件时,Finder提供的NSItemProvider是这样的。

<NSItemProvider: 0x6000000bfa40> {types = (
    "public.file-url",
    "public.url",
    "public.mpeg-4"
)}

你用"public.mpeg-4"作为类型来获取数据,不能获得视频文件对应的URL。这是很罕见的。因为大部分情况下,比如通过照片应用来获得视频时,都可以通过这种方式获得URL。

Finder提供给"public.mpeg-4"实际上是一个Data。但是这个Data用在这里实际上是有问题的。苹果不应该这么做。为什么呢?因为相比于其它类型的文件,比如图片或者声音之类的,视频文件可以是很大的。如果你要使用这个视频文件,那么通过Data来加载,就会占据大量的内存。实际上,苹果自己就在加载视频的文档中写到,不要全部加载到内存。

apple data warning

因为存在这个限制,Finder实际上传来的Data可能是不完整的。比如我尝试分享一个4GB的视频时,实际获得的Data的大小是415MB,并不完整。

结论,既然Data既占内存,又可能不完整,为什么Finder还要提供呢?只能说,苹果做错了。

临时的处理办法是这样的,先用"public.mpeg-4"尝试获取URL,如果获取失败。那么就看NSItemProvider是否支持URL。如果支持就再用"public.url"尝试获取URL。如果还不行,那就认为是没法处理。为什么不反过来呢?因为大部分的情况,都是第一种方式就能直接处理。Finder这么做实际上是错误的。但是为了适应它,才需要调整我们的代码。

Twitter首次授权,三步这么走

iOSmacOS

three steps

初步结论

发送部分

  • methodPOST时,postContent为nil或需要上传的数据。
    • 例外:申请用户授权的第一步时,oauth_callback是作为header的一部分,而不是postContent
  • methodGET时,会需要在baseURL后连接GET的信息urlGetParameter,形成新的fullURL
  • oauth_signature的计算,是将methodbaseURL和所有要发送的信息连起来,并计算校验值。其中,除methodbaseURL之外的所有的键值对,需要按照键的字母顺序进行排列。
  • oauth_signature的计算,signing_key由两部分构成,consumer_secretoauth_token_secret两部分合成,中间用&连接。
    • 例外:申请用户授权的第一步时,由于没有下发的oauth_token_secretsigning_keyconsumer_secret+&
  • header永远使用Authorization字段,该字段使用的键为七个:oauth_consumer_keyoauth_nonceoauth_signatureoauth_signature_methodoauth_timestampoauth_tokenoauth_version
    • 例外:申请用户授权的第一步时,此时还没有服务器下发的oauth_token。但这时要把oauth_callback加入其中。
  • 例外:虽然oauth_signature的计算方法写的是使用baseURL,但是我发现,当HttpMethodGET时,既可以使用baseURL进行计算,也可以使用fullURL进行计算。当使用fullURL时,需要把GET的参数项对应的键值对,从oauth_signature的计算中移除。

接收部分

  • 接收成功的标志是successStatusCode=200。
  • 接收的内容是一组由预定的键组成的键值对。可以认为,键值对与baseURL是对应的关系。
  • 例外:申请用户授权的第二步时,会用到第一步里的跳转链接oauth_callback
  • 例外:申请用户授权的第二步时,由于用户需要用户授权,然后跳转,这一步,是没有返回值的。跳转之后的连接需要开发者手动拦截。
  • 例外:类似发推功能的API这种,我们只需要确认成功发推,并不关注返回值。此时可以设定返回值的键为空,不对其进行处理。

具体实施

第一步:Obtaining a request token

  1. 采用URLSessionDataTask链接,并处理传来的数据
  2. 打开WebViewViewController

第二步:Redirecting the user

  1. 通过内嵌的网页处理URLRequest
  2. 用户登录并授权后,Twitter会跳转到第一步中设定的会掉页面
  3. 通过WKNavigationDelegatefunc webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {,截获该跳转,获得数据。
  4. 此时需要验证本次收到的oauth_token与第一步收到的相同

第三步:Converting the request token to an access token

  1. 采用URLSessionDataTask链接,将第二步获得的oauth_verifier,发往服务器进行验证,获得最终的tokens,并保存。
  2. 授权完成。

Twitter REST API使用通用流程

iOSmacOS

步骤

  1. 申请建立新程序(账号需要绑定手机)
  2. 测试OAuth 1.0

各个变量的含义

原文地址:
POST oauth/request_token
Creating a signature

参数 含义
oauth_consumer_key 申请的程序的key
oauth_nonce base64编码的32字节的随机数据。Twitter使用它来查验信息是否属于重复提交
oauth_signature 所有提交的信息的校验码,用于检验内容是否被篡改1
oauth_signature_method 校验码的计算方法,固定为HMAC-SHA1
oauth_timestamp Unix相对于GMT 1970年1月1日的秒数。
oauth_token 代表当前用户的token,首次使用时需要向Twitter申请
oauth_version oauth的版本,目前总是1.0
Consumer secret 你申请App时获得的另外一段文本2
OAuth token secret 调用API后从Twitter网站获得的当前用户的标志
Signing key 计算校验码时的key,由Consumer secret和OAuth token secret加工而成
status 用户发送的tweet的正文

创建校验码的方法

原文地址:
Creating a signature

写在前面

**一定要记住:只有上面原文的例子是真正可用的。**不过这个例子也有一些问题,后面我会提到。

按照流程我们一般会最先到POST oauth/request_token页面去。因为我们不申请授权的话,什么事都做不了。但这里是Twitter官方文档的第一个坑。用来计算oauth_signature需要的key的一部分是Consumer secret,而这个页面根本没有提供它。因此,这个例子里的值是没法计算的。

更坑的是,由于Consumer secretConsumer Key非常像,而后者就是oauth_consumer_key,这个值在这个页面有提供。因此,初学者如我,就很容易把oauth_consumer_key作为Consumer secret来计算oauth_signature。得到的结果自然与例子不同。我在这里浪费了很多的时间。

另外一个页面Authorizing a request也是同样的问题,没有提供Consumer secret,因此也是无法计算的。

结论:强烈建议你在算法设计完成时,使用Creating a signature这个页面提供的例子来检验你的算法是否正确。如果正确了,那么再进行下一步。

需要计算的量

在调用一次API时,需要有很多参数,有一些参数的值是可以直接获得的,比如oauth的版本,signature的校验方式,都是固定的,每次都一样。但也有一些参数需要计算,简单的timestamp的就不提了,下面提几个相对复杂的。

oauth_nonce

oauth_nonce是一个应该每次都不一样的字符串,Twitter通过这个值来验证客户端是否发送了重复的请求。按照官方的说法,这个字符串的计算过程是取一段32个字节的随机数据,然后将这段数据采用base64编码的方式输出为字符串,之后过滤掉其中非词的字符。

and stripping out all non-word characters

这里的非词指的是大小写字母和数字之外的字符。^[a-z|A-Z|0-9]

实现这个的算法有很多,但是有一点我很疑惑。因为官方文档里的几个例子,字符都是42个,但是我按照算法说明生成的,一般都是43个。只有把32个字节,改成31个字节,得到的才是42个。

先说结论,这个字符串是42还是43其实无所谓。后来我的程序确定能通过之后,我发现,有时生成的字符串是45个。也就是说,其实这个字符串,本质上来讲,只要每次不一样就可以,长度其实没有限制。

附上我的算法:

private static func calculateOauthNonce() -> String {
    let uuidString = UUID().uuidString
    let randomStringIn32Bytes = uuidString.substring(to: uuidString.index(uuidString.startIndex, offsetBy: 32))
    let dataOfRandomStringIn32Bytes = randomStringIn32Bytes.data(using: .utf8)!
    let base64EncodedStringOfRandomStringIn32Bytes = dataOfRandomStringIn32Bytes.base64EncodedString()
    
    let modifiedBase64EncodedString = base64EncodedStringOfRandomStringIn32Bytes.unicodeScalars
        .filter {
            CharacterSet.alphanumerics.contains($0)
        }
        .reduce("", { (result, unicodeScalar) -> String in
            let character = Character(unicodeScalar)
            return result + String(character)
        })
    
    return modifiedBase64EncodedString
}

percent encode

在计算headersignature等处,需要使用到percent encode。百分比化,即把不在范围内的字符,转化为带%号的形式。按照官方的规则percent encode的计算方法如下:

let nonePercentedCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
private func percentedString(_ source:String) -> String {
    return source.addingPercentEncoding(withAllowedCharacters: nonePercentedCharacterSet)!
}

signature base string

signature base string是计算signature的中间步骤。这里有一点要注意,即键值对的key要按照字母顺序排列。这是为了计算校验值的唯一。

这里有一个坑,官方例子提供的值是
POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521

它去掉百分号实际上是

let s2 = "POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521"

print(s2.removingPercentEncoding!)

// POST&https://api.twitter.com/1/statuses/update.json&include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21

可以看到,url实际是https://api.twitter.com/1/statuses/update.json,但是官方文档中写url是https://api.twitter.com/1.1/statuses/update.json。这里一个是1,一个是1.1,你按照文档的url来算,那么signature肯定就和官方提供的tnnArxj06cWHq44gCs1OSKk/jLY=不一样。

signing key

标准的signing key由两部分组成,百分比化的Consumer secret与百分比化的OAuth token secret,二者按照先后顺序用&链接,需要注意两点:

  1. Consumer secret而不是Consumer Key
  2. &不需要百分比化

当首次申请时,由于没有OAuth token secret,此时的signing key为百分比化的Consumer secret之后加上&

signature

这里直接调用加密的函数就可以,值得说的部分是,官方这里要求使用HMAC-SHA1,但是输出值不是普通的字符串,而是要转成base64编码的字符串输出。网上能找到的例子,一般都只是普通的字符串输出,这里提供我的算法,主要是增加了base64编码的输出,以及一些重构。

import Foundation

enum CryptoAlgorithm {
    case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
    
    var HMACAlgorithm: CCHmacAlgorithm {
        var result: Int = 0
        switch self {
        case .MD5:
            result = kCCHmacAlgMD5
        case .SHA1:
            result = kCCHmacAlgSHA1
        case .SHA224:
            result = kCCHmacAlgSHA224
        case .SHA256:
            result = kCCHmacAlgSHA256
        case .SHA384:
            result = kCCHmacAlgSHA384
        case .SHA512:
            result = kCCHmacAlgSHA512
        }
        return CCHmacAlgorithm(result)
    }
    
    var digestLength: Int {
        var result: Int32 = 0
        switch self {
        case .MD5:
            result = CC_MD5_DIGEST_LENGTH
        case .SHA1:
            result = CC_SHA1_DIGEST_LENGTH
        case .SHA224:
            result = CC_SHA224_DIGEST_LENGTH
        case .SHA256:
            result = CC_SHA256_DIGEST_LENGTH
        case .SHA384:
            result = CC_SHA384_DIGEST_LENGTH
        case .SHA512:
            result = CC_SHA512_DIGEST_LENGTH
        }
        return Int(result)
    }
}

enum HashStyle {
    case string, base64EncodedString
}

extension String {
    func hmac(algorithm: CryptoAlgorithm, key: String, hashStyle:HashStyle = .base64EncodedString) -> String {
        let str = self.cString(using:.utf8)
        let strLen = self.lengthOfBytes(using:.utf8)
        let digestLen = algorithm.digestLength
        let hashBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: digestLen)
        let keyStr = key.cString(using:.utf8)
        let keyLen = Int(key.lengthOfBytes(using:.utf8))
        
        CCHmac(algorithm.HMACAlgorithm, keyStr!, keyLen, str!, strLen, hashBytes)
        
        let digest:String
        switch hashStyle {
        case .string:
            digest = calculateString(from: hashBytes, length: digestLen)
        case .base64EncodedString:
            digest = calculateBase64EncodedString(from: hashBytes, length: digestLen)
        }
        
        hashBytes.deallocate(capacity: digestLen)
        
        return digest
    }
    
    private func calculateString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let string = (0..<length).reduce("") { (result, index) -> String in
            return result + String(hashBytes[index])
        }
        
        return string
    }
    
    private func calculateBase64EncodedString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let data = Data(bytes:hashBytes, count:length)
        
        return data.base64EncodedString()
    }
}

计算header

原文:Authorizing a request
这个没啥好说的,按照说明就行了。header和计算signature不一样,header里的键值对的顺序可以是任意的。

调用API

全都计算好了,就可以申请调用API了。简单的步骤如下:

let url = URL(string: "https://api.twitter.com/oauth/request_token")!

var request = URLRequest(url: url)
request.httpMethod = "POST"

var tt = Twitter()

request.setValue(tt.postOauthRequestToken.header, forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) {
    data, response, error in
    
    if error != nil {
        print("error=\(error!.localizedDescription))")
        return
    }
    
    print("response = \(response!.description)")
    
    let responseString = String(data: data!, encoding: .utf8)!
    print("responseString = \(responseString)")
}
task.resume()

如果看到输出的response里的代码是200,那么就证明你的程序是没有问题的了。

  1. 创建校验码的方法

  2. 需要注意的事,计算校验码时需要的key的一部分是Consumer Secret而不是Consumer Key,Twitter的部分文档的例子里没有强调这一点,并切没有提供例子对应的Consumer Secret,这导致了那些例子你是没法计算的,也就是说例子根本是无效的。研究它们只能浪费时间。这一点后面还会提到。

深入探讨NotificationCenter的addObserver(forName:object:queue:using:)中的queque

iOSwatchOSmacOS

先看看下面的代码,这两段notiObserver有区别吗?

override func viewDidLoad() {
    super.viewDidLoad()
    
    let noti = Notification(name: MyNotificationName)
    var v = 0
    let center = NotificationCenter.default
    var notiObserver:NSObjectProtocol! = nil
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: .main) {_ in 
        if v == 10 { center.removeObserver(notiObserver) }
        else {
            v += 1
            center.post(noti)
        }
        print(v)
    }
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: nil) { _ in
        DispatchQueue.main.async {
            if v == 10 { center.removeObserver(notiObserver) }
            else {
                v += 1
                center.post(noti)
            }
            print(v)
        }
    }
    
    center.post(noti)
}

无论你觉得有区别,还是没区别,你可以分别注释掉其中一个然后运行。运行结果是这样的。

上面的那段会输出

10
10
10
10
10
10
10
10
10
10
10

下面那段会输出

1
2
3
4
5
6
7
8
9
10
10

这是为什么呢?我们来看文档。苹果的文档在提到queue是这么说的。

queue
The operation queue to which block should be added.
If you pass nil, the block is run synchronously on the posting thread.

也就是说,它默认是在队列中同步执行。即便我们换成了.main,它也不是我们以为的是异步执行的。实际上,我们可以测试出来,这段代码,在运行时,是堆栈式的。新到的Notification,会进行压栈,优先执行。然后才是旧的Notification没执行完的代码。

如图所示,从左到右是时间轴,依次运行的3段代码,绿色的center.post(noti)导致黄色被执行,黄色的center.post(noti)导致蓝色被执行……,一直到v==10,最后的一段代码print(v),才会从上到下执行下来。

stack
结论:如果我们想要代码符合我们的预期的顺序,应该使用第二种方式,通过GCD,将代码加入到顺序执行的queue中去。

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

延迟处理的妙用

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

参考资料