肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

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

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中自定义应用名的坑(更新)

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如图的位置更改项目名。其它方式都只能更改目标语言的显示名,而不能更改默认语言的。

macOS菜单栏状态项小结

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

Locale苹果官方文档补遗

通用

我们知道,在Xcode中可以通过指定运行时的语言,来测试相应的翻译。如图,我指定了中文作为在测试时的语言。

测试时指定语言

这个方式,在测试翻译的情况下是没有问题的。

最近我需要在应用中通过WKWebView,根据用户所使用的语言来动态加载网页。为了达到这个目的,我使用了

Locale.current.languageCode

根据苹果的文档,languageCode的含义是

The language code of the locale, or nil if has none.

但是实际使用中,我发现它并不能返回zh

iOS_12.1_Locale_iPhoneX

如图,实际上返回的是en_CN

我就此问题向苹果提交了错误报告。苹果回复说,从macOS 10.13, iOS 11开始,Locale.current.languageCode返回的实际上是Bundle.main.preferredLocalizations.first。这是因为苹果认为这样的用户体验最好。

苹果举例说,如果一个用户对于语言的偏好顺序是[ "zh-CN", "de-CN", "en-CN" ],而某个应用仅支持德语和英语,那么Locale.current.languageCode返回的就是de_CN

按照苹果的建议,如果想要测试中文,那么第一步就是添加中文的翻译。于是我添加了简体中文的翻译。这一下,简体中文的内容正确了。

简体中文

另外,对比一下没有中文翻译时,iOS 10和iOS 11的不同。

Locale 对比

事情还没有完,因为我希望的是用户懂中文时,使用中文;否则使用英文。而现在的方式仅能识别简体中文,当用户使用繁体中文时,还是会显示英文。于是我改了代码,新的代码

private func isChinese() -> Bool {
    let languages = Locale.preferredLanguages
    
    for lang in languages {
        if lang.hasPrefix("zh") {
            return true
        }
    }
    
    return false
}

iOS、watchOS应用用户设置相关问题的答案

watchOSiOS

最近在写咕唧2,对于iOS应用与配套watchOS应用之间的设置的相关问题有了一些心得。记下来,备查。

问题与答案

1. 创建的Settings.bundle文件放在哪里?

无论iOS还是watchOS的,都放在iOS应用下。

2. iOS应用的设置,为什么watchOS应用不能直接读了?

这是一个历史遗留问题。1代的手表系统,当时还不叫watchOS,上的应用只有一种,叫瞥一眼(glance)。生成机制是所有的一切都在手机上生成,然后传到手表上显示。因此,当然手机应用和手表应用实际上都在手机上运行。因此,直接通过分享组(share group),手表应用就能读取手机应用的设置。

watchOS 2开始,手表应用改在手表上运行了。而手表上的共享组和手机上的共享组之间不自动同步,所以手表和手机之间的设置不能直接读了。

3. iOS应用如何获得watchOS应用的设置?

不能直接读。因为苹果规定,手表应用可以唤醒手机应用,反之则不行。

4. watchOS应用如何获得iOS应用的设置?

通过session的sendMessage方法唤醒手机,然后手机发送设置给手表。

5. 如何设置watchOS应用的设置?

通过手机端的手表应用。这个应用虽然在手机上,但是可以直接读手表应用的设置。并且会写回去。

一张图

iOS与watchOS的设置同步

参考资料

自定义UITableViewCell的选中背景

iOS

默认情况下,UITableViewCell的选中效果如图。单独选中的效果给人的感觉不是那么可靠。

但从编程角度看,使用选中状态比较简单。不使用选中,而采用标记替代选中比较麻烦。因为这个对号是属于UITableViewCellaccessoryType属性。它并不是和选中状态同步的。需要单独、小心地处理,稍有不慎,就会出现各种错误。

解决思路

创建一个背景透明的对号标记

我最初的想法是创建一个背景透明的对号标记。然后把它应用在UITableViewCellselectedBackgroundView属性上。

