肇鑫的技术博客

业精于勤,荒于嬉

macOS菜单栏状态项小结

所谓菜单栏状态项,指的是macOS顶部菜单栏右侧的那一排图标。Windows里叫系统托盘,macOS就叫菜单栏状态项。后面简称状态项。

获取状态项并制定图标

let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)
guard let button = statusItem.button else {
    fatalError()
}
        
button.image = NSImage(imageLiteralResourceName: "TrayIcon")

图标的图片选择40x40像素的,放在2x下。

添加响应或菜单项

这里是一个坑。虽然状态项支持单击和显示菜单,但是苹果规定,当存在菜单时,单击无效,而只会显示菜单。

var menu: NSMenu?
When non-nil, the status item’s single click action behavior is not used. The menu can be removed by setting the value of this property to nil.

所以你要么添加button的点击功能,要么选择构建一个菜单。这个坑可以通过技术手段绕过,我们后面再提。

鼠标左键和鼠标右键对应不同的响应

状态项默认只支持鼠标左键的点击,而不支持其它的鼠标事件。如果想要支持鼠标右键的点击,我们需要自行实现。

https://stackoverflow.com/questions/32188581/call-action-when-nsstatusbarbutton-is-right-clicked

我选择是上面链接中的方案,新建一个自定义的视图,该视图支持鼠标右键点击,然后再将该视图添加到button上。

自定义的视图:

class MouseRightClickView: NSView {
    var closure:(() -> ())!

    override func rightMouseUp(with event: NSEvent) {
        super.rightMouseUp(with: event)
        
        closure()
    }
}

同时支持鼠标单击和菜单项

现在状态项支持鼠标左键和鼠标右键了,下面我们来实现鼠标左键点击“显示/隐藏应用”,右键点击“显示菜单项”。

guard let button = statusItem.button else {
    fatalError()
}
    
button.image = NSImage(imageLiteralResourceName: "TrayIcon")
button.action = #selector(mouseLeftButtonClicked)
    
// Add mouse right click
let subView = MouseRightClickView(frame: button.frame)
subView.closure = {
    self.constructMenu()
    button.performClick(nil) // menu won't show without this
}
button.addSubview(subView)

大家注意看,创建完成菜单后,必须手工添加一个按钮的点击。如果没有这个,那么只会生成菜单,而不会立即弹出菜单。最后,由于苹果规定如果存在菜单,则不会响应鼠标点击,我们必须在每次菜单关闭时,自动去掉菜单。这就需要设置菜单的代理。

private func constructMenu() {
    let menu = NSMenu()
    menu.delegate = self
    ...
}

菜单代理

extension AppDelegate:NSMenuDelegate {
    // remove the menu or later mouse left click will call it.
    func menuDidClose(_ menu: NSMenu) {
        statusItem.menu = nil
    }
}

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

推特仅支持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分享视频到共享扩展的一些限制的小结

通常,我们使用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这么做实际上是错误的。但是为了适应它,才需要调整我们的代码。