肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

NavigationSplitView的一些奇技淫巧

SwiftUI

Sidebar按钮隐藏起来的正确做法

使用

.toolbar(removing: .sidebarToggle)

可以隐藏侧边栏切换按钮。但是注意,这个不是放在作用在NavigationSplitView上面的。而不是作用在sidebar的视图上面。即必须按照如下的方式使用:

NavigationSplitView(columnVisibility: $columnVisibility) {
    sidebar
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

隐藏Sidebar的正确做法

NavigationSplitView是三栏的,但是如果你要使用两栏,则有两种方式。

使用sidebar

使用sidebar的好处是,sidebar可以隐藏,也可以显示。
使用sidebar+detail的方式。

不使用sidebar

如果你希望永远显示为两栏,不希望隐藏任何一个栏隐藏,那么使用此方式。

NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
    EmptyView()
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

这个的重点在于,通过sidebar设置隐藏了切换sidebar按钮。同时,选择两栏模式,默认不显示sidebar。这样sidebar就无法被调用出来了。并且一直是两栏。

配合Settings使用

在使用Settings时候,包含sidebar的NavigationSplitView的sidebar按钮会导致出现问题。因此,必须使用上面的第二种方式,隐藏sidebar,并且使用一直存在的两栏。

macOS在工具栏上切换页面,TabView和Picker怎么选?

macOS

目前的SwiftUI,如果你希望通过工具栏的按钮直接切换标签,可以使用TabView,也可以使用Picker,不过在细节上,二者有所不同。

TabView

TabView是最简单的方式,只要你应用的根视图为TabView,那么你的系统架构会自动转成Navigation Tab Bar的方式,自动在工具栏显示Tab的标签。

不过这个方式有一种缺陷,就是应用的标题不会在工具栏上显示。如果你想强制限制,必须引入AppKit然后,覆盖Window的相应设置。

Picker

使用Picker的方式则更为友好。之需要在toolbar里添加Picker,然后使用segment样式,就可以获得同样的显示效果。然后使用Switch切换视图即可。

这么做不如直接使用TabView简单,但是可定制化强,并且不会影响标题的显示。

结论

如果不需要设置标题栏,使用TabView更为简便。否则就使用Picker,这样效果更好。

macOS系统菜单栏显示多图标的两种方式

macOS

一直以来都是使用的菜单栏单图标的方式。今天心血来潮,想在Focus原本的图标旁边新增一个刷新的图标,可以用来快速重置计时器。

把任务分配给AI,AI很快完成了第一版。我运行一看,怎么没看到新图标,再仔细一找,的确是两个图标了,但是两个图标是各自独立的,彼此之间还间隔了几个其它的图标。这和我看到的不一样,我其实希望的是像音乐播放器那样的,几个图标一体的方式。于是我和AI说明了要一体的。AI表示了解,然后重新生成了代码。AI特意解释说,一体之后,点击时就需要用点击的位置进行二次判断,还确定用户点击的是哪个图标。

最终结果我实验了一下,的确和音乐播放器的一样。看来他们也是这么做的。

SwiftUI应用伴随系统登录自动启动后显示macOS应用的窗口的办法

macOS

我们知道SwiftUI应用本身没有应用窗口的概念,它是使用WindowGroup来自动管理窗口的。这在用户手动启动应用的时候没有问题。但是如果你设置了应用伴随系统登录后自动启动后,SwiftUI的应用会存在一些问题。

这是因为这种方式启动应用后,SwiftUI的应用不会主动创建窗口,视图会在用户手动点击应用之后才创建。而这可能不是我们所需要的。因为如果我们有使用onAppear来执行一些代码。我们实际上是希望代码可以在应用启动时就运行,而这个机制会导致执行会推迟到用户点击时。

因此,我们需要保证即便SwiftUI没有生成Window,也要自己主动来生成Window。

问题分析

要解决这个问题,我们首先要了解这个启动的整个过程,然后才能知道如何来改进。具体调试的过程我就不讲了,最终我确认启动的过程是这样的:

  1. 用户登录。
  2. 应用启动。
  3. 应用在后台启动,但是没有主窗口,因此无法自动切换到前台。

我们可以使用NSApplication.shared.windows.count来判断。如果是用户手动打开的应用,SwiftUI会创建SwiftUI.AppKitWindow的窗口。而如果是跟随系统登录后后台启动,则不会有这个窗口。

我使用的判断函数是这个:

func isLaunchedAtLogin() -> Bool {
  NSApplication.shared.windows.count < 2
}

之所以用2,而不是1判断。是因为我还使用了菜单栏图标,菜单栏图标使用的是NSStatusItem,因此还会包含一个叫NSStatusWindow的窗口。

解决方案

func applicationDidFinishLaunching(_ notification: Notification) {
    createContentViewWindow()
}

/// 用户启动时SwiftUI的窗口会先创建,因此window不会为nil,但是有可能存在延迟,因为是通过.updateWindow还获取的。
/// 所以这里使用其他的方式进行判断,而不是使用window是否为nil
private func createContentViewWindow() {
  /// 若是用户点击,会有两个窗口,一个是SwiftUI创建的主窗口,一个status窗口。后者应该是对应菜单栏图标的。
  /// 如果是伴随系统启动,则SwiftUI创建的主窗口不存在,只有status的窗口。
  func isLaunchedAtLogin() -> Bool {
    NSApplication.shared.windows.count < 2
  }

  if isLaunchedAtLogin() {
    let contentView = ContentView()
      .environment(\.managedObjectContext, ModelProvider.shared.container.viewContext)
    let window = NSWindow(
      contentRect: NSRect(x: 0, y: 0, width: 400, height: 240),
      styleMask: [.titled, .closable, .miniaturizable, .resizable],
      backing: .buffered, defer: false)

    window.setFrameAutosaveName("Main Window")
    window.contentView = NSHostingView(rootView: contentView)
    window.center()
    window.makeKeyAndOrderFront(nil)
    self.window = window
  }
}

小结

传统上,如果我们使用loginItem来实现应用伴随系统启动,那么为了区分是用户手动启动,还是伴随系统启动,需要使用传递参数的方式。但是传递参数,就需要使用额外的launcher辅助应用。

设置辅助应用的步骤是很复杂的。因此,我们现在大多都是直接使用下面的代码来直接使用自动伴随系统登录启动。

try SMAppService.mainApp.register()

这个办法虽然大大简化了设置系统启动后启动应用的步骤。但是这么做之后,由于没有辅助应用,也就没法使用传递参数的办法了。

本文给出的使用NSApplication.shared.windows.count,用窗口数量来间接判断的方式,利用了SwiftUI在后台启动后,不会主动创建窗口的特性,解决了这个问题。

解决Xcode Cloud无法enable Swift Package包中的宏的问题

Swift Packages

最近我在做应用适配iOS/macOS 26的特性。今天在Xcode Cloud打包的时候遇到打包失败的错误。

Macro “DefaultsMacrosDeclarations” from package “Defaults” must be enabled before it can be used.

这个问题是我应用所使用的第三方的库 “Defaults”在其内部使用了宏。这个宏在Xcode本地编译时,需要用户手动点击确认才能继续。但是Xcode Cloud中,没有点击确认的位置。因此,就无法完成打包应用的过程。

解决办法

通过运行脚本的方式,在克隆完文件夹之后,运行脚本,规避掉对于宏的验证。

#!/bin/sh 
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES

必须在Xcode中的根部位置,创建一个新组,命名为ci_scripts,然后在这个组中创建ci_post_clone.sh,内容是上面的内容。

必须在Xcode的根部位置创建组,并且命名也不能错。

小插曲

我其实最开始是像GPT 4.1提出了这个问题。GPT 4.1的解答只对了一半。它提出了创建文件夹和脚本,文件夹是正确的,脚本名字是错误的。并且它也没有告诉需要在Xcode中创建组,而只是说在项目的根目录创建就可以。最后,它创建的脚本内容不完全正确。

之后我使用了Google搜索。Google搜索默认的AI总结的是正确的,但应该就是从stackoverflow里的答案总结的。我最后是看的SO里的回答,进行的总结。

另外,我建议你完整阅读下面的第一个引用。我使用了里面最为简便的方案。而非最安全的。也许你看了之后,会选择一条不同的手段。

引用文献

How do I trust a swift macro target for Xcode Cloud builds?

Writing custom build scripts

什么?AccentColor又闹幺蛾子了?

SwiftUI

一年以前,我就踩过一次AccentColor的坑。没想到,一年之后我又掉进来了。

SwiftUI下,TextField诡异失去Focus下样式的问题

事情的起因是这样的。因为苹果的新系统发布了嘛。我不能免俗的也要改进我之前的应用,添加对于新系统特性的一些支持之类的。

但是我在修改代码后测试时发现,当使用ZStack模拟弹窗之后,弹窗后面的视图的颜色会出错。我一开始以为是ZStack的问题,于是将模拟弹窗改成了.fullScreenCover的方式。这个问题在当时看起来时解决了。但是今天我在使用中发现,这个问题又重新出现了。

于是我在Google上搜索了一下,没想到这还是一个SwiftUI长期存在的一个问题。

How do I stop the AccentColor from turning Gray when a sheet is being presented?

原来在SwiftUI中实际使用时。原本应该一致的Color.accentColor和Color("AccentColor")在实际使用中是不一致的。说得更具体些,就是Color.accentColor会使用应用设置和用户的系统设置。而Color("AccentColor")则是将AccentColor作为颜色资源从Asset文件夹直接读取。因此,虽然它的名字也叫"AccentColor",但是实际上它只是名字叫"AccentColor"的一个颜色,你改成别的名字,比如"MyAppColor"也是一样的。虽然这样会失去Color.accentColor一些独特的个性,但是能保证颜色的一致,即颜色不会莫名其妙的改变。

系统弹sheet的时候,Color.accentColor会改变的问题,应该就是sheet本身可能存在某种机制,将应用内设置的Color.accentColor从Asset文件夹设置的内容,改成了系统默认设置的内容。比如下图红色圈起来的部分,就是系统允许用户自定义用户AccentColor偏好的地方。

accent_color

大模型又暴露了……

大模型

这真的不是危言耸听,这是真的骇人听闻。盘点一下我遇到大模型的智障行为,本文不定期更新。

大模型至今不了解SwiftUI的更新机制,必须要严格约束,小心使用

今天遇到了一个sheet弹窗之后,视图更新不同步的问题。由于我已经在提示词中,告诉了我倾向使用@Observable,而不是旧版的ObservableObject,所以一开始大模型创建了正确的@Observable class,然后在视图中使用@State创建了这个model的实例。

但是为什么还是出错了呢?那就要看使用时更具体的实现了。在开启sheet之前,大模型调用了一个准备函数,在这个函数中,对于model进行了一系列的初始化。具体的步骤是这样的,先新建一个model,然后修改它,然后用这个修改完成的model,替换为系统@State里的那个model。

我首先将问题描述给大模型,sheet首次打开的时候,显示的界面并不符合预期,应该有值的地方,实际上为空。但是如果关掉,再次打开,就又正确了。

大模型思考了一番,说这可能是sheet的机制造成,sheet有时会提前锁定一些值,这会造成打开视图时的不同步。它的建议是,在sheet打开的视图内部的onAppear中新增一个fallback的调用,重新检查并赋值。然后还说,如果这样还不行,可以考虑使用GCD延迟的方式,也就是使用DispatchQueme.main.async调用来实现改动。

试了,onAppear的确不行。但是我没有继续使用它建议的GCD延迟的方式。我问它是否还有其它的办法。

大模型想了想,说可以考虑将@Observable的class改成纯粹的struct的方式。然后就其次咔嚓地修改起来,我一看改动后的代码,妈呀!连mutating都搞出来了。SwiftUI的代码哪有这么写的啊?我果断点了Undo,取消了这次修改。

正确的方式

我再次看了一下代码,突然我发现了问题的所在。前面提到,在准备函数中,大模型的改动方式是“先新建一个model,然后修改它,然后用这个修改完成model,替换为系统@State里的那个model。”这个方式在SwiftUI中显然是错误的。SwfitUI中@State的class类型,你不能替换它,因为一替换之前的跟踪就被中断了。

所以我和大模型说,我们不应该“新建model,修改,然后替换@State”,而是应该直接修改@State中的变量。

大模型想了想,觉得我说得有道理,就修改了。然后还跟我说,如果我确定这个好用,那么onAppear那里的代码,就可以删掉了。

于是我先注释掉onAppear那里的代码,然后运行应用,果然一切都正常了。

小结

大模型并没有真正了解SwiftUI的更新机制,这部分的代码写的时候,它更像是一个拙略的模仿者。虽然这部分,我之前的领悟也不怎么深刻,但是我随着持续不断地学习和使用SwiftUI,现在在这方面,我有信心可以说,我可比大模型强多了。

大模型不擅长重构

这个说法听起来是反直觉的。大模型相比于人类,应该更擅长重构吧。为什么说它不擅长呢?

事实如此。昨天我想重构一个900多行的SwiftUI的文件,里面包含一个主视图,主视图下的视图组件,以及一个弹出视图。如果是开发者来重构这个文件,之需要新建文件,然后复制,粘贴,再删掉旧的就可以了。可以很快就完成。

但是我用大模型来重构。却接连几次失败。过程一般是这样。一开始大模型计划的挺好。

  • 我将会将文件拆分为以下几个文件。
  • 然后开始拆分,但是执行到5、6个的时候。
  • 弹出出错提示,上下文已经耗尽了。

我为了节省上下文,又单独增加了对于上下文节省的办法。每次重构一个文件,复制、粘贴、删除完了,验证有没有错误。有错误修复,没有再继续。并且可以抛弃掉使用过的上下文。

再次运行,稍微好了些,多实现几个文件,但是最后还是因为上下文不够而失败了。

小结

大模型不擅长重构,不是因为它无法规划重构。而是因为大模型重构时需要考虑大量的上下文信息,最终会因为上下文耗尽而无法继续。

苹果表无法解锁macOS问题的一个奇葩的解决办法

macOS

我实在无法找到更适当的词,只能用奇葩来形容这个解决办法。

我开始使用macOS 26 beta系列也有一段时间了,目前在使用的最新版本是beta 7。长久以来,我一直遇到一个有些奇怪的问题,就是每次系统睡眠之后唤醒,苹果表的解锁总是失败。但是进入系统之后,在需要苹果表解锁的其它情况下,比如打开密码应用或者钥匙串,双击苹果表侧键解锁的这个功能却总是能成功。

不过苹果表解锁也不总是失败。我有两台Mac mini,一台是M1,一台是M4,M1的解锁就总是成功,而M4这台就总是失败。

尝试解决这个问题

今天我突发奇想,想要在AI的辅助下解决这个问题。我首先怀疑的时候VPN软件。因为更新到最新的macOS 26 beta之后,原来使用Clash X Pro不好用了,我不得不换成了Clash Verge rev。我问AI有没有可能是VPN软件造成的。AI说有可能。让我关了它再试。我试了。还是一样。

之后我又和AI一起开启了macOS控制台应用,想尝试通过读取日志来找到问题的解决方案。最后发现了一个loginwindow的一条故障日志,故障(红色)是比错误(黄色)等级更高的错误。它说在创建main文件夹的某个临时文件夹时出错,可能是沙盒的问题。我当时也信了,因为我的M4是256G的,为了节省磁盘空间,我将home文件夹设置到了外置的SSD上。我想这种比较罕见的设置,可能是苹果没有考虑过。我甚至还差一点儿就去跟苹果反馈这个问题。但是后来我放弃了。因为我觉得每次输入密码也还好,不算很麻烦,就没有反馈。同时我在想,以后我再买新电脑,一定要多花些钱,买个大一些的硬盘。

山重水复疑无路

的确。我没能解决这个问题。我只是放下了它。然后,我开始着手解决我的另外一个开源应用的小问题。

App Helper,应用助手。是我开源在GitHub上的一个macOS的助手应用。它的其中一个功能,是一键切换HDR模式,即在开启和关闭之间切换。不过,我发现,我的显示器,虽然支持HDR,但是使用不同的连接方式它对于HDR的支持不同。比如用USB-C线直接连接,就不支持HDR,用HDMI或者DisplayPort线连接,就支持。

所以,我的目标是通过系统API检测,在不支持HDR的时候,隐藏这个一键切换的按钮。因为我当前是USB-C的连接,不支持HDR,所以我首先完成了这部分的代码。

因为我还需要测试支持HDR下的部分。于是我同时使用HDMI进行连接。macOS有一个问题,就是你用两根不同的线连接同一个显示器,但是在macOS看来你就是在使用双显示器。它的显示器设置中,同时显示出两台显示器,并且不能设置禁用其中的一个。这样就比较麻烦了。因为我虽然可以通过显示器上的信号源菜单来切换到不同的接口,但是存在一个主副窗口的问题。

最终没办法,我只能将Mac mini上的USB-C连接显示器的那根拔了下来。将HDMI的连接作为唯一的连接,这样可以方便我进行调试。

柳暗花明又一村

这次HDR的选项还是不能显示。经过调试我发现,必须显示器先打开HDR模式,苹果的API才能检测出显示器支持HDR,如果没有开启,那就检测不出来。

那也无妨,大不了我就跟原来一样,不检测了。我又测试一键切换的功能。结果这个功能也不好用。这就比较郁闷了。因为苹果本身并没有提供切换的HDR切换的API,我使用的是脚本调用控件的方式,这个方法生效的前提是控件的位置必须固定。我管它叫数格子,脚本的写法类似,找到xx组的xx格子,然后把它上面的开关打开/关闭。现在系统升级了,位置变了。苹果💊。

算了,心累。我打算彻底去掉这个功能。毕竟,苹果动动手指,我就得重新数格子,并且还需要考虑不同版本macOS的兼容性,实在得不偿失。

休息,休息一会儿

休息结束之后,重新唤醒已经睡眠的Mac,手表传来熟悉的解锁声。我居然成功解锁了这台M4。又试了几次,无论是睡眠之后立即解锁,还是睡眠了几小时之后再解锁,都是次次成功。

“这是为什么呢?”(蔡明)如是说。“排除了一切不可能,那么剩下的那个无论多么不合理,就是唯一的可能。”——福尔摩斯。

我做的最大改动只有一点,使用HDMI连接显示器,并且拿掉了USB-C的连线。所以,这个问题的原因就是使用USB-C连接显示器,会导致苹果表无法解锁macOS。这谁能想到啊,你说是不是奇葩的解决办法?

特别说明,这个USB-C连接显示器,就是两边都是USB-C接口。而HDMI连接,则是用Mac mini独有的HDMI接口连接显示器的HDMI接口。

理论上,这个USB-C除了传递数据,还能同时获得显示器传来最大90瓦的电量。不过由于Mac mini的USB-C不支持反向供电,所以没啥用。如果比MacBook,是可以同时供电的。但是不知道是不是这一点影响了苹果表的解锁。我不是硬件工程师,不敢妄言。但是觉得这个值得提一下。