这条路不好走。因为一旦制定了selectedBackgroundView。表格之间的分隔线就没有。解决方案有利用各种方式画分隔线的。比较麻烦。

在我采用默认的表格,并允许多选时,我发现苹果的默认选中,就是没有分割线的,只不过它用了有颜色的背景。这在单选时不明显,一旦有多选,就很明显。

创建一个有背景色的对号标记

创建好的图片如图。

checkmark_background@2x

用1x的语言描述,背景:宽320,高44。对号:宽16,高32,水平居中,右边距16。

难点来了

我们知道,不同格式设备的宽度是不一样的。那么对于320这个iOS设备的最小宽度,这个图片在遇到更宽的设备时是需要拉伸的。

关于如何拉伸图片,我们可以看这篇:UIImage图片拉伸平铺(resizableImage)

看着不难,实际上全是坑。因为思维习惯不是直接的,是间接的。

简述一下拉伸图片的原理

如图,如果有一个图片,被分成1-9共9个部分。那么它的四个角的1、3、7、9图形保持不变。我们通过定义2/上、4/左、8/下、6/右的方式,来确定5的范围,然后选择拉伸5。这个就是定义图片拉伸的原理。

1 2 3
4 5 6
7 8 9

下面应用这个原理,放大上面的带对号的背景图。

checkmark_background 1-9@2x

因为我们需要保证对号的位置不变,所以要把它画在3的位置。这是唯一的限制。根据这个限制我们可以计算出。

  • 上:
    • 上边距 = (背景高 - 对号高) / 2 = (44 - 32) / 2 = 6
    • 上 = 上边距 + 对号高 = 6 + 32 = 38
  • 右 = 对号宽 + 右边距 = 16 + 16 = 32

至于下和左,我们因为要尽量保证5越大越好,就都取1。

最终代码如下:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "sampleCell", for: indexPath)
    
    // 获得背景图
    let image = #imageLiteral(resourceName: "Checkmark_background")
    // 指定拉伸范围
    let edgeInsets = UIEdgeInsetsMake(38, 1, 1, 32)
    // 获得拉伸图片
    let resizableImage = image.resizableImage(withCapInsets: edgeInsets, resizingMode: .stretch)
    // 生成图片视图,大小与表格项相同
    let imageView = UIImageView(frame: cell.bounds)
    // 指定图片
    imageView.image = resizableImage
    // 指定表格项选中时的背景视图
    cell.selectedBackgroundView = imageView
    // 设定文字
    cell.textLabel?.text = "条件\(indexPath.row)"

    return cell
}

最终效果

如图:

fina

UIImageView异步加载导致问题的解决

iOS

UIImageView加载了一张图片之后,如果再加载另外一张图片,然后立即删除第二张图片。则UIImageView会变黑。

演示代码如下:

import UIKit

class ViewController: UIViewController {
    lazy var image = { () -> UIImage? in
        guard let path = Bundle.main.path(forResource: "poster_icon_mac_1024", ofType: "png") else {
            return nil
        }
        
        return UIImage(contentsOfFile: path)
    }()
    
    lazy var replaceImage = { () -> UIImage? in
        guard let path = Bundle.main.path(forResource: "Miss Devil 恶魔人事·椿真子.Miss.Devil.Jinji.no.Akuma.Tsubaki.Mako.Ep05.Chi_Jap.HDTVrip.1280X720-0001", ofType: "png") else {
            
            return nil
        }
        
        return UIImage(contentsOfFile: path)
    }()
    
    lazy var saveImageURL = { () -> URL in
        let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
        let fileName = UUID().uuidString + ".jpg"
        
        return URL(fileURLWithPath: fileName, isDirectory: false, relativeTo: baseURL)
    }()
    
    var latestImage:UIImage? = nil

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        addImage()
        
        DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
            self.createImage()
            self.loadImage()
            self.changeImageViewToLatestImage()
            self.removeLatestImage()
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @IBOutlet weak var imageView: UIImageView!
    
    func addImage() {
        imageView.image = image
    }
    
    func changeImage() {
        imageView.image = replaceImage
    }
    
    func createImage() {
        let imageData = UIImageJPEGRepresentation(replaceImage!, 1.0)
        FileManager.default.createFile(atPath: saveImageURL.path, contents: imageData!, attributes: nil)
    }
    
    func loadImage() {
        latestImage = UIImage(contentsOfFile: saveImageURL.path)
    }
    
    func changeImageViewToLatestImage() {
        imageView.image = latestImage
    }
    
    func removeLatestImage() {
        try! FileManager.default.removeItem(at: saveImageURL)
    }
}

分析与解决

这个问题的造成,是因为为了性能,UIImageView其实是异步加载的。在它加载完成之前,如果图片被删除了,就会加载不到,变黑。

有人提出,可以将图片通过Data类型,加载到内存中,然后将内存中的图片加载到UIImageView的方式,来绕过这个问题。这个思路的确能解决这个问题,但是毕竟多占用了内存。不算是最好的方式。

其实,我们知道了原理,就等UIImageView加载完成之后,再删除图片就好了。虽然苹果并没有告诉我们图片何时加载好。但是我们知道,类似更新UI的这种操作,必然是在UI线程完成的,即main线程。那么我们只需要在main队列排队一下就可以了。

将代码

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage()
    self.removeLatestImage()
}

改成

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage()

    DispatchQueue.main.async { [unowned self] in
        self.removeLatestImage()
    }
}

思考

为什么上面的代码会成功呢?这是因为第一段代码的运行顺序是

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage() // 异步操作,押后运行
    self.removeLatestImage() // 先删除了图片,之后才进行的图片加载
}

而修改之后的代码是

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage() // 异步操作1,押后运行

    DispatchQueue.main.async { [unowned self] in // // 异步操作2,押后运行
        self.removeLatestImage()
    }
}

由于main队列是一个包执行完成之后,才会执行下一个。因此实际上修改后的代码是在图片加载完成之后才删除图片的。这就避免了这个问题。

循环结构中异步代码实现灵活退出(下)

Swift

循环结构中异步代码实现灵活退出(上)

上一篇中,我们实现了基本结构。但是如果每次都这么做,会比较麻烦。这一篇中,我们尝试将代码封装起来,这样以后我们再做时,只需调用一次就可以了。

首先,我们将之前的代码变成closure。

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        concurrentQueue.async {
            self.run()
        }
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i, withClosure: { (result) in
                DispatchQueue.main.async {
                    self.textView.string += "\(i)\n"
                }
                
                if i == 5 {
                    result = true
                }
            }) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int, withClosure closure: @escaping (inout Bool) -> ()) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)
        
        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            closure(&result)
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }

    @IBOutlet var textView: NSTextView!
}

接下来,我们新建一个文件RangeEnumerator.swift,扩展Sequence

import Foundation

extension Sequence {
    public func breakableForEach(closureWithCondition: @escaping (Element, inout Bool)->()) {
        let semaphore = DispatchSemaphore(value: 0)
        
        DispatchQueue.global().async {
            for e in self {
                if self.shouldBreak(semaphore, e, withClosure: closureWithCondition) {
                    break
                }
            }
        }
    }
    
    private func shouldBreak(_ semaphore:DispatchSemaphore, _ e:Element, withClosure closure: @escaping (Element, inout Bool) -> ()) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)
        
        DispatchQueue.global().asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            closure(e, &result)
            semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
}

之后,最初的代码就可以简化为。

class ViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        (1...10).breakableForEach { (i, result) in
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }

            if i == 5 {
                result = true
            }
        }
    }
    
    @IBOutlet var textView: NSTextView!
}

另外,由于扩展中不能创建存储变量,所以,之前的信号量和队列,就必须放在函数里了。