这个项目的技术选型没有一条是想当然,每一条都要回答:有哪些选项、选了什么、为什么。下面按重要程度讲最关键的几条。

共同的原则只有一个:让系统更新之后的最坏结果是“某个功能不可用”,而不是“App 打不开”。

语言和界面:Swift 加 AppKit

SwiftUI 的 touchBar 修饰符只作用于自己窗口里获得焦点的视图,做不了后台常驻,底层还是要回到 AppKit。Electron 之类的跨平台方案对 Touch Bar 的支持也只限于自己的窗口,体积和内存都不符合“高效”。Objective-C 调私有接口更顺手,但项目其余部分用 Swift 更安全,Swift 调私有接口的写法也并不复杂。

私有接口:运行时解析,而不是链接

C 函数用 dlopen 加 dlsym,类方法用 class_getClassMethod 和 responds(to:)。缺了哪个,对应功能就关闭,App 照常运行。反过来,如果直接链接私有框架,系统删掉任何一个符号,App 启动时动态链接器就会报错退出。

前者最坏的结果是菜单里显示“当前系统不支持”,后者是 App 打不开。这是“稳定”这个目标里最重要的一条。

显示方式和列表控件

显示方式固定用 placement 为 1(占满整条),原因就是上一篇发现的:0 在“展开的控制条”模式下不显示,而且没法自动检测。列表用 NSScrubber,它是 Touch Bar 原生的可滚动列表,自带惯性滚动,能区分点按和滑动,还能复用视图。用 NSScrollView 加按钮也能做,但点按和滑动的区分、惯性都要自己处理,手感就不像系统的 Dock 了。

NSScrubber 有个小坑:点已经选中的项不会再次回调,所以每次选中后要立刻把选中态清掉,点击时的高亮由图标视图自己来画。

刷新:事件驱动,不轮询

Dock 里固定的 App 读 com.apple.dock 的 persistent-apps;正在运行的 App 用 NSWorkspace 的运行列表,对它做 KVO 监听启动和退出,再加上前台 App 变化的通知。多次变化合并到下一轮 run loop,只刷新一次。

如果只是运行状态变了,就原地更新小圆点,不重建列表,也就不会打断当前的滚动位置;图标位置变了才重新载入。图标按 Touch Bar 需要的尺寸栅格化一次后缓存。整个过程没有任何定时器,所以空闲时不占 CPU。

恢复:只在已知事件之后,不定时抢回

睡眠唤醒、屏幕唤醒、会话切回、屏幕解锁、控制条进程重启,这几个事件之后系统会收回自定义的 Touch Bar。我在事件发生一秒后重新挂上,多个事件合并成一次。控制条进程重启的检测很朴素:它在运行中的 App 列表里,进程号变了就说明重启过。

我没有做“看不见就抢回来”的定时检查。一是 isVisible 本身不可信;二是这样会和截屏工具、Siri 这类同样要用 Touch Bar 的系统功能打架,这正是这类工具不稳定的常见原因。

工程形式和语言

工程用 Swift Package 加 shell 脚本打包:全是纯文本,好对比,Xcode 也能直接打开。代价是 SwiftPM 不会生成 .app,要由脚本拼装二进制、Info.plist、图标再签名。界面文字只有中文和英文两种,直接写成 L10n.tr(中文, English) 的一对,不走 lproj 那一套,这样可以在菜单里单独选语言,改完不用重启。

登录启动和打包

登录时自动启动用 macOS 13 起提供的 SMAppService,一行代码注册,出现在系统设置的登录项里。比自己写 LaunchAgent 简单,也不会留下残留文件。最低系统版本因此定为 macOS 13。

打包用系统自带的 hdiutil:DMG 里放 App 和一个指向应用程序文件夹的快捷方式,拖一下就装好。第三方的美化工具能做背景图和图标布局,但要额外的依赖,对一个自用起步的小工具不值得。